← Commit history
v0.16.2 (insiders): nb-bridge#41: hover/move/wheel accept raw {x,y} (the stale_coordinates gate is clicks-only and names the verb), input_dispatch refuses an unknown type instead of clicking, real isTrusted wheel via input_dispatch type:wheel and the new nbrowser_scroll verb, screenshot toFile honours a caller path, readiness staleOnDisk covers connected profiles; extension 0.2.10; skills: updating-the-extension recipe.
10 files changed
+54−18
bin/nb-dashboard+1−1@@ -20,7 +20,7 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.16.1'; // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.2'; // keep in lockstep with package.json (pkg version) // SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now"). // The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is // proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.16.1+0.16.2
bridge/bridge.json+3−2@@ -2,8 +2,8 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.16.1",- "expectedExtensionVersion": "0.2.9",+ "version": "0.16.2",+ "expectedExtensionVersion": "0.2.10", "description": "Drives the user's REAL signed-in Chrome/Edge, in two layers. Layer 1 (nb) needs NOTHING installed and works on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing, the OAuth/consent path), nbrowser_os_windows, nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. Layer 2 (nbe, the Adom Native Browser Extension) unlocks the rest: CDP driving (navigate/click/read/screenshot) inside their logged-in profile. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.", "homepage": "https://wiki.adom.inc/adom/nb-bridge", "author": "Adom Inc.",@@ -67,6 +67,7 @@ "nbrowser_eval", "nbrowser_evaluate", "nbrowser_input_dispatch",+ "nbrowser_scroll", "nbrowser_click", "nbrowser_select", "nbrowser_set_file_input",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.16.1",+ "version": "0.16.2", "private": true, "description": "Native-browser bridge for Bridge (ab): layer 1 (nb) plus layer 2 via nbe, the Adom Native Browser Extension (Chrome + Edge).", "main": "server.js",
bridge/server.js+14−8@@ -1504,7 +1504,8 @@ const VERB_META = { nbrowser_screenshot_full_res: { hint: 'Full-resolution, full-PAGE screenshot via CDP (the entire scrollable page, not just the viewport). Still PAGE content only.', related: ['nbrowser_screenshot'], pitfalls: ['this is full-PAGE, NOT "fullscreen monitor" - it never captures the desktop or the user\'s other windows', 'large base64 can be slow over the relay - prefer toFile then pull_file', 'for the window frame or a native dialog/popup, use ab desktop_screenshot_window {hwnd | titleContains} (parent/child hwnds), never a monitor capture'] }, nbrowser_eval: { hint: 'Run JS in the page and get the result. Powerful - use read-only expressions to inspect state.', related: ['nbrowser_screenshot'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] }, nbrowser_evaluate: { hint: 'Alias of nbrowser_eval.', related: ['nbrowser_eval'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },- nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input (click/move/type/key).', related: ['nbrowser_click', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy'] },+ nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input. type: click | dblclick | rightclick | move | hover | wheel | type | key. An unknown type is REFUSED (unknown_input_type), it never becomes a click (from ext 0.2.10). wheel takes deltaX/deltaY at {x,y} (or selector/text) and is what a canvas camera zoom needs.', related: ['nbrowser_click', 'nbrowser_scroll', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy', 'the stale_coordinates freshness gate applies to CLICKS only: hover/move/wheel accept raw {x,y} without atScrollX/Y (ext 0.2.10)', 'there is no "mouseWheel" type; say wheel (mousewheel and scroll are accepted aliases)'] },+ nbrowser_scroll: { hint: 'Real isTrusted mouse WHEEL at a point: {sessionId, x, y | selector | text, deltaX?, deltaY, steps?}. deltaY:-300 zooms a canvas camera in (or scrolls up), +300 out (or down). This is the verb for Babylon/three.js canvas zoom and for scrolling a pane the page owns; press_key cannot do it and page-level scrolling is not it.', related: ['nbrowser_input_dispatch', 'nbrowser_hover', 'nbrowser_screenshot'], pitfalls: ['pageScrolled:false is NORMAL on a canvas that consumed the wheel (a zoom is not a scroll): verify with a screenshot or an eval of the app state', 'no freshness gate: a wheel at a stale point is harmless motion, unlike a click', 'split a big delta with steps (e.g. steps:5) when an app clamps per-event zoom'] }, nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Scrolls the target into view, re-resolves its rect, and RETURNS changed:bool - whether the URL/DOM actually changed after the click - so you can detect "click landed but nothing happened" and retry. Optional gliding cursor + intent label. The finder PIERCES open shadow roots, so custom-element controls (Schwab/QBO) are clickable by text/selector.', related: ['nbrowser_select', 'nbrowser_wait_for', 'nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_wait_for', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'changed:false means the click dispatched but the page did not visibly change (disabled/covered element, missed target) - re-resolve or retry', 'for a deterministic post-click wait use nbrowser_wait_for, not a fixed sleep', 'to PICK an option from a custom dropdown, use nbrowser_select (waits for options + returns the value) - never coordinate-click a virtualized list'] }, nbrowser_select: { hint: 'Pick an option from a dropdown/combobox BY ITS VISIBLE TEXT - the reliable primitive for custom + native selects. Give {label} (the control\'s accessible name) OR {near} (a visible label beside it) to find the trigger, plus {optionText}. It opens the control with an isTrusted click, WAITS for the options to render, clicks the EXACT text match (piercing shadow roots), and RETURNS the newly-selected value so you can verify. Fixes coordinate-clicks landing on the wrong row of a virtualized list.', related: ['nbrowser_click', 'nbrowser_wait_for'], pitfalls: ['pass label OR near to locate the trigger; optionText must match the option\'s exact visible text (case-insensitive)', 'ALWAYS check the returned `selected` value before continuing - confirm it is the row you meant (mis-selecting a bank account by one row is the exact bug this prevents)', 'raise timeoutMs for a slow/virtualized option list (default 4000, max 20000)'] }, nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you.', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },@@ -1536,7 +1537,7 @@ const VERB_META = { nbrowser_clear_cookies: { hint: 'Background logout: drop a site\'s cookies (incl. httpOnly) for ONE registrable domain, leaving other sites (e.g. Google SSO) intact.', related: ['nbrowser_login_state'], pitfalls: ['scoped to one domain so it never nukes unrelated sessions', '⚠️ do NOT use this to log OUT of a site that ties device-trust to cookies (e.g. Gusto) - it forces a fresh 2FA; use the site\'s own Sign out'] }, nbrowser_caption: { hint: 'Paint an on-page caption (subtitle or red alert banner) for demos/narration.', related: ['nbrowser_cursor'], pitfalls: ['only on a window you opened', 'pass action:"hide" or empty text to dismiss'] }, nbrowser_focus: { hint: 'Select a tab within its window + flash its taskbar button. Does NOT raise the window unless you justify a foreground (userRequestedForeground:true + foregroundReason).', related: ['nbrowser_flash', 'nbrowser_screenshot'], pitfalls: ['does NOT foreground by design - the user\'s focus is never stolen; capture in place with nbrowser_screenshot', 'to actually raise it you must justify + warn the user (ab notify) first'] },- nbrowser_hover: { hint: 'Hover an element (by selector/text) to reveal menus/tooltips (isTrusted).', related: ['nbrowser_click'], pitfalls: ['some menus need a dwell - hover then screenshot before clicking the revealed item'] },+ nbrowser_hover: { hint: 'Hover an element (by selector/text, or raw {x,y}) to reveal menus/tooltips (isTrusted). Raw {x,y} needs NO atScrollX/Y: a hover is read-only pointer motion, so the stale_coordinates gate does not apply (ext 0.2.10; it used to refuse with a message that said click).', related: ['nbrowser_click', 'nbrowser_scroll'], pitfalls: ['some menus need a dwell - hover then screenshot before clicking the revealed item', 'a coordinate scan of a canvas (hover a grid, read the tooltip after each) is fine with raw {x,y}'] }, nbrowser_double_click: { hint: 'Double-click an element (isTrusted).', related: ['nbrowser_click'], pitfalls: ['click-by-text matching >1 element is refused (ambiguous_target) - pass a selector'] }, nbrowser_right_click: { hint: 'Right-click an element to open a context menu (isTrusted).', related: ['nbrowser_click'], pitfalls: ['the native context menu is OS chrome, not page DOM - you often cannot click its items via CDP; prefer the page\'s own UI'] }, nbrowser_login_form: { hint: 'Detect a login form\'s state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call before driving a login.', related: ['nbrowser_autologin', 'nbrowser_totp', 'nbrowser_login_state'], next: 'nbrowser_autologin', pitfalls: ['if already autofilled, just submit (background) - do NOT retype (retyping can dismiss Chrome\'s password dropdown)', 'never foreground the window to "make the dropdown appear" - that steals the user\'s focus'] },@@ -1658,7 +1659,8 @@ function dispatch(verb, args) { const staleOnDisk = []; if (EXPECTED_EXT) { for (const kp of knownProfiles()) {- if (connectedKeys.has(kp.key)) continue; // connected path already handled above+ // Connected profiles are checked too (nb-bridge#41 side note): the running service worker can be current+ // while the load-unpacked FOLDER on disk is old, and dev_reload then reloads that old folder. let st; try { st = adomExtStateForProfile(kp.browser, kp.profileDir); } catch { continue; } if (st && st.installed && st.version && cmpVer(st.version, EXPECTED_EXT) < 0) { staleOnDisk.push({ profile: kp.key, installed: st.version, expected: EXPECTED_EXT });@@ -1773,7 +1775,8 @@ function dispatch(verb, args) { d('nbrowser_screenshot_full_res', 'Full-resolution / full-page screenshot via CDP.', { profile: P, sessionId: S, tabId: 'optional str', fullPage: 'optional bool' }, { ok: 'bool', base64: 'str' }, 120, { example: { sessionId: 'work', fullPage: true } }), d('nbrowser_eval', 'Run JS in the page context and return the result.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: 'document.title' } }), d('nbrowser_evaluate', 'Alias of nbrowser_eval.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: '1+1' } }),- d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"move"|"type"|"key"', x: 'int?', y: 'int?', text: 'str?', key: 'str?' }, { ok: 'bool' }, 120, { example: { sessionId: 'work', type: 'click', x: 200, y: 300 } }),+ d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input. Unknown type is refused (unknown_input_type), never a click.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"dblclick"|"rightclick"|"move"|"hover"|"wheel"|"type"|"key"', x: 'int?', y: 'int?', selector: 'str?', text: 'str? (visible text to resolve, or the text to type for type:"type")', key: 'str?', deltaX: 'int? (wheel)', deltaY: 'int? (wheel; -300 = zoom in / scroll up)', steps: 'int? (wheel, split the delta)', atScrollX: 'int? (clicks by raw xy only)', atScrollY: 'int?' }, { ok: 'bool', dispatched: 'str', errorCode: 'str? (unknown_input_type | stale_coordinates | ambiguous_target)' }, 120, { example: { sessionId: 'work', type: 'wheel', x: 400, y: 300, deltaY: -300 } }),+ d('nbrowser_scroll', 'Real isTrusted mouse wheel at a point (canvas camera zoom, pane scroll). No freshness gate.', { sessionId: S, x: 'int?', y: 'int?', selector: 'str?', text: 'str?', deltaX: 'int?', deltaY: 'int (px; negative = up / zoom in)', steps: 'int? (1..20)', settleMs: 'int?' }, { ok: 'bool', dispatched: '"wheel"', pageScrolled: 'bool', scrollBefore: '{sx,sy}', scrollAfter: '{sx,sy}' }, 30, { example: { sessionId: 's1', x: 442, y: 589, deltaY: -300 } }), d('nbrowser_click', 'Click by visible text / selector / xy; scrolls into view + returns changed:bool (did the URL/DOM actually change). Finder pierces open shadow roots. Optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str', settleMs: 'optional int (default 500)' }, { ok: 'bool', changed: 'bool', urlBefore: 'str', urlAfter: 'str' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }), d('nbrowser_select', 'Pick a dropdown/combobox option BY VISIBLE TEXT (custom or native): finds the trigger by label|near, opens it, waits for options, clicks the exact match (shadow-piercing), returns the selected value to verify.', { sessionId: S, tabId: 'optional str', label: 'optional str (control accessible name)', near: 'optional str (visible label beside it)', optionText: 'required str (exact option text)', timeoutMs: 'optional int (default 4000, max 20000)' }, { ok: 'bool', selected: 'str', matchedExact: 'bool', clickedText: 'str', via: '"custom-dropdown"|"native-select"' }, 25, { example: { sessionId: 'work', near: 'Account', optionText: 'Individual …3456' } }), d('nbrowser_set_file_input', 'Upload files into an <input type=file> over CDP - no OS picker, background-safe. Direct (selector/text -> input, incl. hidden) or armForNextChooser:true for inputs created on Browse-click. files:[{name,contentBase64}] = bridge stages small files on the laptop.', { sessionId: 'required str', selector: 'optional str', text: 'optional str', filePaths: 'optional [abs laptop paths]', files: 'optional [{name, contentBase64}]', armForNextChooser: 'optional bool', chooserStatus: 'optional bool' }, { ok: 'bool', inputFound: 'bool?', via: 'direct|chooser', files: '[names]', armed: 'bool?', served: 'bool?' }, 20, { example: { sessionId: 'work', selector: 'input[type=file]', filePaths: ['C:/Users/me/Downloads/foo.png'] } }),@@ -1801,7 +1804,7 @@ function dispatch(verb, args) { // were invisible to every caller for several releases. Keep this list in step with VERB_META; // tools/check-guard-coverage.mjs now fails the build if it drifts again. d('nbrowser_events', 'Drain buffered lifecycle events since your last call: window_closed (with byUser), tab_closed, spawned_window (a page you drove opened a compose/popup), focus_violation, foreground_raised, dialog. THE channel by which you learn the user closed your window or a page spawned one behind your back.', { limit: 'optional int' }, { ok: 'bool', events: '[{event,windowId?,tabId?,byUser?,url?,agoMs}]', count: 'int' }, 20, { example: {} }),- d('nbrowser_hover', 'Hover an element (by selector or visible text) to reveal menus/tooltips. Real isTrusted input.', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.info-icon' } }),+ d('nbrowser_hover', 'Hover an element (by selector, visible text, or raw {x,y}; no atScrollX/Y needed) to reveal menus/tooltips. Real isTrusted input.', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int', hoverDwellMs: 'optional int (default 600)' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.info-icon' } }), d('nbrowser_double_click', 'Double-click an element (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '#row-3' } }), d('nbrowser_right_click', 'Right-click an element to open a context menu (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.ctx' } }), d('nbrowser_focus', 'Select a tab within its window and politely flash its taskbar button. Does NOT raise the window unless you justify a foreground.', { sessionId: S, tabId: 'optional str', userRequestedForeground: 'optional bool', foregroundReason: 'REQUIRED with userRequestedForeground' }, { ok: 'bool', tabId: 'str', foregrounded: 'bool' }, 15, { example: { sessionId: 's1' } }),@@ -2591,7 +2594,7 @@ function dispatch(verb, args) { resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' }); }, timeoutFor(verb)); const startedAt = Date.now();- pending.set(id, { resolve: (r) => resolve(attachProgress(verb, startedAt, r)), timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt });+ pending.set(id, { resolve: (r) => resolve(attachProgress(verb, startedAt, r)), timer, verb, toFile: (args && args.toFile) || false, profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt }); activityStart(id, verb, args && args.sessionId); sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n'); };@@ -2641,9 +2644,12 @@ async function onHostFrameInner(frame) { if (payload.base64 && ((isShot && p.toFile) || isRec)) { try { const sub = isRec ? 'adom-nbrowser-recordings' : 'adom-nbrowser-shots';- const dir = path.join(process.env.TEMP || os.tmpdir(), sub);+ // nb-bridge#41 item 3: toFile:"<absolute path>" is HONOURED (the caller's path, parent dirs created);+ // toFile:true keeps the fixed temp dir. It used to accept a path and silently write elsewhere.+ const want = (isShot && typeof p.toFile === 'string' && /^[a-zA-Z]:[\\/]|^\\\\|^\//.test(p.toFile)) ? path.normalize(p.toFile) : null;+ const dir = want ? path.dirname(want) : path.join(process.env.TEMP || os.tmpdir(), sub); fs.mkdirSync(dir, { recursive: true });- const file = path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`);+ const file = want || path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`); fs.writeFileSync(file, Buffer.from(payload.base64, 'base64')); const sizeKB = Math.round(Buffer.byteLength(payload.base64, 'base64') / 1024); payload = { ...payload, path: file, savedToFile: true, sizeKB, _hint: 'Pull this with `adom-bridge pull_file {filePaths:["' + file.replace(/\\/g, '\\\\') + '"]}` (binary, chunked, no relay timeout).' };
extension/manifest.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Adom Native Browser Extension", "short_name": "Adom",- "version": "0.2.9",+ "version": "0.2.10", "description": "Layer 2 of Adom Native Browser (nb): lets your Adom AI drive your real, signed-in Chrome or Edge, using your own logins.", "minimum_chrome_version": "116", "background": {
extension/src/cdp.js+32−3@@ -656,7 +656,16 @@ export async function inputDispatch(tabId, args) { await send(tabId, 'Input.insertText', { text: String(args.text || '') }); return { ok: true, dispatched: 'type', length: String(args.text || '').length, via: args.selector ? 'selector' : 'focused', ...(finder ? { finder } : {}) }; }- // pointer types: move | hover | click | dblclick | rightclick+ // pointer types: move | hover | click | dblclick | rightclick | wheel+ // nb-bridge#41 item 2: an UNKNOWN type used to fall through to a click (four "zoom" attempts became four+ // clicks on a canvas editor with a tool armed). Refuse it by name instead.+ const KNOWN = ['click', 'dblclick', 'doubleclick', 'rightclick', 'contextmenu', 'move', 'hover', 'wheel', 'mousewheel', 'scroll'];+ if (!KNOWN.includes(type)) {+ return { ok: false, errorCode: 'unknown_input_type', dispatched: false,+ error: `unknown_input_type: "${args.type}" is not an input type I dispatch - refusing rather than guessing (an unknown type used to become a CLICK).`,+ _hint: 'type must be one of click | dblclick | rightclick | move | hover | wheel | type | key. For a mouse wheel / canvas zoom use type:"wheel" with deltaX/deltaY (or nbrowser_scroll).' };+ }+ const isWheel = (type === 'wheel' || type === 'mousewheel' || type === 'scroll'); const c = await resolveCoords(tabId, args, wantFinder); if (!c.found) { const out = { ok: false, error: `target not found: ${args.selector || args.text || '(no selector/text)'}`, _hint: 'element not visible/present; wait or refine the selector/text' };@@ -677,12 +686,14 @@ export async function inputDispatch(tabId, args) { // those coords were computed. Require the scroll baseline (atScrollX/atScrollY, from a screenshot's // coordMap) and refuse if it drifted — a cached pixel silently becomes a DIFFERENT element after a // scroll/re-render (the "Next" that turned into "Back"). Re-resolve by selector/text instead.- if (/click|move|hover/.test(type) && !args.selector && !args.text && (args.x != null || args.y != null) && !resolveOnly) {+ // nb-bridge#41 item 1: the gate applies to CLICKS only. A hover, move or wheel is read-only pointer motion:+ // a stale hover shows the wrong tooltip, it never presses the wrong control. Those accept raw {x,y}.+ if (/click|contextmenu/.test(type) && !args.selector && !args.text && (args.x != null || args.y != null) && !resolveOnly) { const live = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})'); const bx = args.atScrollX, by = args.atScrollY; if (bx == null && by == null) { return { ok: false, errorCode: 'stale_coordinates',- error: 'stale_coordinates: a raw {x,y} click carries no freshness proof — the page may have scrolled/re-rendered since those coords were valid.',+ error: `stale_coordinates: a raw {x,y} ${type} carries no freshness proof - the page may have scrolled/re-rendered since those coords were valid.`, _hint: 'Prefer nbrowser_click {selector:"…"} or {text:"…"} (re-resolved live). If you must click by pixel, pass atScrollX/atScrollY from your latest screenshot coordMap so I can verify the page has not scrolled.' }; } if ((bx != null && Math.abs(Number(bx) - live.sx) > 2) || (by != null && Math.abs(Number(by) - live.sy) > 2)) {@@ -700,6 +711,24 @@ export async function inputDispatch(tabId, args) { const intent = args.intent || args.label; const tgt = (info.clickedText || info.matchedText || '').trim().slice(0, 40); if (showCursor) { await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'find')); await sleep(args.cursorGlideMs || 450); }+ if (isWheel) {+ // Real CDP wheel (isTrusted): what a canvas camera (Babylon ArcRotateCamera, three.js OrbitControls) and+ // a scrollable pane both listen for. deltaY < 0 zooms in / scrolls up, > 0 zooms out / scrolls down.+ const deltaX = Number(args.deltaX) || 0, deltaY = Number(args.deltaY) || 0;+ if (!deltaX && !deltaY) return { ok: false, errorCode: 'bad_args', error: 'wheel needs deltaX and/or deltaY (px; deltaY:-300 zooms in / scrolls up)' };+ const steps = Math.max(1, Math.min(20, Number(args.steps) || 1));+ const before = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });+ for (let i = 0; i < steps; i++) {+ await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: deltaX / steps, deltaY: deltaY / steps });+ if (steps > 1) await sleep(Number(args.stepDelayMs) || 40);+ }+ await sleep(Number(args.settleMs) || 150);+ const after = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ return { ok: true, dispatched: 'wheel', x, y, deltaX, deltaY, steps, scrollBefore: before, scrollAfter: after,+ pageScrolled: !!(before && after && (before.sx !== after.sx || before.sy !== after.sy)), ...info, ...fin,+ _hint: 'isTrusted wheel at that point. pageScrolled:false is normal on a canvas (the app consumed the wheel, e.g. a camera zoom); verify the effect with nbrowser_screenshot or an eval of the app state.' };+ } if (type === 'move' || type === 'hover') { if (showCursor) await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'act')); // Approach from just outside, then settle on center, then dwell — a single mouseMoved
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "nb-bridge",- "version": "0.16.1",+ "version": "0.16.2", "type": "app", "title": "NB - Native Browser Bridge", "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",
tools/promote.shtools/ship.sh