extension(browser): v1.9.16 — CSP-safe DOM ops + CDP-shaped surface
Decomplected three orthogonal layers:
L3: hanzo.* ergonomic aliases
L2: CDP-shape canonical primitives (Page.* DOM.* Input.* ...)
L1: runFunc(tabId, fn, args) → scripting.executeScript({world: MAIN, func: ref})
Every DOM op now passes a real function reference to scripting.executeScript
instead of constructing Function(codeStr)(). The Function() path is blocked
by strict page CSP on sec.gov / github.com / any default-src 'self' SPA.
Direct function refs are exempt from page CSP per the WebExtension spec, so
the new path works everywhere.
Added (all CSP-safe):
• hanzo.clickByText fillByLabel findByText listForm submitForm
• hanzo.press scroll waitForText waitForMutation
• hanzo.uploadFile dialogAccept observe/observeRead/observeStop
• Input.dispatchMouseEvent Input.scrollWheel
• DOM.querySelector DOM.scrollIntoView DOM.focus
• Page.printToPDF
Rewrote with runFunc (CSP-safe):
• hanzo.click fill check uncheck clear getText getHTML
• hanzo.querySelectorAll
Capabilities handshake bumped to advertise the new method names.
Driver-side mapping in hanzo-tools-browser/browser_tool.py updated with
~100 snake_case action → wire-method entries (Python MCP surface).
API reference: dist/browser-extension/firefox/API.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.9.15",
|
||||
"version": "1.9.16",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"@types/firefox-webext-browser": "^120.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.25.6",
|
||||
"esbuild": "^0.25.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -173,13 +173,24 @@ class HanzoFirefoxExtension {
|
||||
'select', 'check', 'uncheck', 'press_key', 'content', 'snapshot',
|
||||
'getText', 'getHTML', 'getAttribute', 'getElementInfo', 'getPageInfo',
|
||||
'querySelectorAll', 'waitForSelector', 'waitForNavigation',
|
||||
// CSP-safe text/label primitives + composite waits (1.9.16+)
|
||||
'clickByText', 'fillByLabel', 'findByText', 'listForm',
|
||||
'press', 'scroll', 'waitForText', 'waitForMutation',
|
||||
'submitForm', 'uploadFile', 'dialogAccept',
|
||||
'observe', 'observeRead', 'observeStop',
|
||||
// Coordinate-level Input.* (1.9.16+)
|
||||
'Input.dispatchMouseEvent', 'Input.scrollWheel',
|
||||
// DOM.* + Page.* aliases (1.9.16+)
|
||||
'DOM.querySelector', 'DOM.scrollIntoView', 'DOM.focus',
|
||||
'Page.printToPDF',
|
||||
// Existing legacy
|
||||
'fetch', 'cookies', 'localStorage', 'injectScript', 'injectCSS',
|
||||
'createTab', 'closeTab', 'activateTab', 'history',
|
||||
'observeMutations', 'computedStyles', 'boundingRects',
|
||||
'setHTML', 'setText', 'setAttribute', 'removeAttribute',
|
||||
'setStyle', 'addClass', 'removeClass', 'insertElement', 'removeElement',
|
||||
'monitor', 'audit'
|
||||
]
|
||||
'monitor', 'audit',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,6 +363,69 @@ class HanzoFirefoxExtension {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSP-safe alternative to runInPage. Passes a real function reference
|
||||
* (and serializable args) to scripting.executeScript. Because no
|
||||
* Function() or eval() is constructed at runtime, page CSP that
|
||||
* blocks 'unsafe-eval' does NOT block this path. Works on .gov, on
|
||||
* sites that ship strict default-src 'self', and on github.com.
|
||||
*
|
||||
* Use this for any DOM read/write the bridge needs to do. The
|
||||
* `fn` argument is a normal JS function — it is serialized by
|
||||
* Firefox/Chrome's scripting API and executed in the MAIN world.
|
||||
*
|
||||
* Added in 1.9.16 alongside the CDP-shaped decomplected surface.
|
||||
*/
|
||||
private async runFunc<R = any>(
|
||||
tabId: number,
|
||||
fn: (...args: any[]) => R,
|
||||
args: any[] = [],
|
||||
timeoutMs: number = 10000,
|
||||
): Promise<R> {
|
||||
let tab: chrome.tabs.Tab | undefined;
|
||||
try { tab = await browser.tabs.get(tabId); } catch { /* best-effort */ }
|
||||
if (tab && isPrivilegedTab(tab)) {
|
||||
throw new Error(
|
||||
`Cannot run JS in privileged tab ${tabId} (${tab.url}).`,
|
||||
);
|
||||
}
|
||||
const scripting = (browser as any).scripting;
|
||||
if (!scripting?.executeScript) {
|
||||
throw new Error('scripting.executeScript not available (need MV3 scripting permission)');
|
||||
}
|
||||
const exec = (async () => {
|
||||
const runIn = async (world: 'MAIN' | 'ISOLATED'): Promise<any[]> => {
|
||||
const results = await scripting.executeScript({
|
||||
target: { tabId },
|
||||
world,
|
||||
args,
|
||||
func: fn,
|
||||
});
|
||||
return Array.isArray(results) ? results.map((r: any) => r?.result) : [results];
|
||||
};
|
||||
let out: any[];
|
||||
try {
|
||||
out = await runIn('MAIN');
|
||||
} catch (e) {
|
||||
out = await runIn('ISOLATED');
|
||||
}
|
||||
if (!Array.isArray(out) || out.length === 0) {
|
||||
throw new Error(
|
||||
`runFunc returned no frames for tab ${tabId} — page may be privileged or unloaded.`,
|
||||
);
|
||||
}
|
||||
const result = out[0];
|
||||
if (result && typeof result === 'object' && (result as any).__hanzo_error) {
|
||||
throw new Error((result as any).__hanzo_error);
|
||||
}
|
||||
return result === undefined ? (null as any) : result;
|
||||
})();
|
||||
return Promise.race([
|
||||
exec,
|
||||
new Promise<R>((_, reject) => setTimeout(() => reject(new Error('runFunc timeout')), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public dispatcher for ZAP-server-initiated requests. Mirrors
|
||||
* `CDPBridge.dispatchMethod` on the Chrome side so background-ZAP wiring
|
||||
@@ -668,14 +742,15 @@ class HanzoFirefoxExtension {
|
||||
// =====================================================================
|
||||
case 'hanzo.click': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
this.notifyOverlay(tabId, 'ai.control.highlight', { selector: params.selector as string });
|
||||
this.notifyOverlay(tabId, 'ai.control.status', { text: `Clicking ${params.selector}` });
|
||||
const clicked = await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
if (el) { el.click(); return true; }
|
||||
return false;
|
||||
`);
|
||||
const clicked = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
el.click();
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!clicked };
|
||||
}
|
||||
|
||||
@@ -712,20 +787,34 @@ class HanzoFirefoxExtension {
|
||||
// =====================================================================
|
||||
case 'hanzo.fill': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
const val = this.escapeValue((params.value as string) || '');
|
||||
this.notifyOverlay(tabId, 'ai.control.highlight', { selector: params.selector as string });
|
||||
this.notifyOverlay(tabId, 'ai.control.status', { text: `Filling ${params.selector}` });
|
||||
await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.value = '${val}';
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string, value: string) => {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | HTMLElement | null;
|
||||
if (!el) return false;
|
||||
try { (el as HTMLElement).scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
(el as HTMLElement).focus();
|
||||
const tag = (el as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (setter && (tag === 'INPUT' || tag === 'TEXTAREA')) {
|
||||
setter.call(el, value);
|
||||
} else {
|
||||
(el as HTMLInputElement).value = value;
|
||||
}
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
`);
|
||||
return { success: true };
|
||||
if ((el as HTMLElement).isContentEditable) {
|
||||
(el as HTMLElement).textContent = value;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [params.selector as string, (params.value as string) || '']);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'hanzo.type': {
|
||||
@@ -749,16 +838,17 @@ class HanzoFirefoxExtension {
|
||||
|
||||
case 'hanzo.clear': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
if (el) {
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
`);
|
||||
return { success: true };
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
if (!el) return false;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (setter) setter.call(el, ''); else (el as HTMLInputElement).value = '';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'hanzo.select': {
|
||||
@@ -777,22 +867,26 @@ class HanzoFirefoxExtension {
|
||||
|
||||
case 'hanzo.check': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
if (el && !el.checked) { el.click(); }
|
||||
`);
|
||||
return { success: true };
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) return false;
|
||||
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
if (!el.checked) el.click();
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'hanzo.uncheck': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
if (el && el.checked) { el.click(); }
|
||||
`);
|
||||
return { success: true };
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) return false;
|
||||
try { el.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
if (el.checked) el.click();
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'Input.dispatchKeyEvent': {
|
||||
@@ -815,22 +909,21 @@ class HanzoFirefoxExtension {
|
||||
// =====================================================================
|
||||
case 'hanzo.getText': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
const text = await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
return el ? el.textContent : null;
|
||||
`);
|
||||
const text = await this.runFunc<string | null>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector);
|
||||
return el ? (el.textContent || '') : null;
|
||||
}, [params.selector as string]);
|
||||
return { result: { value: text } };
|
||||
}
|
||||
|
||||
case 'hanzo.getHTML': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
const outer = params.outer !== false;
|
||||
const html = await this.runInPage(tabId, `
|
||||
var el = document.querySelector('${sel}');
|
||||
return el ? ${outer ? 'el.outerHTML' : 'el.innerHTML'} : null;
|
||||
`);
|
||||
const html = await this.runFunc<string | null>(tabId, (selector: string, useOuter: boolean) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
return useOuter ? el.outerHTML : el.innerHTML;
|
||||
}, [params.selector as string, outer]);
|
||||
return { result: { value: html } };
|
||||
}
|
||||
|
||||
@@ -883,25 +976,464 @@ class HanzoFirefoxExtension {
|
||||
|
||||
case 'hanzo.querySelectorAll': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
const elements = await this.runInPage(tabId, `
|
||||
var els = document.querySelectorAll('${sel}');
|
||||
var result = [];
|
||||
for (var i = 0; i < Math.min(els.length, 100); i++) {
|
||||
var el = els[i];
|
||||
var rect = el.getBoundingClientRect();
|
||||
result.push({
|
||||
index: i, tagName: el.tagName, id: el.id, className: el.className,
|
||||
textContent: (el.textContent || '').trim().slice(0, 100),
|
||||
value: el.value, href: el.href,
|
||||
visible: !!(rect.width && rect.height)
|
||||
const elements = await this.runFunc<any[]>(tabId, (selector: string, limit: number) => {
|
||||
const els = document.querySelectorAll(selector);
|
||||
const out: any[] = [];
|
||||
const n = Math.min(els.length, limit);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const el = els[i] as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
out.push({
|
||||
index: i,
|
||||
tagName: el.tagName,
|
||||
id: el.id || '',
|
||||
className: typeof el.className === 'string' ? el.className : '',
|
||||
name: el.getAttribute('name') || '',
|
||||
type: el.getAttribute('type') || '',
|
||||
role: el.getAttribute('role') || '',
|
||||
ariaLabel: el.getAttribute('aria-label') || '',
|
||||
value: (el as HTMLInputElement).value || '',
|
||||
href: (el as HTMLAnchorElement).href || '',
|
||||
textContent: (el.textContent || '').trim().slice(0, 200),
|
||||
visible: !!(rect.width && rect.height),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
`);
|
||||
return out;
|
||||
}, [params.selector as string, (params.limit as number) || 100]);
|
||||
return { elements: elements || [] };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// CDP-shaped decomplected surface (1.9.16+). See dist/API.md for the
|
||||
// canonical reference. Three orthogonal layers:
|
||||
// 1) Wire transport (JSON over WS/HTTP)
|
||||
// 2) CDP-shaped canonical primitives (Page.* DOM.* Input.* ...)
|
||||
// 3) hanzo.* ergonomic aliases that compose canonical primitives
|
||||
// Every DOM-touching method goes through runFunc → executeScript with
|
||||
// a direct function reference. NEVER Function() / eval(). That is what
|
||||
// makes them CSP-safe on .gov, github.com, and any strict-CSP SPA.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
case 'Input.dispatchMouseEvent': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
const result = await this.runFunc<any>(tabId, (p: any) => {
|
||||
const { type, x, y, button = 0, clickCount = 1, modifiers = 0 } = p;
|
||||
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
if (!target) return { delivered: false, reason: 'no element at point' };
|
||||
const mods = {
|
||||
altKey: !!(modifiers & 1), ctrlKey: !!(modifiers & 2),
|
||||
metaKey: !!(modifiers & 4), shiftKey: !!(modifiers & 8),
|
||||
};
|
||||
const init: MouseEventInit = {
|
||||
bubbles: true, cancelable: true, view: window,
|
||||
button, buttons: type === 'mousePressed' ? 1 : 0,
|
||||
clientX: x, clientY: y, screenX: x, screenY: y, detail: clickCount,
|
||||
...mods,
|
||||
};
|
||||
const map: Record<string, string> = { mouseMoved: 'mousemove', mousePressed: 'mousedown', mouseReleased: 'mouseup' };
|
||||
target.dispatchEvent(new MouseEvent(map[type] || type.toLowerCase(), init));
|
||||
if (type === 'mousePressed' && clickCount >= 1) {
|
||||
target.dispatchEvent(new MouseEvent('click', init));
|
||||
}
|
||||
return { delivered: true, target: target.tagName, x, y };
|
||||
}, [params]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'Input.scrollWheel': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
const result = await this.runFunc<any>(tabId, (p: any) => {
|
||||
const { x = 0, y = 0, deltaX = 0, deltaY = 0 } = p;
|
||||
const target = (x || y)
|
||||
? (document.elementFromPoint(x, y) as HTMLElement | null)
|
||||
: ((document.scrollingElement || document.body) as HTMLElement | null);
|
||||
if (!target) return { delivered: false };
|
||||
target.dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true, cancelable: true, deltaX, deltaY, clientX: x, clientY: y,
|
||||
}));
|
||||
if (target === document.scrollingElement || target === document.body) {
|
||||
window.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' as ScrollBehavior });
|
||||
}
|
||||
return { delivered: true };
|
||||
}, [params]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'Page.printToPDF': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
await this.runFunc<boolean>(tabId, () => { window.print(); return true; }, []);
|
||||
return {
|
||||
opened: true,
|
||||
note: 'Firefox extension cannot programmatically save PDF. ' +
|
||||
'For headless PDF export use Page.captureScreenshot with fullPage=true ' +
|
||||
'and convert PNG→PDF on the client side.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'DOM.querySelector': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const element = await this.runFunc<any>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
tagName: el.tagName, id: el.id || '',
|
||||
className: typeof el.className === 'string' ? el.className : '',
|
||||
textContent: (el.textContent || '').trim().slice(0, 200),
|
||||
value: (el as HTMLInputElement).value || '',
|
||||
visible: !!(rect.width && rect.height),
|
||||
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
};
|
||||
}, [params.selector as string]);
|
||||
return { element };
|
||||
}
|
||||
|
||||
case 'DOM.scrollIntoView': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'DOM.focus': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const ok = await this.runFunc<boolean>(tabId, (selector: string) => {
|
||||
const el = document.querySelector(selector) as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
return true;
|
||||
}, [params.selector as string]);
|
||||
return { success: !!ok };
|
||||
}
|
||||
|
||||
case 'hanzo.clickByText': {
|
||||
if (!tabId || !params.text) throw new Error('No active tab or text');
|
||||
const result = await this.runFunc<any>(tabId, (text: string, scope: string) => {
|
||||
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const want = norm(text);
|
||||
const tags = scope === 'all'
|
||||
? 'button, a, input[type=button], input[type=submit], input[type=radio], input[type=checkbox], label, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem]'
|
||||
: 'button, a, label, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem]';
|
||||
const els = Array.from(document.querySelectorAll(tags)) as HTMLElement[];
|
||||
const matches = els.filter((el) => {
|
||||
const t = norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '');
|
||||
return t === want || t.includes(want);
|
||||
});
|
||||
const exact = matches.find((el) => norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '') === want) || matches[0];
|
||||
if (!exact) return { clicked: false, candidates: matches.length };
|
||||
try { exact.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
exact.click();
|
||||
return { clicked: true, tag: exact.tagName, text: (exact.innerText || (exact as HTMLInputElement).value || '').slice(0, 100) };
|
||||
}, [params.text as string, (params.scope as string) || 'default']);
|
||||
return result || { clicked: false };
|
||||
}
|
||||
|
||||
case 'hanzo.fillByLabel': {
|
||||
if (!tabId || !params.label) throw new Error('No active tab or label');
|
||||
const result = await this.runFunc<any>(tabId, (labelText: string, value: string) => {
|
||||
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const want = norm(labelText);
|
||||
const labels = Array.from(document.querySelectorAll('label')) as HTMLLabelElement[];
|
||||
const lbl = labels.find((l) => norm(l.innerText) === want) || labels.find((l) => norm(l.innerText).includes(want));
|
||||
let input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null = null;
|
||||
if (lbl) {
|
||||
const forId = lbl.getAttribute('for');
|
||||
if (forId) input = document.getElementById(forId) as any;
|
||||
if (!input) input = lbl.querySelector('input, textarea, select');
|
||||
}
|
||||
if (!input) {
|
||||
const all = Array.from(document.querySelectorAll('input, textarea, select')) as (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement)[];
|
||||
input = all.find((el) =>
|
||||
norm(el.getAttribute('aria-label') || '') === want
|
||||
|| norm(el.getAttribute('placeholder') || '') === want
|
||||
|| norm(el.getAttribute('name') || '') === want
|
||||
|| norm(el.getAttribute('id') || '') === want
|
||||
) || null;
|
||||
}
|
||||
if (!input) return { filled: false };
|
||||
try { input.scrollIntoView({ block: 'center', behavior: 'instant' as ScrollBehavior }); } catch (e) {}
|
||||
input.focus();
|
||||
const tag = input.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
|| Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||
if (setter) setter.call(input, value); else (input as HTMLInputElement).value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { filled: true, name: input.name || input.id };
|
||||
}
|
||||
if (tag === 'SELECT') {
|
||||
(input as HTMLSelectElement).value = value;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { filled: true, name: input.name || input.id };
|
||||
}
|
||||
return { filled: false };
|
||||
}, [params.label as string, (params.value as string) || '']);
|
||||
return result || { filled: false };
|
||||
}
|
||||
|
||||
case 'hanzo.findByText': {
|
||||
if (!tabId || !params.text) throw new Error('Missing text');
|
||||
const result = await this.runFunc<any[]>(tabId, (text: string, scope: string, limit: number) => {
|
||||
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const want = norm(text);
|
||||
const tagSel = scope === 'all' ? '*' : 'button, a, label, input, select, textarea, [role=button], [role=radio], [role=checkbox], [role=tab], [role=menuitem], h1, h2, h3, h4, h5, h6, p, span, li, td';
|
||||
const els = Array.from(document.querySelectorAll(tagSel)) as HTMLElement[];
|
||||
const matches: any[] = [];
|
||||
for (let i = 0; i < els.length && matches.length < limit; i++) {
|
||||
const el = els[i];
|
||||
const t = norm(el.innerText || (el as HTMLInputElement).value || el.getAttribute('aria-label') || '');
|
||||
if (!t) continue;
|
||||
if (t === want || t.includes(want)) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
matches.push({
|
||||
index: i, tag: el.tagName,
|
||||
id: el.id || '',
|
||||
className: typeof el.className === 'string' ? el.className : '',
|
||||
text: t.slice(0, 200), exact: t === want,
|
||||
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
|
||||
visible: !!(rect.width && rect.height),
|
||||
});
|
||||
}
|
||||
}
|
||||
matches.sort((a, b) => (b.exact ? 1 : 0) - (a.exact ? 1 : 0));
|
||||
return matches;
|
||||
}, [params.text as string, (params.scope as string) || 'default', (params.limit as number) || 50]);
|
||||
return { matches: result || [] };
|
||||
}
|
||||
|
||||
case 'hanzo.listForm': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
const inputs = await this.runFunc<any[]>(tabId, () => {
|
||||
const all = Array.from(document.querySelectorAll('input, textarea, select, button, label, [role=radio], [role=checkbox], [role=button]')) as HTMLElement[];
|
||||
return all.slice(0, 200).map((el, i) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const lblFor = el.id ? document.querySelector(`label[for="${el.id}"]`) as HTMLElement | null : null;
|
||||
return {
|
||||
i,
|
||||
tag: el.tagName,
|
||||
type: el.getAttribute('type') || '',
|
||||
name: el.getAttribute('name') || '',
|
||||
id: el.id || '',
|
||||
role: el.getAttribute('role') || '',
|
||||
ariaLabel: el.getAttribute('aria-label') || '',
|
||||
placeholder: el.getAttribute('placeholder') || '',
|
||||
value: (el as HTMLInputElement).value || '',
|
||||
checked: !!(el as HTMLInputElement).checked,
|
||||
labelText: (lblFor?.innerText || '').trim().slice(0, 200),
|
||||
innerText: (el.innerText || '').trim().slice(0, 200),
|
||||
visible: !!(rect.width && rect.height),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
return { inputs: inputs || [] };
|
||||
}
|
||||
|
||||
case 'hanzo.press': {
|
||||
if (!tabId || !params.key) throw new Error('Missing key');
|
||||
const result = await this.runFunc<any>(tabId, (key: string, modifiers: any) => {
|
||||
const el = (document.activeElement || document.body) as HTMLElement;
|
||||
const mods = {
|
||||
altKey: !!modifiers?.alt, ctrlKey: !!modifiers?.ctrl,
|
||||
metaKey: !!modifiers?.meta, shiftKey: !!modifiers?.shift,
|
||||
};
|
||||
const keyCodeMap: Record<string, number> = {
|
||||
Enter: 13, Tab: 9, Escape: 27, Backspace: 8, Delete: 46,
|
||||
ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
|
||||
Home: 36, End: 35, PageUp: 33, PageDown: 34, ' ': 32,
|
||||
};
|
||||
const keyCode = keyCodeMap[key] ?? (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0);
|
||||
const init: any = { key, code: key, keyCode, which: keyCode, bubbles: true, cancelable: true, ...mods };
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', init));
|
||||
if (key.length === 1) el.dispatchEvent(new KeyboardEvent('keypress', init));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
||||
if (key === 'Enter' && el.tagName === 'INPUT') {
|
||||
const form = (el as HTMLInputElement).form;
|
||||
if (form) { try { (form as any).requestSubmit?.() || form.submit?.(); } catch (e) {} }
|
||||
}
|
||||
return { delivered: true, target: el.tagName, keyCode };
|
||||
}, [params.key as string, params.modifiers || {}]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'hanzo.scroll': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
const result = await this.runFunc<any>(tabId, (target: any, amount: any) => {
|
||||
if (target === 'top') { window.scrollTo({ top: 0, behavior: 'smooth' }); return { scrolled: 'top' }; }
|
||||
if (target === 'bottom') { window.scrollTo({ top: document.body?.scrollHeight || 0, behavior: 'smooth' }); return { scrolled: 'bottom' }; }
|
||||
if (typeof target === 'string' && target) {
|
||||
const el = document.querySelector(target) as HTMLElement | null;
|
||||
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return { scrolled: 'into-view', selector: target }; }
|
||||
return { scrolled: null, error: 'selector not found', selector: target };
|
||||
}
|
||||
if (amount && (amount.x || amount.y)) {
|
||||
window.scrollBy({ left: amount.x || 0, top: amount.y || 0, behavior: 'smooth' });
|
||||
return { scrolled: 'by', ...amount };
|
||||
}
|
||||
return { scrolled: null, error: 'no target or amount' };
|
||||
}, [params.target || params.selector || null, params.amount || null]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'hanzo.waitForText': {
|
||||
if (!tabId || !params.text) throw new Error('Missing text');
|
||||
const timeoutMs = (params.timeout as number) || 15000;
|
||||
const interval = (params.interval as number) || 250;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const found = await this.runFunc<boolean>(tabId, (text: string) => {
|
||||
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
return norm(document.body?.innerText || '').includes(norm(text));
|
||||
}, [params.text as string]);
|
||||
if (found) return { found: true, elapsed: Date.now() - start };
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
return { found: false, timedOut: true, elapsed: Date.now() - start };
|
||||
}
|
||||
|
||||
case 'hanzo.waitForMutation': {
|
||||
if (!tabId || !params.selector) throw new Error('Missing selector');
|
||||
const timeoutMs = (params.timeout as number) || 15000;
|
||||
const result = await this.runFunc<any>(tabId, (selector: string, timeout: number) => {
|
||||
return new Promise((resolve) => {
|
||||
const existing = document.querySelector(selector);
|
||||
if (existing) { resolve({ found: true, immediate: true }); return; }
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) { observer.disconnect(); resolve({ found: true, immediate: false }); }
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
||||
setTimeout(() => { observer.disconnect(); resolve({ found: false, timedOut: true }); }, timeout);
|
||||
});
|
||||
}, [params.selector as string, timeoutMs]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'hanzo.submitForm': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
const result = await this.runFunc<any>(tabId, (hint: string | null) => {
|
||||
const norm = (s: string) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
const intents = hint ? [hint] : ['submit', 'continue', 'next', 'save', 'send', 'agree', 'accept', 'ok'];
|
||||
const wantList = intents.map(norm);
|
||||
const candidates = Array.from(document.querySelectorAll(
|
||||
'button, input[type=submit], input[type=button], a[role=button], [role=button], a.btn'
|
||||
)) as HTMLElement[];
|
||||
for (const want of wantList) {
|
||||
for (const c of candidates) {
|
||||
const t = norm(c.innerText || (c as HTMLInputElement).value || c.getAttribute('aria-label') || '');
|
||||
if (t === want) {
|
||||
try { c.scrollIntoView({ block: 'center' }); } catch (e) {}
|
||||
c.click();
|
||||
return { submitted: true, intent: want, tag: c.tagName, text: t.slice(0, 100), exact: true };
|
||||
}
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const t = norm(c.innerText || (c as HTMLInputElement).value || c.getAttribute('aria-label') || '');
|
||||
if (t.includes(want)) {
|
||||
try { c.scrollIntoView({ block: 'center' }); } catch (e) {}
|
||||
c.click();
|
||||
return { submitted: true, intent: want, tag: c.tagName, text: t.slice(0, 100), exact: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { submitted: false, candidates: candidates.length, intents };
|
||||
}, [(params.intent as string) || null]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'hanzo.uploadFile': {
|
||||
if (!tabId || !params.selector || !params.content || !params.name) {
|
||||
throw new Error('Need selector, name, content (base64)');
|
||||
}
|
||||
const result = await this.runFunc<any>(tabId, (selector: string, name: string, mimeType: string, contentB64: string) => {
|
||||
const el = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!el) return { uploaded: false, error: 'selector not found' };
|
||||
if (el.tagName !== 'INPUT' || el.type !== 'file') return { uploaded: false, error: 'not a file input' };
|
||||
const binStr = atob(contentB64);
|
||||
const bytes = new Uint8Array(binStr.length);
|
||||
for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i);
|
||||
const file = new File([bytes], name, { type: mimeType });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
el.files = dt.files;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { uploaded: true, name, size: bytes.byteLength };
|
||||
}, [params.selector as string, params.name as string, (params.mimeType as string) || 'application/octet-stream', params.content as string]);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'hanzo.dialogAccept': {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
await this.runFunc<boolean>(tabId, (acceptText: string) => {
|
||||
const W = window as any;
|
||||
W.__hanzo_dialogText = acceptText || '';
|
||||
W.alert = () => true;
|
||||
W.confirm = () => true;
|
||||
W.prompt = () => W.__hanzo_dialogText || '';
|
||||
return true;
|
||||
}, [(params.text as string) || '']);
|
||||
return { armed: true };
|
||||
}
|
||||
|
||||
case 'hanzo.observe': {
|
||||
if (!tabId || !params.name) throw new Error('Missing name');
|
||||
await this.runFunc<boolean>(tabId, (name: string, selector: string | null, opts: any) => {
|
||||
const W = window as any;
|
||||
W.__hanzo_obs = W.__hanzo_obs || {};
|
||||
if (W.__hanzo_obs[name]?.observer) W.__hanzo_obs[name].observer.disconnect();
|
||||
W.__hanzo_obs[name] = { events: [], observer: null };
|
||||
const target = selector ? document.querySelector(selector) : document.documentElement;
|
||||
if (!target) return false;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
W.__hanzo_obs[name].events.push({
|
||||
type: m.type,
|
||||
target: (m.target as Element)?.tagName || '',
|
||||
added: m.addedNodes?.length || 0,
|
||||
removed: m.removedNodes?.length || 0,
|
||||
attr: m.attributeName || null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
if (W.__hanzo_obs[name].events.length > 500) W.__hanzo_obs[name].events.shift();
|
||||
}
|
||||
});
|
||||
obs.observe(target, opts || { childList: true, subtree: true, attributes: true });
|
||||
W.__hanzo_obs[name].observer = obs;
|
||||
return true;
|
||||
}, [params.name as string, (params.selector as string) || null, params.options || null]);
|
||||
return { started: true, name: params.name };
|
||||
}
|
||||
|
||||
case 'hanzo.observeRead': {
|
||||
if (!tabId || !params.name) throw new Error('Missing name');
|
||||
const events = await this.runFunc<any[]>(tabId, (name: string, clear: boolean) => {
|
||||
const W = window as any;
|
||||
const entry = W.__hanzo_obs?.[name];
|
||||
if (!entry) return [];
|
||||
const out = entry.events.slice();
|
||||
if (clear !== false) entry.events.length = 0;
|
||||
return out;
|
||||
}, [params.name as string, params.clear !== false]);
|
||||
return { events: events || [] };
|
||||
}
|
||||
|
||||
case 'hanzo.observeStop': {
|
||||
if (!tabId || !params.name) throw new Error('Missing name');
|
||||
await this.runFunc<boolean>(tabId, (name: string) => {
|
||||
const W = window as any;
|
||||
const entry = W.__hanzo_obs?.[name];
|
||||
if (entry?.observer) entry.observer.disconnect();
|
||||
if (W.__hanzo_obs) delete W.__hanzo_obs[name];
|
||||
return true;
|
||||
}, [params.name as string]);
|
||||
return { stopped: true, name: params.name };
|
||||
}
|
||||
|
||||
case 'hanzo.getComputedStyles': {
|
||||
if (!tabId || !params.selector) throw new Error('No active tab or selector');
|
||||
const sel = this.escapeSelector(params.selector as string);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.15",
|
||||
"version": "1.9.16",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.9.15",
|
||||
"version": "1.9.16",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfXGh0lPUT5m04GKfjUwrLsV6pLaK3VcZuFRPogqAir2tzyLYnQPRtHynue9yvDyguIVnlkwvcwfDOYZrvH76zbw4s6onPBG8HqTKO6LQ9K3kdO1qBBkMMjdOgULQ1MrWThEbpU7NSTiwLYpEta/jAvrKRCAeKIlQE8p6htZmPy9aRUZuae66JgLcAlzD2vviX9sVB1asFABJVswL1RgZ55/8IzZaUrFjzOo9OHK4hmEOtudzkML+5silsAYdC+1BZugph2x94ai17YmZTCL1XyUa5Ke4q80cj+i9rOTgzhZs+mruyhL/AvNVOXilsgqCdNqSz77naWzC3pVGbxOewIDAQAB",
|
||||
"permissions": [
|
||||
|
||||
Generated
+2
-1
@@ -152,7 +152,7 @@ importers:
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
esbuild:
|
||||
specifier: ^0.25.6
|
||||
specifier: ^0.25.8
|
||||
version: 0.25.8
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
@@ -4998,6 +4998,7 @@ packages:
|
||||
nats@2.29.3:
|
||||
resolution: {integrity: sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
deprecated: Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
Reference in New Issue
Block a user