diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md index bfb3a9b42..a9cb754cc 100644 --- a/.claude/agent-memory/code-reviewer/MEMORY.md +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -31,6 +31,23 @@ - `window-resizing` class disables ALL layout transitions during native window resize - `[data-resize-handle-active]` sibling/parent selector disables panel transitions during drag +## Cross-Component Event Bus Pattern + +- `window.dispatchEvent(new CustomEvent("insert-to-chat", { detail }))` is the established + pattern for browser panel → chat input communication (both text and element insertion). + The listener lives in `MainLayout.tsx` useEffect with no deps (stable ref via `workspaceChatPanelRef`). +- Multi-tab (ChatArea with multiple SessionPanel tabs): only ONE SessionPanel tab is assigned the + ref at a time (last rendered wins via ref={workspaceChatPanelRef} directly on the component). + Element insertion always goes to the currently-active chat tab. Acceptable current limitation. + +## XML Attribute Serialization Risk Pattern + +- `serializeInspectElement` in `parseInspectTags.ts` embeds user-controlled string values (innerText, + path, tagName, reactComponent) into XML attribute values using double-quote delimiters with NO + escaping. A `"` in any of these fields breaks `attrRegex = /(\w+)="([^"]*)"/g` parsing and + corrupts the tag. Real DOM innerText can contain `"` (button labels, link text, etc.). + Fix pattern: HTML-escape values before embedding in attributes. + ## Distribution / CI Patterns - `sed -i ''` is macOS/BSD syntax. On ubuntu-latest (GNU sed), use `sed -i "..."` (no empty-string arg). Scripts that use `sed -i ''` WILL fail in CI. @@ -39,7 +56,6 @@ - `tauri-action@v0` needs `includeUpdaterJson: true` (or it defaults true when updater is configured) — verify latest.json is uploaded as a release asset alongside the DMG. - `workflow_dispatch` without a branch filter can tag + push from any branch; restrict by adding `branches: [main]` under `on.workflow_dispatch` or add a guard step. - The app spawns system `node` binary (not bundled) — hardened runtime notarization may need `com.apple.security.cs.disable-library-validation` in Entitlements.plist if node's dylibs fail team-ID checks. - ## Icon Component Patterns (New) - `AppIcon` registry pattern: static `APP_ICON_MAP` record maps appId → icon component function diff --git a/.claude/agent-memory/deep-reviewer/MEMORY.md b/.claude/agent-memory/deep-reviewer/MEMORY.md new file mode 100644 index 000000000..45a062424 --- /dev/null +++ b/.claude/agent-memory/deep-reviewer/MEMORY.md @@ -0,0 +1,18 @@ +# Deep Reviewer Memory + +## Browser Automation Architecture +- Inject scripts live in `src/features/browser/automation/inject/` (TypeScript source) +- Compiled via esbuild to `dist-inject/` (IIFE format, gitignored) +- Consumer files import compiled output via Vite `?raw` imports +- Three independent scripts: `browser-utils` (`__hiveBrowserUtils`), `visual-effects` (`__hiveVisuals`), `inspect-mode` (`__hiveInspect`) +- Title-channel protocol uses `\x01` (SOH) prefix bytes -- verify hex dump, not text grep +- Build command: `bun run build:inject` (runs before dev/build) + +## Common Patterns to Watch +- `waitForDomSettle` timer cleanup: ensure all timers are cleared on all exit paths +- Dead parameters: `slowly` in `buildTypeJs` is accepted but never used +- `data-hive-ref` is used for both tree snapshots and inspect mode element refs + +## Review Infrastructure +- Reviews go to `.context/reviews/review-NN.md` +- First review was review-01 on 2026-02-21 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9176fa3b1..efdff11a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Build inject scripts + run: bun run build:inject + - name: Run sidecar unit tests run: bun run test:sidecar:unit diff --git a/.gitignore b/.gitignore index 6d7f6af69..bdaf427a1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ src-tauri/target/ src-tauri/WixTools/ # Sidecar build artifact (built via `bun run build:sidecar`) src-tauri/resources/bin/index.bundled.cjs +# Browser inject build artifacts (built via `bun run build:inject`) +src/features/browser/automation/dist-inject/ # Misc .DS_Store diff --git a/package.json b/package.json index 7694ad0ed..6fdc6c6e0 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "packageManager": "bun@1.2.19", "type": "module", "scripts": { - "dev": "bun run build:sidecar && tauri dev", + "dev": "bun run build:inject && bun run build:sidecar && tauri dev", "dev:web": "./scripts/dev.sh", "dev:frontend": "vite", "dev:backend": "node backend/server.cjs", - "build": "bun run build:sidecar && tsc && vite build", - "build:tauri": "bun run build:sidecar && tauri build", + "build": "bun run build:inject && bun run build:sidecar && tsc && vite build", + "build:tauri": "bun run build:inject && bun run build:sidecar && tauri build", + "build:inject": "bunx tsx src/features/browser/automation/build-inject.ts", "build:sidecar": "bunx tsx sidecar/build.ts && bun install --frozen-lockfile --cwd packages/mcp-notebook && bunx tsx packages/mcp-notebook/build.ts", "preview": "vite preview", "tauri": "tauri", diff --git a/scripts/dev.sh b/scripts/dev.sh index 9d9e9ccd4..378ceda9a 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -29,6 +29,12 @@ if [ -n "$STALE_PID" ]; then sleep 0.3 fi +# Build browser inject scripts (TypeScript → IIFE for WKWebView) +echo -e "${BLUE}Building browser inject scripts...${NC}" +bun run build:inject +echo -e "${GREEN}✓ Inject scripts built${NC}" +echo "" + # Start backend server with dynamic port echo -e "${BLUE}Starting backend server with dynamic port...${NC}" PORT=0 node backend/server.cjs > /tmp/backend.log 2>&1 & diff --git a/sidecar/agents/hive-tools/browser.ts b/sidecar/agents/hive-tools/browser.ts index 9f320f6ad..60d6a0538 100644 --- a/sidecar/agents/hive-tools/browser.ts +++ b/sidecar/agents/hive-tools/browser.ts @@ -18,7 +18,7 @@ import { FrontendClient } from "../../frontend-client"; // Snapshot file-based fallback constants // ============================================================================ -// Two-tier thresholds matching Cursor's approach: +// Two-tier thresholds: // - Action tools (click, type, hover, etc.): 25 KB — keeps context compact // - Dedicated snapshot tool: 200 KB — user explicitly asked for a snapshot const SNAPSHOT_SIZE_THRESHOLD = 25 * 1024; // 25 KB for action tools diff --git a/sidecar/test/browser-templates.test.ts b/sidecar/test/browser-templates.test.ts index 36846c055..881f5701f 100644 --- a/sidecar/test/browser-templates.test.ts +++ b/sidecar/test/browser-templates.test.ts @@ -14,7 +14,6 @@ import { describe, it, expect } from "vitest"; // Import templates directly — they're pure functions with no platform deps import { SNAPSHOT_JS, - BROWSER_UTILS, CONSOLE_MESSAGES_JS, NETWORK_REQUESTS_JS, buildClickJs, @@ -38,10 +37,14 @@ import { // Helpers // ======================================================================== -/** Check that a JS string is a self-invoking function expression */ +/** Check that a JS string is a self-invoking function expression. + * Accepts both classic IIFEs `(function(){...})()` and arrow IIFEs + * `(() => {...})()` — esbuild's `format: "iife"` emits the latter. */ function assertIsIIFE(js: string, name: string) { const trimmed = js.trim(); - expect(trimmed, `${name} should start with (function`).toMatch(/^\(function\s*\(/); + // esbuild may prepend "use strict"; — strip it for the shape check + const body = trimmed.replace(/^"use strict";\s*/, ""); + expect(body, `${name} should start with (function or (() =>`).toMatch(/^\((?:function\s*\(|(?:\(\)\s*=>))/); expect(trimmed, `${name} should end with )()`).toMatch(/\)\(\)\s*;?\s*$/); } @@ -191,14 +194,6 @@ describe("browser-utils JS templates", () => { assertHasReturnJson(buildPressKeyJs("Enter"), "buildPressKeyJs"); }); - it("includes BROWSER_UTILS inside the IIFE", () => { - const js = buildPressKeyJs("Tab"); - // BROWSER_UTILS should be inside the IIFE, not before it - const iifeStart = js.indexOf("(function(){"); - const utilsStart = js.indexOf("buildPageSnapshot"); - expect(iifeStart).toBeLessThan(utilsStart); - }); - it("supports modifier keys", () => { const js = buildPressKeyJs("a", { ctrl: true, shift: true }); expect(js).toContain("ctrlKey: true"); diff --git a/src-tauri/src/commands/webview.rs b/src-tauri/src/commands/webview.rs index 9c38359d4..884de5546 100644 --- a/src-tauri/src/commands/webview.rs +++ b/src-tauri/src/commands/webview.rs @@ -77,7 +77,7 @@ const BROWSER_INIT_SCRIPT: &str = r#"(function(){ try { _origTitle = document.title; document.title = '\x01CN:' + location.href; - setTimeout(function() { document.title = _origTitle; }, 0); + setTimeout(function() { document.title = _origTitle; }, 60); } catch(e) {} } history.pushState = function(){ _push.apply(history, arguments); notifyNav(); }; @@ -227,8 +227,11 @@ pub async fn create_browser_webview( return; } - // Element selected in inspect mode: "\x01CE:{json}" - // Emitted when user clicks an element or drag-selects an area + // Inspect mode: element event "\x01CE:{json}" + // NOTE: The inject script no longer uses the title-channel for inspect + // events (buffer+drain via eval_browser_webview_with_result is the sole + // path). This handler is kept for backward compatibility but should not + // fire in normal operation. if title.starts_with("\x01CE:") { let json_str = &title[4..]; app_for_title @@ -243,8 +246,7 @@ pub async fn create_browser_webview( return; } - // Selection mode state change: "\x01CS:{json}" - // Emitted when inspect mode is enabled/disabled + // Inspect mode: selection-mode change "\x01CS:{json}" if title.starts_with("\x01CS:") { let json_str = &title[4..]; app_for_title @@ -339,9 +341,7 @@ pub async fn show_browser_webview(app: AppHandle, label: String) -> Result<(), S .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - webview - .show() - .map_err(|e| format!("Failed to show webview: {}", e)) + webview.show().map_err(|e| format!("Failed to show webview: {}", e)) } /// Hide a browser webview (keeps it alive but invisible). @@ -351,9 +351,7 @@ pub async fn hide_browser_webview(app: AppHandle, label: String) -> Result<(), S .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - webview - .hide() - .map_err(|e| format!("Failed to hide webview: {}", e)) + webview.hide().map_err(|e| format!("Failed to hide webview: {}", e)) } /// Close and destroy a browser webview. @@ -424,20 +422,54 @@ pub async fn eval_browser_webview_with_result( { let webview = app .get_webview(&label) - .ok_or_else(|| format!("Webview '{}' not found", label))?; + .ok_or_else(|| { + if cfg!(debug_assertions) { + eprintln!("[eval_with_result] Webview '{}' not found", label); + } + format!("Webview '{}' not found", label) + })?; let (tx, rx) = std_mpsc::channel::>(); let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30000)); + // Log first 80 chars of JS for diagnostics (avoid spamming large scripts) + let js_preview: String = js.chars().take(80).collect(); + let is_drain = js.contains("drainEvents") || js.contains("__HIVE_LOGS__"); + webview .with_webview(move |platform_wv| { let raw_ptr = platform_wv.inner() as *mut std::ffi::c_void; eval_js_wkwebview(raw_ptr, &js, tx); }) - .map_err(|e| format!("Failed to access webview: {}", e))?; + .map_err(|e| { + if cfg!(debug_assertions) { + eprintln!("[eval_with_result] with_webview failed for '{}': {}", label, e); + } + format!("Failed to access webview: {}", e) + })?; - rx.recv_timeout(timeout) - .map_err(|e| format!("JS eval timed out: {}", e))? + let result = rx.recv_timeout(timeout) + .map_err(|e| { + if cfg!(debug_assertions) { + eprintln!("[eval_with_result] TIMEOUT for '{}' ({}ms) js: {}...", label, timeout.as_millis(), js_preview); + } + format!("JS eval timed out: {}", e) + })?; + + // Log drain results at debug level (frequent calls) + if cfg!(debug_assertions) && is_drain { + if let Ok(ref val) = result { + if val != "[]" && val != "undefined" { + eprintln!("[eval_with_result] drain returned data for '{}': {}...", + label, + val.chars().take(120).collect::()); + } + } else if let Err(ref err) = result { + eprintln!("[eval_with_result] drain ERROR for '{}': {}", label, err); + } + } + + result } #[cfg(not(target_os = "macos"))] @@ -447,6 +479,188 @@ pub async fn eval_browser_webview_with_result( } } +/// Open native DevTools (WebKit Inspector) for a browser webview. +/// +/// Currently opens as a **detached floating window**. The `docked` parameter +/// is accepted but ignored — docking inside the browser panel is not yet +/// implemented (see TODO below). +/// +/// ## TODO: Docked DevTools inside the browser panel +/// +/// **Goal:** Inspector docked at the bottom of the browser panel (like Chrome), +/// not in a separate floating window. +/// +/// **Why it's hard:** `_inspector.show()` docks the inspector by splitting the +/// WKWebView's superview. In Tauri v2 multi-webview, that superview is the +/// NSWindow's content view — shared by ALL webviews (main app UI + browser). +/// Splitting it breaks the entire layout. +/// +/// **Approaches tried (all failed with ObjC exceptions):** +/// +/// 1. **Container NSView wrapping** (recommended by 4/5 eng-explore personas): +/// Wrap the WKWebView in an intermediate NSView (tag=9999) so inspector.show() +/// splits the container instead of the content view. +/// - Tried at creation time (in create_browser_webview): `with_webview` dispatches +/// async to main thread — WKWebView not yet in view hierarchy → ObjC exception. +/// - Tried lazily (first set_browser_webview_bounds call): WKWebView is live, but +/// creating an NSView via `msg_send![class!(NSView), new]` and then calling ANY +/// method on it (`setTag:`, `setFrame:`) triggers "Rust cannot catch foreign +/// exceptions, aborting". The crash point is non-deterministic across runs, +/// suggesting memory corruption — likely an ARM64 ABI mismatch where the `objc` +/// crate (0.2.x) passes CGRect structs through `objc_msgSend`'s variadic calling +/// convention instead of using HFA (Homogeneous Floating-point Aggregate) registers. +/// +/// 2. **View Theft** (steal inspector from its floating window, reparent into app): +/// Call show() + detach() to get a floating inspector window, then steal its +/// contentView and reparent it into the main window. Successfully steals the view +/// but crashes immediately: "Rust cannot catch foreign exceptions" — moving WebKit's +/// internal views between windows violates internal invariants. +/// +/// **Possible future approaches:** +/// +/// - **objc2 crate** instead of objc 0.2.x: Uses typed selectors and correct ARM64 ABI +/// for struct parameters. Would fix the suspected CGRect calling convention issue. +/// Requires significant refactoring of all msg_send! calls in this file. +/// +/// - **Small ObjC helper (.m file)** compiled as part of the build: Write the container +/// wrapping logic in native ObjC (with @try/@catch for exception safety) and call it +/// from Rust via C FFI. Avoids the `objc` crate's ABI issues entirely. +/// +/// - **CALayer masking** instead of NSView container: Set masksToBounds on the content +/// view's layer at the browser panel bounds. Doesn't require creating new NSViews. +/// Downside: affects all views in the content view, not just the browser. +/// +/// - **Tauri v3** may have better multi-webview support with proper view isolation, +/// making the container approach unnecessary. +#[tauri::command] +pub async fn open_browser_devtools( + app: AppHandle, + label: String, + docked: Option, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let webview = app + .get_webview(&label) + .ok_or_else(|| format!("Webview '{}' not found", label))?; + + // docked param accepted for future use but currently always detaches + let _ = docked; + + let err_holder = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let err_inner = err_holder.clone(); + + webview + .with_webview(move |platform_wv| { + use objc::runtime::Object; + use objc::{msg_send, sel, sel_impl}; + + #[repr(C)] + #[derive(Clone, Copy)] + struct CGSize { width: f64, height: f64 } + #[repr(C)] + #[derive(Clone, Copy)] + struct CGPoint { x: f64, y: f64 } + #[repr(C)] + #[derive(Clone, Copy)] + struct CGRect { origin: CGPoint, size: CGSize } + + unsafe { + let wk: *mut Object = platform_wv.inner() as *mut Object; + if wk.is_null() { + *err_inner.lock().unwrap() = Some("WKWebView pointer is null".into()); + return; + } + let inspector: *mut Object = msg_send![wk, _inspector]; + if inspector.is_null() { + *err_inner.lock().unwrap() = Some("_inspector is null — DevTools may be disabled".into()); + return; + } + + // Save the WKWebView's frame before show() — inspector.show() docks + // by splitting the superview, which resizes the WKWebView. After + // detach() moves the inspector to a floating window, the WKWebView + // frame isn't fully restored. We save and restore it explicitly. + let saved_frame: CGRect = msg_send![wk, frame]; + + // show() connects the inspector, detach() puts it in a floating window. + // show() alone would dock (split content view), breaking multi-webview layout. + let _: () = msg_send![inspector, show]; + let _: () = msg_send![inspector, detach]; + + // Restore the WKWebView's original frame (undoes the split resize) + let _: () = msg_send![wk, setFrame: saved_frame]; + + if cfg!(debug_assertions) { + eprintln!("[devtools] Inspector opened (floating window), frame restored"); + } + } + }) + .map_err(|e| format!("Failed to access webview: {}", e))?; + + if let Some(err_msg) = err_holder.lock().unwrap().take() { + return Err(err_msg); + } + + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app, label, docked); + Err("DevTools are only supported on macOS".to_string()) + } +} + +/// Close the inspector for a browser webview. +#[tauri::command] +pub async fn close_browser_devtools(app: AppHandle, label: String) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let webview = app + .get_webview(&label) + .ok_or_else(|| format!("Webview '{}' not found", label))?; + + let err_holder = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let err_inner = err_holder.clone(); + + webview + .with_webview(move |platform_wv| { + use objc::runtime::Object; + use objc::{msg_send, sel, sel_impl}; + unsafe { + let wk: *mut Object = platform_wv.inner() as *mut Object; + if wk.is_null() { + *err_inner.lock().unwrap() = Some("WKWebView pointer is null".into()); + return; + } + let inspector: *mut Object = msg_send![wk, _inspector]; + if inspector.is_null() { + *err_inner.lock().unwrap() = Some("_inspector is null — DevTools may be disabled".into()); + return; + } + let _: () = msg_send![inspector, close]; + if cfg!(debug_assertions) { + eprintln!("[devtools] Inspector closed"); + } + } + }) + .map_err(|e| format!("Failed to access webview: {}", e))?; + + if let Some(err_msg) = err_holder.lock().unwrap().take() { + return Err(err_msg); + } + + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app, label); + Err("DevTools only supported on macOS".to_string()) + } +} + /// Reload a browser webview. #[tauri::command] pub async fn reload_browser_webview(app: AppHandle, label: String) -> Result<(), String> { @@ -476,8 +690,9 @@ pub async fn drain_browser_console(app: AppHandle, label: String) -> Result<(), .get_webview(&label) .ok_or_else(|| format!("Webview '{}' not found", label))?; - // Uses setTimeout to restore title in next tick — prevents WKWebView - // from coalescing the title changes (same fix as SPA navigation detection). + // Uses setTimeout(60ms) to restore title — gives WKWebView's cross-process + // KVO enough time to observe the title change before it's restored. + // setTimeout(0) was too fast and caused message drops. webview .eval( r#"(function(){ @@ -486,7 +701,7 @@ pub async fn drain_browser_console(app: AppHandle, label: String) -> Result<(), if(b.length > 0) { var t = document.title; document.title = '\x01CL:' + JSON.stringify(b); - setTimeout(function() { document.title = t; }, 0); + setTimeout(function() { document.title = t; }, 60); } })()"#, ) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 91c6917c6..b8cb2fde5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -251,6 +251,8 @@ fn main() { commands::eval_browser_webview, commands::eval_browser_webview_with_result, commands::reload_browser_webview, + commands::open_browser_devtools, + commands::close_browser_devtools, commands::drain_browser_console, commands::get_cookie_browsers, commands::sync_browser_cookies, diff --git a/src/app/layouts/MainLayout.tsx b/src/app/layouts/MainLayout.tsx index 644e6092e..4591d6478 100644 --- a/src/app/layouts/MainLayout.tsx +++ b/src/app/layouts/MainLayout.tsx @@ -215,13 +215,40 @@ export function MainLayout() { }, }); - // Listen for 'insert-to-chat' events from BrowserPanel + // Listen for 'insert-to-chat' events from BrowserPanel / DiffViewer useEffect(() => { const handleInsertToChat = (event: Event) => { - if (!(event instanceof CustomEvent)) return; - const raw = (event.detail as { text?: string } | undefined)?.text; - const text = typeof raw === "string" ? raw.trim() : ""; - if (text && workspaceChatPanelRef.current) { + if (!(event instanceof CustomEvent) || !workspaceChatPanelRef.current) return; + const detail = event.detail as { text?: string; element?: Record; files?: File[] } | undefined; + + // File attachment (e.g., browser screenshot) + if (detail?.files?.length) { + workspaceChatPanelRef.current.addFiles(detail.files); + return; + } + + // Element insertion from InSpec mode + if (detail?.element) { + workspaceChatPanelRef.current.addInspectedElement(detail.element as { + ref: string; + tagName: string; + path: string; + innerText?: string; + context?: "local" | "external"; + reactComponent?: string; + file?: string; + line?: string; + styles?: string; + props?: string; + attributes?: string; + innerHTML?: string; + }); + return; + } + + // Plain text insertion (e.g., from DiffViewer) + const text = typeof detail?.text === "string" ? detail.text.trim() : ""; + if (text) { workspaceChatPanelRef.current.insertText(text); } }; diff --git a/src/features/browser/automation/browser-utils.ts b/src/features/browser/automation/browser-utils.ts index 6bfd55e1d..1cebb2262 100644 --- a/src/features/browser/automation/browser-utils.ts +++ b/src/features/browser/automation/browser-utils.ts @@ -1,401 +1,35 @@ // src/features/browser/automation/browser-utils.ts -// JavaScript code that executes INSIDE the webview to build accessibility trees, -// simulate clicks, type text, and interact with page elements. +// Re-exports the compiled browser utils IIFE + builder functions. // -// Architecture: These are string constants containing JS code. The frontend -// wraps them in eval calls via Tauri's eval_browser_webview_with_result command. -// Results are returned directly via WKWebView's evaluateJavaScript:completionHandler:. +// The source TypeScript lives in inject/browser-utils.ts with full IDE support. +// It's compiled by build-inject.ts (esbuild) into dist-inject/browser-utils.js. // -// Ported from Cursor's BROWSER_UTILS pattern with enhancements from mcp-dev-browser. - -/** - * Core utility functions injected into every browser automation call. - * Includes: accessibility tree builder, element finder, event helpers. - * - * ~400 lines of self-contained JS with no external dependencies. - */ -export const BROWSER_UTILS = ` -// ======================================================================== -// Accessibility Tree Builder (ported from Cursor's browser automation) -// ======================================================================== - -function getTextFromIds(ids) { - if (!ids) return ''; - return ids.split(' ').map(function(id) { - var el = document.getElementById(id); - return el ? (el.textContent || '').trim() : ''; - }).filter(Boolean).join(' '); -} - -function getVisibleText(el) { - try { - var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { - acceptNode: function(node) { - var parent = node.parentElement; - if (!parent) return NodeFilter.FILTER_REJECT; - var style = window.getComputedStyle(parent); - if (style.display === 'none' || style.visibility === 'hidden') return NodeFilter.FILTER_REJECT; - return NodeFilter.FILTER_ACCEPT; - } - }); - var text = ''; - while (walker.nextNode()) { - text += ' ' + walker.currentNode.textContent; - if (text.length > 200) break; - } - return text.replace(/\\s+/g, ' ').trim().substring(0, 200); - } catch(e) { - var raw = el.innerText || el.textContent || ''; - return raw.replace(/\\s+/g, ' ').trim().substring(0, 200); - } -} - -function getLabelsText(el) { - try { - if (!el.labels || !el.labels.length) return ''; - return Array.from(el.labels).map(function(l) { - return getVisibleText(l); - }).filter(Boolean).join(' ').substring(0, 200); - } catch(e) { return ''; } -} - -function getImplicitRole(el) { - var tag = el.tagName.toLowerCase(); - switch(tag) { - case 'a': return el.hasAttribute('href') ? 'link' : 'generic'; - case 'button': case 'summary': return 'button'; - case 'input': - var t = (el.type || 'text').toLowerCase(); - if (t === 'button' || t === 'submit' || t === 'reset' || t === 'image') return 'button'; - if (t === 'checkbox') return 'checkbox'; - if (t === 'radio') return 'radio'; - if (t === 'range') return 'slider'; - if (t === 'number') return 'spinbutton'; - return 'textbox'; - case 'select': return (el.multiple || (el.size && el.size > 1)) ? 'listbox' : 'combobox'; - case 'option': return 'option'; - case 'textarea': return 'textbox'; - case 'img': case 'svg': return 'img'; - case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading'; - case 'ul': case 'ol': return 'list'; - case 'li': return 'listitem'; - case 'nav': return 'navigation'; - case 'main': return 'main'; - case 'header': return 'banner'; - case 'footer': return 'contentinfo'; - case 'form': return 'form'; - case 'table': return 'table'; - case 'tr': return 'row'; - case 'td': return 'cell'; - case 'th': return 'columnheader'; - case 'section': return 'section'; - case 'article': return 'article'; - case 'aside': return 'aside'; - case 'details': return 'group'; - case 'progress': return 'progressbar'; - case 'meter': return 'meter'; - case 'label': return 'label'; - default: return 'generic'; - } -} - -function computeAccessibleName(el, role) { - if (el.getAttribute('aria-hidden') === 'true') return ''; - var labelledBy = el.getAttribute('aria-labelledby'); - if (labelledBy) { var t = getTextFromIds(labelledBy); if (t) return t.substring(0, 200); } - var ariaLabel = el.getAttribute('aria-label'); - if (ariaLabel) return ariaLabel.substring(0, 200); - var placeholder = el.getAttribute('aria-placeholder'); - if (placeholder) return placeholder.substring(0, 200); - var labels = getLabelsText(el); - if (labels) return labels; - var tag = el.tagName.toLowerCase(); - if (tag === 'img') { var alt = el.getAttribute('alt'); if (alt) return alt.substring(0, 200); } - if (tag === 'input') { - if (['button','submit','reset'].includes((el.type||'').toLowerCase())) { - if (el.value) return el.value.substring(0, 200); - } - return (el.placeholder || el.value || '').substring(0, 200); - } - if (tag === 'textarea') return (el.placeholder || el.value || '').substring(0, 200); - if (tag === 'select') { - var opts = Array.from(el.selectedOptions || []).map(function(o) { return o.text; }); - if (opts.length) return opts.join(', ').substring(0, 200); - } - var interactiveTags = ['button','a','h1','h2','h3','h4','h5','h6','label','p','li','summary']; - if (interactiveTags.includes(tag) || role === 'button' || role === 'link' || role === 'heading') { - return getVisibleText(el); - } - var title = el.getAttribute('title'); - if (title) return title.substring(0, 200); - return ''; -} - -function collectElementStates(el, role) { - var states = []; - try { - if (el.matches(':focus')) states.push('focused'); - if (el.matches(':disabled')) states.push('disabled'); - if (el.checked) states.push('checked'); - if (el.required) states.push('required'); - if (el.readOnly) states.push('readonly'); - if (el.selected) states.push('selected'); - } catch(e) {} - var ariaStates = { - 'aria-selected': 'selected', 'aria-expanded': null, 'aria-pressed': 'pressed', - 'aria-current': 'current', 'aria-invalid': 'invalid', 'aria-busy': 'busy' - }; - for (var attr in ariaStates) { - var val = el.getAttribute(attr); - if (val && val !== 'false') { - if (attr === 'aria-expanded') states.push(val === 'true' ? 'expanded' : 'collapsed'); - else states.push(ariaStates[attr] || attr.replace('aria-', '')); - } - } - return [...new Set(states)]; -} - -function collectElementDetails(el, role) { - var details = {}; - var desc = el.getAttribute('aria-description') || ''; - var descBy = el.getAttribute('aria-describedby'); - if (descBy) desc = (desc + ' ' + getTextFromIds(descBy)).trim(); - if (desc) details.description = desc.substring(0, 200); - var tag = el.tagName.toLowerCase(); - if (tag === 'a' && el.href) details.url = el.href; - if ((tag === 'img' || tag === 'svg') && el.src) details.src = el.src; - if (tag === 'input' || tag === 'textarea') { - if ((el.type || '').toLowerCase() !== 'password') { - if (el.value) details.value = el.value.substring(0, 200); - } - if (el.placeholder) details.placeholder = el.placeholder.substring(0, 200); - } - if (tag === 'select') { - var opts = Array.from(el.selectedOptions || []).map(function(o) { return o.text; }); - if (opts.length) details.value = opts.join(', ').substring(0, 200); - } - return details; -} - -function shouldIncludeElement(el) { - if (el.getAttribute('aria-hidden') === 'true') return false; - var tag = el.tagName.toLowerCase(); - var includeTags = ['a','button','input','select','textarea','img','svg', - 'h1','h2','h3','h4','h5','h6','nav','main','header','footer', - 'section','article','form','label','ul','ol','li','p','summary','details']; - if (includeTags.includes(tag)) return true; - var role = el.getAttribute('role'); - if (role && role !== 'generic' && role !== 'presentation' && role !== 'none') return true; - if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')) return true; - if (el.contentEditable === 'true') return true; - if (el.querySelector('a,button,input,select,textarea')) return true; - return false; -} - -// Global node counter — caps traversal to prevent timeouts on heavy pages. -// Reset before each snapshot; shared across recursive calls. -var __nodeCount = 0; -var __NODE_LIMIT = 3000; - -function buildAccessibilityTree(element, depth, maxDepth) { - if (depth > maxDepth || __nodeCount >= __NODE_LIMIT) return null; - __nodeCount++; - // Assign or reuse data-cursor-ref - var ref = element.getAttribute('data-cursor-ref'); - if (!ref) { - ref = 'ref-' + Math.random().toString(36).substring(2, 15); - element.setAttribute('data-cursor-ref', ref); - } - var role = element.getAttribute('role') || getImplicitRole(element); - var name = computeAccessibleName(element, role); - var states = collectElementStates(element, role); - var details = collectElementDetails(element, role); - var node = { ref: ref, role: role, name: name }; - // Add heading level - var tag = element.tagName.toLowerCase(); - if (/^h[1-6]$/.test(tag)) node.level = parseInt(tag[1]); - if (states.length) node.states = states; - Object.assign(node, details); - // Recurse children (bail early if node limit reached) - var children = []; - for (var i = 0; i < element.children.length && __nodeCount < __NODE_LIMIT; i++) { - var child = element.children[i]; - if (shouldIncludeElement(child)) { - var childNode = buildAccessibilityTree(child, depth + 1, maxDepth); - if (childNode) children.push(childNode); - } - } - if (children.length) node.children = children; - return node; -} - -function buildPageSnapshot() { - __nodeCount = 0; - return buildAccessibilityTree(document.body, 0, 20); -} - -// ======================================================================== -// YAML Formatter (token-efficient output for AI consumption) -// ======================================================================== - -function accessibilityTreeToYaml(node, indent) { - indent = indent || 0; - if (!node) return ''; - var pad = ' '.repeat(indent); - var lines = []; - lines.push(pad + '- role: ' + node.role); - if (node.name) { - var escaped = /[:"\\[]/.test(node.name) ? '"' + node.name.replace(/"/g, '\\\\"') + '"' : node.name; - lines.push(pad + ' name: ' + escaped); - } - lines.push(pad + ' ref: ' + node.ref); - if (node.level) lines.push(pad + ' level: ' + node.level); - if (node.states && node.states.length) lines.push(pad + ' states: [' + node.states.join(', ') + ']'); - if (node.url) lines.push(pad + ' url: ' + node.url); - if (node.value) lines.push(pad + ' value: ' + node.value); - if (node.placeholder) lines.push(pad + ' placeholder: ' + node.placeholder); - if (node.description) lines.push(pad + ' description: ' + node.description); - if (node.children && node.children.length) { - lines.push(pad + ' children:'); - for (var i = 0; i < node.children.length; i++) { - lines.push(accessibilityTreeToYaml(node.children[i], indent + 2)); - } - } - return lines.join('\\n'); -} - -// ======================================================================== -// Element Interaction Helpers -// ======================================================================== - -function findElementByRef(ref) { - return document.querySelector('[data-cursor-ref="' + ref + '"]'); -} - -function scrollIntoViewIfNeeded(el) { - var rect = el.getBoundingClientRect(); - var inView = rect.top >= 0 && rect.left >= 0 - && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) - && rect.right <= (window.innerWidth || document.documentElement.clientWidth); - if (!inView) el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto' }); -} - -function getElementCenter(el) { - var rect = el.getBoundingClientRect(); - return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }; -} - -function simulateClick(el, opts) { - opts = opts || {}; - scrollIntoViewIfNeeded(el); - var center = getElementCenter(el); - var eventOpts = { - bubbles: true, cancelable: true, view: window, - button: 0, buttons: 1, - clientX: center.x, clientY: center.y, - ctrlKey: false, shiftKey: false, altKey: false, metaKey: false - }; - el.focus(); - el.dispatchEvent(new MouseEvent('mousedown', eventOpts)); - el.dispatchEvent(new MouseEvent('mouseup', eventOpts)); - el.dispatchEvent(new MouseEvent('click', eventOpts)); - if (opts.doubleClick) { - el.dispatchEvent(new MouseEvent('mousedown', eventOpts)); - el.dispatchEvent(new MouseEvent('mouseup', eventOpts)); - el.dispatchEvent(new MouseEvent('click', eventOpts)); - el.dispatchEvent(new MouseEvent('dblclick', eventOpts)); - } -} - -function simulateType(el, text, opts) { - opts = opts || {}; - scrollIntoViewIfNeeded(el); - el.focus(); - if (el.contentEditable === 'true') { - el.textContent = text; - el.dispatchEvent(new Event('input', { bubbles: true })); - } else { - // Use the native value setter to bypass React's internal value tracker. - // React intercepts el.value = X but only fires onChange if its tracker - // sees a change. Setting via the prototype setter updates the tracker. - var nativeSetter = Object.getOwnPropertyDescriptor( - el.tagName.toLowerCase() === 'textarea' - ? window.HTMLTextAreaElement.prototype - : window.HTMLInputElement.prototype, - 'value' - ); - if (nativeSetter && nativeSetter.set) { - nativeSetter.set.call(el, text); - } else { - el.value = text; - } - // Dispatch InputEvent (not just Event) with data + inputType for - // framework compatibility (React, Vue, Angular all check these). - el.dispatchEvent(new InputEvent('input', { - bubbles: true, cancelable: true, - data: text, inputType: 'insertText' - })); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - if (opts.submit) { - el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); - el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })); - var form = el.closest && el.closest('form'); - if (form) form.requestSubmit ? form.requestSubmit() : form.submit(); - } -} - -// ======================================================================== -// DOM Settle — wait for DOM to stop changing after an action -// ======================================================================== -// Uses MutationObserver to detect a "quiet period" where no DOM mutations -// occur. This ensures SPA frameworks (React, Next.js, Vue) have finished -// re-rendering after a click/type/hover before we capture the snapshot. +// To modify browser utilities, edit inject/browser-utils.ts and +// run `bun run build:inject` to recompile. +// +// Builder functions stay here because they're TypeScript functions that +// parameterize small runtime snippets — not worth extracting. // -// Parameters: -// quietMs — ms of silence required to consider DOM settled (default: 150) -// maxMs — hard cap to prevent infinite waiting (default: 2000) +// KEY CHANGE: BROWSER_UTILS (~390 lines) was previously string-interpolated +// into every builder call. Now it's injected ONCE on page load via +// BROWSER_UTILS_SETUP, and builders reference window.__hiveBrowserUtils. -function waitForDomSettle(quietMs, maxMs) { - quietMs = quietMs || 150; - maxMs = maxMs || 2000; - return new Promise(function(resolve) { - var timer = null; - var maxTimer = null; - var observer = new MutationObserver(function() { - if (timer) clearTimeout(timer); - timer = setTimeout(function() { - observer.disconnect(); - if (maxTimer) clearTimeout(maxTimer); - resolve(); - }, quietMs); - }); - observer.observe(document.body, { - childList: true, subtree: true, attributes: true, characterData: true - }); - // Start quiet timer immediately (resolves if no mutations at all) - timer = setTimeout(function() { - observer.disconnect(); - resolve(); - }, quietMs); - // Hard cap: always resolve by maxMs - maxTimer = setTimeout(function() { - observer.disconnect(); - if (timer) clearTimeout(timer); - resolve(); - }, maxMs); - }); -} -`; +/** The IIFE string to eval in WKWebView — installs browser utils on window.__hiveBrowserUtils. */ +import BROWSER_UTILS_SETUP from './dist-inject/browser-utils.js?raw'; +export { BROWSER_UTILS_SETUP }; + +// Shorthand preamble for builder functions — verifies utils are loaded. +const HIVE = `var hive = window.__hiveBrowserUtils; +if (!hive) return JSON.stringify({ success: false, error: 'Browser utils not initialized' });`; /** - * JS code to capture a page snapshot. Prepends BROWSER_UTILS. + * JS code to capture a page snapshot. * Returns: { snapshot: string, url: string, title: string } */ export const SNAPSHOT_JS = `(function(){ -${BROWSER_UTILS} -var tree = buildPageSnapshot(); -var yaml = accessibilityTreeToYaml(tree, 0); +${HIVE} +var tree = hive.buildPageSnapshot(); +var yaml = hive.accessibilityTreeToYaml(tree, 0); return JSON.stringify({ snapshot: yaml, url: window.location.href, @@ -408,23 +42,23 @@ return JSON.stringify({ */ export function buildClickJs(ref: string, doubleClick?: boolean): string { return `(function(){ -${BROWSER_UTILS} -var el = findElementByRef(${JSON.stringify(ref)}); +${HIVE} +var el = hive.findElementByRef(${JSON.stringify(ref)}); if (!el) { return JSON.stringify({ success: false, error: 'Element not found: ' + ${JSON.stringify(ref)} }); } var urlBefore = window.location.href; -simulateClick(el, { doubleClick: ${!!doubleClick} }); -return waitForDomSettle(150, 2000).then(function() { +hive.simulateClick(el, { doubleClick: ${!!doubleClick} }); +return hive.waitForDomSettle(150, 2000).then(function() { // Double-settle for SPA navigation: if URL changed after first settle, // the framework is likely still fetching data before rendering the new page. // Wait a second round to catch the post-data-fetch re-render. if (window.location.href !== urlBefore) { - return waitForDomSettle(150, 3000); + return hive.waitForDomSettle(150, 3000); } }).then(function() { - var tree = buildPageSnapshot(); - var yaml = accessibilityTreeToYaml(tree, 0); + var tree = hive.buildPageSnapshot(); + var yaml = hive.accessibilityTreeToYaml(tree, 0); return JSON.stringify({ success: true, snapshot: yaml, url: window.location.href, title: document.title }); }); })()`; @@ -435,15 +69,15 @@ return waitForDomSettle(150, 2000).then(function() { */ export function buildTypeJs(ref: string, text: string, submit?: boolean, slowly?: boolean): string { return `(function(){ -${BROWSER_UTILS} -var el = findElementByRef(${JSON.stringify(ref)}); +${HIVE} +var el = hive.findElementByRef(${JSON.stringify(ref)}); if (!el) { return JSON.stringify({ success: false, error: 'Element not found: ' + ${JSON.stringify(ref)} }); } -simulateType(el, ${JSON.stringify(text)}, { submit: ${!!submit} }); -return waitForDomSettle(150, 2000).then(function() { - var tree = buildPageSnapshot(); - var yaml = accessibilityTreeToYaml(tree, 0); +hive.simulateType(el, ${JSON.stringify(text)}, { submit: ${!!submit} }); +return hive.waitForDomSettle(150, 2000).then(function() { + var tree = hive.buildPageSnapshot(); + var yaml = hive.accessibilityTreeToYaml(tree, 0); return JSON.stringify({ success: true, snapshot: yaml, url: window.location.href, title: document.title }); }); })()`; @@ -461,15 +95,15 @@ export function buildWaitForTextJs( intervalMs: number = 500 ): string { return `(function(){ -${BROWSER_UTILS} +${HIVE} return new Promise(function(resolve) { var deadline = Date.now() + ${timeoutMs}; var searchText = ${JSON.stringify(text)}; function poll() { var bodyText = document.body.innerText || ''; if (bodyText.indexOf(searchText) !== -1) { - var tree = buildPageSnapshot(); - var yaml = accessibilityTreeToYaml(tree, 0); + var tree = hive.buildPageSnapshot(); + var yaml = hive.accessibilityTreeToYaml(tree, 0); resolve(JSON.stringify({ success: true, snapshot: yaml, url: window.location.href, title: document.title @@ -498,15 +132,15 @@ export function buildWaitForTextGoneJs( intervalMs: number = 500 ): string { return `(function(){ -${BROWSER_UTILS} +${HIVE} return new Promise(function(resolve) { var deadline = Date.now() + ${timeoutMs}; var searchText = ${JSON.stringify(text)}; function poll() { var bodyText = document.body.innerText || ''; if (bodyText.indexOf(searchText) === -1) { - var tree = buildPageSnapshot(); - var yaml = accessibilityTreeToYaml(tree, 0); + var tree = hive.buildPageSnapshot(); + var yaml = hive.accessibilityTreeToYaml(tree, 0); resolve(JSON.stringify({ success: true, snapshot: yaml, url: window.location.href, title: document.title @@ -525,10 +159,6 @@ return new Promise(function(resolve) { })()`; } -// ======================================================================== -// BrowserHover — dispatch hover events on an element -// ======================================================================== - /** * JS code to hover over an element by ref. * Dispatches mouseenter → mouseover → mousemove at element center. @@ -536,13 +166,13 @@ return new Promise(function(resolve) { */ export function buildHoverJs(ref: string): string { return `(function(){ -${BROWSER_UTILS} -var el = findElementByRef(${JSON.stringify(ref)}); +${HIVE} +var el = hive.findElementByRef(${JSON.stringify(ref)}); if (!el) { return JSON.stringify({ success: false, error: 'Element not found: ' + ${JSON.stringify(ref)} }); } -scrollIntoViewIfNeeded(el); -var center = getElementCenter(el); +hive.scrollIntoViewIfNeeded(el); +var center = hive.getElementCenter(el); var opts = { bubbles: true, cancelable: true, view: window, clientX: center.x, clientY: center.y, @@ -551,18 +181,14 @@ var opts = { el.dispatchEvent(new MouseEvent('mouseenter', Object.assign({}, opts, { bubbles: false }))); el.dispatchEvent(new MouseEvent('mouseover', opts)); el.dispatchEvent(new MouseEvent('mousemove', opts)); -return waitForDomSettle(150, 2000).then(function() { - var tree = buildPageSnapshot(); - var yaml = accessibilityTreeToYaml(tree, 0); +return hive.waitForDomSettle(150, 2000).then(function() { + var tree = hive.buildPageSnapshot(); + var yaml = hive.accessibilityTreeToYaml(tree, 0); return JSON.stringify({ success: true, snapshot: yaml, url: window.location.href, title: document.title }); }); })()`; } -// ======================================================================== -// BrowserPressKey — dispatch keyboard events -// ======================================================================== - /** * JS code to press a key. Dispatches keydown → keyup (keypress omitted — deprecated). * Supports modifier keys: ctrlKey, shiftKey, altKey, metaKey. @@ -578,8 +204,9 @@ export function buildPressKeyJs( const alt = modifiers?.alt ?? false; const meta = modifiers?.meta ?? false; + // PressKey doesn't need browser utils (no snapshot, no element finder). + // It operates on document.activeElement directly. return `(function(){ -${BROWSER_UTILS} var key = ${JSON.stringify(key)}; var target = document.activeElement || document.body; @@ -644,10 +271,6 @@ ${BROWSER_UTILS} `; } -// ======================================================================== -// BrowserSelectOption — select dropdown values -// ======================================================================== - /** * JS code to select option(s) in a