Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8caa647106 | ||
|
|
07b9bf6239 | ||
|
|
5932768548 | ||
|
|
aabc15a382 | ||
|
|
6ca23de63a | ||
|
|
179a5bc718 | ||
|
|
8677e55f30 | ||
|
|
719cd51919 | ||
|
|
ea747cff45 | ||
|
|
3200927f8c | ||
|
|
39cf5950a5 | ||
|
|
1781862fb1 | ||
|
|
cfd53c8142 | ||
|
|
4c5a0bb064 | ||
|
|
11862defbc |
@@ -2,6 +2,8 @@
|
||||
out/
|
||||
dist/
|
||||
*.vsix
|
||||
*.xpi
|
||||
*.crx
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# LLM.md - Hanzo Extension
|
||||
|
||||
## Overview
|
||||
Hanzo AI Development Platform Monorepo
|
||||
Hanzo AI Development Platform Monorepo. The browser extension lives in
|
||||
`packages/browser` and ships to Chrome / Firefox / Safari.
|
||||
|
||||
## Tech Stack
|
||||
- **Language**: TypeScript/JavaScript
|
||||
- **Build**: esbuild via `packages/browser/src/build.js`
|
||||
- **Tests**: vitest
|
||||
|
||||
## Build & Run
|
||||
```bash
|
||||
@@ -12,27 +15,38 @@ pnpm install && pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Structure
|
||||
## Architecture (since browser-extension 2.0.0)
|
||||
|
||||
Two-process: each Python `hanzo-mcp` hosts a ZAP server directly. The
|
||||
extension discovers it on the lowest free port from
|
||||
`[9999, 9998, 9997, 9996, 9995]` and dispatches commands over a binary
|
||||
WebSocket frame:
|
||||
|
||||
```
|
||||
extension/
|
||||
LICENSE
|
||||
LLM.md
|
||||
Makefile
|
||||
PUBLISHING.md
|
||||
README.md
|
||||
apps/
|
||||
benchmark/
|
||||
docs/
|
||||
examples/
|
||||
hanzo-ai-chrome-1.7.12.zip
|
||||
hanzo-ai-firefox-1.7.12.zip
|
||||
images/
|
||||
package.json
|
||||
packages/
|
||||
pnpm-lock.yaml
|
||||
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
|
||||
```
|
||||
|
||||
## Key Files
|
||||
- `README.md` -- Project documentation
|
||||
- `package.json` -- Dependencies and scripts
|
||||
- `Makefile` -- Build automation
|
||||
- **shared/zap.ts** — canonical wire constants (MSG_REQUEST=0x10,
|
||||
MSG_RESPONSE=0x11, MSG_PING=0xFE, MSG_PONG=0xFF). Both Chrome and
|
||||
Firefox extensions use this. Includes `setZapRequestHandler(mgr, fn)`
|
||||
so the Python server can dispatch RPCs back to the extension.
|
||||
- **background.ts** (Chrome) — wires `cdpBridge.dispatchMethod` as the
|
||||
ZAP inbound handler.
|
||||
- **background-firefox.ts** — wires `HanzoFirefoxExtension.dispatchMethod`
|
||||
as the ZAP inbound handler.
|
||||
- **cdp-bridge-server.ts** — DEPRECATED. Legacy node bridge on
|
||||
`:9223/9224`. Fallback only; not in the critical path.
|
||||
|
||||
Wire constants must stay locked across implementations:
|
||||
- `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`
|
||||
- `extension/packages/browser/src/shared/zap.ts`
|
||||
- `extension/packages/mcp/src/zap-server.ts`
|
||||
|
||||
If you change them, change all three and update `tests/shared-zap.test.ts`
|
||||
+ `tests/test_zap_server.py::TestWireFormat::test_constants_match_extension`.
|
||||
|
||||
## Versioning rule
|
||||
Bump patch (X.Y.Z+1) for protocol-compatible changes. Bump major (e.g.
|
||||
1.x → 2.0) only when the wire format changes or the architecture pivots
|
||||
(2.0.0 = ZAP becomes canonical, dropping the node bridge from default
|
||||
boot). `package.json` and both `manifest*.json` must agree.
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.8.8",
|
||||
"name": "@hanzo/extension",
|
||||
"version": "1.9.14",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Development Platform Monorepo",
|
||||
"description": "Hanzo AI Extension",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.15",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -16,14 +16,15 @@
|
||||
"axios": "^1.10.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^11.1.0",
|
||||
"webextension-polyfill": "0.12.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/firefox-webext-browser": "^120.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -8,18 +8,26 @@
|
||||
* - Uses `browser.*` APIs (with Promises) instead of `chrome.*` (callbacks)
|
||||
* - Cannot use ES module exports at top level
|
||||
* - Cannot run WebSocket servers, only clients
|
||||
* - Uses `browser.tabs.executeScript` instead of `chrome.debugger`
|
||||
* - MV3 109+ uses `browser.scripting.executeScript({world,func,args})`,
|
||||
* NOT the removed MV2 `tabs.executeScript`. See `executeScriptWithTimeout`.
|
||||
*
|
||||
* The webextension-polyfill import is a no-op on Firefox (browser.* is
|
||||
* native) but keeping it as the first import here mirrors the Chrome
|
||||
* background's structure and avoids divergence.
|
||||
*/
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import type { ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
|
||||
import {
|
||||
createZapManager,
|
||||
discoverZapServers,
|
||||
startZapDiscoveryLoop,
|
||||
connectZap,
|
||||
zapRequest,
|
||||
zapCallTool,
|
||||
hasZapTool,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import {
|
||||
@@ -37,6 +45,7 @@ import {
|
||||
firefoxAdapter,
|
||||
type BrowserAdapter,
|
||||
} from './shared/auth.js';
|
||||
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
|
||||
|
||||
// Declare browser API for TypeScript
|
||||
declare const browser: typeof chrome;
|
||||
@@ -56,6 +65,29 @@ interface CDPResponse {
|
||||
const BROWSER_NAME = 'firefox';
|
||||
const VERSION = typeof browser !== 'undefined' ? browser.runtime.getManifest().version : '0';
|
||||
|
||||
// URLs the extension can never run scripts in. browser.scripting.executeScript
|
||||
// silently returns no frames for these, which used to surface as a mystery
|
||||
// `{}` to the caller. We detect upfront and raise an actionable error.
|
||||
const PRIVILEGED_URL_PATTERNS: RegExp[] = [
|
||||
/^about:/i, // about:debugging, about:addons, about:config…
|
||||
/^moz-extension:/i, // any extension's own pages
|
||||
/^chrome:/i, // chrome://… (Firefox accepts these too)
|
||||
/^view-source:/i,
|
||||
/^resource:/i,
|
||||
/^file:/i, // local files unless explicit perm granted
|
||||
/^https?:\/\/addons\.mozilla\.org\b/i, // AMO blocks WebExtensions by policy
|
||||
];
|
||||
|
||||
function isPrivilegedTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab) return false;
|
||||
const url = tab.url || (tab as any).pendingUrl || '';
|
||||
if (!url) {
|
||||
// Some Firefox builds redact url for privileged tabs; treat as privileged.
|
||||
return true;
|
||||
}
|
||||
return PRIVILEGED_URL_PATTERNS.some((rx) => rx.test(url));
|
||||
}
|
||||
|
||||
// Shared ZAP manager (replaces inline ZAP protocol code)
|
||||
const zapMgr: ZapManager = createZapManager();
|
||||
|
||||
@@ -66,8 +98,9 @@ const controlSession: ControlSession = {
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// Start ZAP discovery on load (using shared module)
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
// ZAP discovery is started AFTER the extension class is instantiated
|
||||
// because the inbound request handler delegates to its dispatchMethod().
|
||||
// See bottom of file for the discoverZapServers() call.
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -124,9 +157,16 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
|
||||
private register(): void {
|
||||
// Tell the bridge server explicitly that we're Firefox so the
|
||||
// `browser` parameter on multi-browser actions can route to us by
|
||||
// name (e.g. `{ action: 'navigate', browser: 'firefox', url: '...' }`).
|
||||
// Without this the bridge falls back to the User-Agent sniff in
|
||||
// `assignClientId` which we can't observe from the SW (no navigator
|
||||
// headers on a WebSocket message).
|
||||
this.send({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: BROWSER_NAME, // 'firefox'
|
||||
capabilities: [
|
||||
'navigate', 'screenshot', 'click', 'dblclick', 'hover', 'type',
|
||||
'evaluate', 'tabs', 'fill', 'clear', 'reload', 'goBack', 'goForward',
|
||||
@@ -184,23 +224,36 @@ class HanzoFirefoxExtension {
|
||||
// MV3 path — preferred on every modern build (Chrome 88+, FF 109+).
|
||||
const scripting = (browser as any).scripting;
|
||||
if (scripting?.executeScript) {
|
||||
const results = await scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: 'MAIN',
|
||||
args: [code],
|
||||
func: (codeStr: string) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
return Function(codeStr)();
|
||||
} catch (e: any) {
|
||||
return { __hanzo_error: e?.message || String(e) };
|
||||
}
|
||||
},
|
||||
});
|
||||
// scripting.executeScript returns an array of {result, frameId}.
|
||||
// Surface the same shape the legacy MV2 API returned (one result
|
||||
// per frame) so runInPage's caller doesn't have to change.
|
||||
return Array.isArray(results) ? results.map((r: any) => r?.result) : [results];
|
||||
const runIn = async (world: 'MAIN' | 'ISOLATED'): Promise<any[]> => {
|
||||
const results = await scripting.executeScript({
|
||||
target: { tabId },
|
||||
world,
|
||||
args: [code],
|
||||
func: (codeStr: string) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
return Function(codeStr)();
|
||||
} catch (e: any) {
|
||||
return { __hanzo_error: e?.message || String(e) };
|
||||
}
|
||||
},
|
||||
});
|
||||
// scripting.executeScript returns an array of {result, frameId}.
|
||||
return Array.isArray(results) ? results.map((r: any) => r?.result) : [results];
|
||||
};
|
||||
|
||||
// Try MAIN (page CSP applies — see page-internal vars). If the page
|
||||
// CSP blocks Function() the inner func returns __hanzo_error;
|
||||
// ISOLATED world has its own CSP and almost always allows
|
||||
// Function(), so we silently fall back. This rescues e.g. GitHub,
|
||||
// Google Docs, banking sites with strict 'script-src self'.
|
||||
const main = await runIn('MAIN');
|
||||
const blocked = main.length > 0 && main.every((r) =>
|
||||
r && typeof r === 'object' && r.__hanzo_error &&
|
||||
/CSP|unsafe-eval|Content Security|Function|eval/i.test(String(r.__hanzo_error))
|
||||
);
|
||||
if (!blocked) return main;
|
||||
return runIn('ISOLATED');
|
||||
}
|
||||
// MV2 fallback — only Firefox <109 reaches this branch.
|
||||
const legacy = (browser as any).tabs?.executeScript;
|
||||
@@ -229,7 +282,7 @@ class HanzoFirefoxExtension {
|
||||
private async resolveTab(params: Record<string, unknown>): Promise<chrome.tabs.Tab> {
|
||||
const rawId = params.tabId ?? params.targetId;
|
||||
if (rawId !== undefined && rawId !== null) {
|
||||
const id = this.parseTabId(rawId);
|
||||
const id = parseTabId(rawId);
|
||||
if (id !== null) {
|
||||
try { return await browser.tabs.get(id); } catch { /* fall through */ }
|
||||
}
|
||||
@@ -244,34 +297,24 @@ class HanzoFirefoxExtension {
|
||||
throw new Error('No active tab');
|
||||
}
|
||||
|
||||
/** Fully anchored: rejects URLs / hashes / arbitrary strings even when
|
||||
* they happen to end with a numeric segment. See shared/tab-id.ts. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run JS in the page via executeScript and return the serialized result.
|
||||
* Wraps result to avoid undefined → {} JSON serialization issue.
|
||||
* Bumped to throw on empty/missing results so undefined → {} can no
|
||||
* longer hide a privileged-tab or CSP failure (1.9.2).
|
||||
*/
|
||||
/**
|
||||
* Detect a serialized Promise from executeScript: an empty object {} that
|
||||
* came from awaiting a thenable. We can't do better in MV3 / Firefox because
|
||||
* executeScript drops Promise values.
|
||||
*/
|
||||
private looksLikePromiseResult(value: unknown): boolean {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
&& Object.keys(value as Record<string, unknown>).length === 0
|
||||
&& !Array.isArray(value);
|
||||
}
|
||||
|
||||
private async runInPage(tabId: number, code: string, timeoutMs: number = 10000): Promise<any> {
|
||||
// Privileged URLs (about:, moz-extension:, view-source:, …) silently
|
||||
// refuse executeScript. Detect upfront with an actionable error rather
|
||||
// than letting the caller see a mystery `{}` response.
|
||||
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}). ` +
|
||||
`Activate a regular http(s) tab via tabId/tab_id parameter.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the user's code in a function body that ALWAYS has a top-level
|
||||
// `return`. Without this `Function(body)()` discards the IIFE result and
|
||||
// hands back undefined → JSON serializes to {} → caller can't tell
|
||||
@@ -286,13 +329,44 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
`;
|
||||
const results = await this.executeScriptWithTimeout(tabId, wrappedCode, timeoutMs);
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
throw new Error(
|
||||
`executeScript returned no frames for tab ${tabId} — page may be ` +
|
||||
`privileged, in a process the extension can't access, or unloaded.`,
|
||||
);
|
||||
}
|
||||
// results is the per-frame mapped array of `r?.result`. With world: MAIN
|
||||
// an `undefined` slot means a frame errored *outside* our try/catch
|
||||
// (typical CSP `script-src 'self'` blocking `Function()`). Surface that
|
||||
// explicitly instead of returning a silent `undefined`.
|
||||
const result = results[0];
|
||||
if (result === undefined) {
|
||||
throw new Error(
|
||||
`executeScript ran but returned no value (frame errored outside the ` +
|
||||
`wrapped try/catch — most often page CSP blocking Function()). Tab ${tabId}.`,
|
||||
);
|
||||
}
|
||||
if (result && typeof result === 'object' && result.__hanzo_error) {
|
||||
throw new Error(result.__hanzo_error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public dispatcher for ZAP-server-initiated requests. Mirrors
|
||||
* `CDPBridge.dispatchMethod` on the Chrome side so background-ZAP wiring
|
||||
* is symmetric across browsers. Calls into the same canonical
|
||||
* ``executeMethod`` switch already used by the legacy WS path.
|
||||
*/
|
||||
async dispatchMethod(method: string, params: Record<string, unknown>): Promise<any> {
|
||||
const result = await this.executeMethod(method, params || {});
|
||||
if (result && typeof result === 'object' && 'error' in result && result.error) {
|
||||
const err: any = new Error(String(result.error));
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeMethod(
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
@@ -329,18 +403,16 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
|
||||
case 'Target.closeTarget': {
|
||||
const closeId = params.targetId
|
||||
? parseInt((params.targetId as string).replace('tab-', ''), 10)
|
||||
: params.tabId as number;
|
||||
if (closeId) await browser.tabs.remove(closeId);
|
||||
// Use shared parseTabId for safety: rejects URLs / strings that
|
||||
// happen to end with digits (e.g. an ad URL with ?tab=123 in it).
|
||||
const closeId = parseTabId(params.targetId ?? params.tabId);
|
||||
if (closeId !== null) await browser.tabs.remove(closeId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case 'Target.activateTarget': {
|
||||
const activateId = params.targetId
|
||||
? parseInt((params.targetId as string).replace('tab-', ''), 10)
|
||||
: params.tabId as number;
|
||||
if (activateId) await browser.tabs.update(activateId, { active: true });
|
||||
const activateId = parseTabId(params.targetId ?? params.tabId);
|
||||
if (activateId !== null) await browser.tabs.update(activateId, { active: true });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -459,7 +531,13 @@ class HanzoFirefoxExtension {
|
||||
if (!tabId) throw new Error('No active tab');
|
||||
// Accept both `expression` (CDP) and `code` (MCP/SDK convention).
|
||||
const expression = (params.expression as string) ?? (params.code as string) ?? '';
|
||||
const timeout = (params.timeout as number) || 10000;
|
||||
// Default 30s — Porkbun-class pages with 1500+ DOM nodes routinely
|
||||
// need more than 10s for first-paint queries. Caller can override.
|
||||
const timeout = (params.timeout as number) || 30000;
|
||||
// Caller can opt into Promise-await semantics. CDP names this flag
|
||||
// `awaitPromise`; the MCP/SDK callers pass `await_promise`.
|
||||
const awaitPromise = Boolean(params.awaitPromise ?? params.await_promise);
|
||||
|
||||
// If the caller already wrote a `return` or wrote a function/IIFE,
|
||||
// wrap differently so we don't double-wrap and break syntax.
|
||||
const trimmed = expression.trim();
|
||||
@@ -468,19 +546,53 @@ class HanzoFirefoxExtension {
|
||||
: trimmed.endsWith(';')
|
||||
? `${trimmed} return undefined;`
|
||||
: `return (${trimmed});`;
|
||||
let value = await this.runInPage(tabId, code, timeout);
|
||||
// Auto-await Promises: the page may evaluate to a Promise, but
|
||||
// executeScript serializes it as `{}`. Probe for a `.then` and
|
||||
// resolve in-page so we return the resolved value instead.
|
||||
if (this.looksLikePromiseResult(value)) {
|
||||
value = await this.runInPage(tabId, `
|
||||
|
||||
// When awaitPromise is requested, resolve in-page so executeScript's
|
||||
// structured-clone serialization sees the resolved value rather than
|
||||
// a Promise (which it can't transfer).
|
||||
const evalCode = awaitPromise
|
||||
? `
|
||||
return Promise.resolve((function(){ ${code} })()).then(function(__v){
|
||||
try { return JSON.parse(JSON.stringify(__v)); } catch(e) { return String(__v); }
|
||||
});
|
||||
`, timeout);
|
||||
`
|
||||
: code;
|
||||
|
||||
let value: unknown;
|
||||
let evalError: string | null = null;
|
||||
try {
|
||||
value = await this.runInPage(tabId, evalCode, timeout);
|
||||
} catch (e: any) {
|
||||
evalError = e?.message || String(e);
|
||||
value = null;
|
||||
}
|
||||
// Match CDP shape (legacy callers) AND surface `value` directly.
|
||||
return { result: { value }, value };
|
||||
|
||||
// CDP-shape: { result: { type, value } } plus a top-level `value`
|
||||
// alias for ergonomic callers. Coerce `undefined` → `null` so the
|
||||
// JSON-encoded ZAP frame keeps the keys. If evaluation errored,
|
||||
// surface CDP-style `exceptionDetails` AND a top-level `error` so
|
||||
// both flavors of caller can see what happened.
|
||||
const safeValue = value === undefined ? null : value;
|
||||
const cdpType = safeValue === null
|
||||
? 'object'
|
||||
: Array.isArray(safeValue)
|
||||
? 'object'
|
||||
: typeof safeValue;
|
||||
const out: Record<string, unknown> = {
|
||||
result: { type: cdpType, value: safeValue },
|
||||
value: safeValue,
|
||||
};
|
||||
if (evalError !== null) {
|
||||
out.error = evalError;
|
||||
out.result = {
|
||||
type: 'object',
|
||||
value: null,
|
||||
subtype: 'error',
|
||||
description: evalError,
|
||||
};
|
||||
out.exceptionDetails = { text: evalError };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -2333,6 +2445,57 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
|
||||
const hanzoExtension = new HanzoFirefoxExtension();
|
||||
|
||||
// Install the ZAP inbound-request handler so Python hanzo-mcps can dispatch
|
||||
// browser actions BACK to Firefox over the same socket. This is what
|
||||
// closes the loop: the extension is no longer ZAP-client-only — it accepts
|
||||
// inbound RPC and routes via the canonical executeMethod dispatcher.
|
||||
setZapRequestHandler(zapMgr, async (method, params) => {
|
||||
return hanzoExtension.dispatchMethod(method, (params || {}) as Record<string, unknown>);
|
||||
});
|
||||
|
||||
// Start the always-on ZAP discovery loop. Ticks every 5s; each tick is a
|
||||
// no-op when we're already connected to every advertised server. Replaces
|
||||
// the earlier setTimeout chain that died if any path missed a re-arm.
|
||||
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000);
|
||||
|
||||
// =============================================================================
|
||||
// Keep-alive — two-layer:
|
||||
//
|
||||
// 1. Port: every content-script connects a `zap-keepalive` port. As long
|
||||
// as ANY tab holds a port, Firefox keeps the background event-page
|
||||
// loaded indefinitely. This is the canonical MV3 pattern and the
|
||||
// cheapest fix for the suspend bug — no polling required.
|
||||
//
|
||||
// 2. Alarms: belt-and-suspenders for the case where no http(s) tab is
|
||||
// open (about: pages, fresh window) AND the background gets
|
||||
// suspended. Alarm fires every 30s, wakes the page, re-discovers
|
||||
// if the ZAP socket is gone.
|
||||
// =============================================================================
|
||||
browser.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'zap-keepalive') return;
|
||||
// Just receiving messages keeps the background page alive — we don't
|
||||
// even need to respond. Drop the listener silently when the content
|
||||
// script unloads.
|
||||
port.onMessage.addListener(() => { /* noop — receipt is the keep-alive */ });
|
||||
});
|
||||
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
// Belt-and-suspenders: even if the discovery interval is suspended (e.g.
|
||||
// background page idled out), the alarm wakes it every 15s and triggers
|
||||
// a one-shot discovery probe.
|
||||
browser.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!zapMgr.state.connected) {
|
||||
console.log('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering');
|
||||
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Hanzo] alarms unavailable:', e);
|
||||
}
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
if (controlSession.active && controlSession.tabId === tabId) {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
// Background Service Worker for Browser Extension
|
||||
//
|
||||
// First import: webextension-polyfill. On Chrome/Edge this is a side-effect
|
||||
// import that defines `globalThis.browser` as the unified Promise-returning
|
||||
// WebExtension API. On Firefox/Safari `browser.*` is already native and the
|
||||
// polyfill is a no-op. The shared modules (auth.ts, tab-id.ts, etc.) read
|
||||
// this global so importing it once at the entrypoint is enough.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import type { ZapState, ControlSession, RagSnippet, RagQueryParams } from './shared/types.js';
|
||||
import {
|
||||
createZapManager,
|
||||
encodeZapMessage,
|
||||
discoverZapServers,
|
||||
startZapDiscoveryLoop,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
MSG_REQUEST,
|
||||
DEFAULT_ZAP_PORTS as SHARED_ZAP_PORTS,
|
||||
type ZapManager,
|
||||
} from './shared/zap.js';
|
||||
import {
|
||||
@@ -115,8 +124,11 @@ const controlSession: ControlSession = {
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// Default ports (overridable via chrome.storage.local settings)
|
||||
const DEFAULT_ZAP_PORTS = SHARED_ZAP_PORTS;
|
||||
// Default ports (overridable via chrome.storage.local settings).
|
||||
// NOTE: ZAP discovery is mDNS-only (HIP-0069); the legacy zapPorts setting
|
||||
// is no longer consulted for discovery — kept here only because other
|
||||
// surfaces (status display, settings UI) still expose it during the
|
||||
// removal window.
|
||||
const DEFAULT_MCP_PORT = CFG_MCP_PORT;
|
||||
const DEFAULT_CDP_PORT = CFG_CDP_PORT;
|
||||
const DEFAULT_RAG_TOP_K = CFG_RAG_TOP_K;
|
||||
@@ -133,11 +145,10 @@ const controlMessageLimiter = new ActionRateLimiter();
|
||||
const controlMessageSignatures = new Map<string, string>();
|
||||
|
||||
/** Load port configuration from storage */
|
||||
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
|
||||
async function getPortConfig(): Promise<{ mcpPort: number; cdpPort: number }> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['zapPorts', 'mcpPort', 'cdpPort'], (result) => {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort'], (result) => {
|
||||
resolve({
|
||||
zapPorts: result.zapPorts || DEFAULT_ZAP_PORTS,
|
||||
mcpPort: result.mcpPort || DEFAULT_MCP_PORT,
|
||||
cdpPort: result.cdpPort || DEFAULT_CDP_PORT,
|
||||
});
|
||||
@@ -1794,10 +1805,12 @@ function handleMCPMessage(data: any) {
|
||||
// Startup
|
||||
// =============================================================================
|
||||
|
||||
// 0. Register side panel
|
||||
if (chrome.sidePanel) {
|
||||
chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
|
||||
}
|
||||
// 0. Side panel intentionally NOT registered. The user requirement is a
|
||||
// right-anchored, in-page chat overlay across every browser; the native
|
||||
// chrome.sidePanel surface gives an OS-managed panel which doesn't match
|
||||
// that design and behaves differently from the Firefox path. Routing
|
||||
// through the content-script overlay (page.overlay.* messages) is the
|
||||
// single source of truth — see popup.ts.
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
@@ -1807,9 +1820,42 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
});
|
||||
|
||||
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
|
||||
getPortConfig().then(({ zapPorts }) => {
|
||||
discoverZapServers(zapMgr, BROWSER_NAME, VERSION, zapPorts, debugLog);
|
||||
// Install a request handler so Python hanzo-mcps can dispatch browser
|
||||
// actions BACK to this extension over the same socket. This is what
|
||||
// makes the 2-process architecture work — the extension is no longer
|
||||
// a one-way client; it accepts inbound RPC and routes via the existing
|
||||
// cdp-bridge dispatcher.
|
||||
setZapRequestHandler(zapMgr, async (method, params) => {
|
||||
return cdpBridge.dispatchMethod(method, params);
|
||||
});
|
||||
// mDNS-only discovery per HIP-0069 — always-on watchdog, no port hint.
|
||||
startZapDiscoveryLoop(zapMgr, BROWSER_NAME, VERSION, 5000, debugLog);
|
||||
|
||||
// Keep-alive — Layer 1: Port. Content scripts in every loaded http(s) tab
|
||||
// hold a `zap-keepalive` port. As long as ANY tab has one, Chrome keeps
|
||||
// the service worker alive without alarms.
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== 'zap-keepalive') return;
|
||||
port.onMessage.addListener(() => { /* receipt is the keep-alive */ });
|
||||
});
|
||||
|
||||
// Keep-alive — Layer 2: Alarms. Belt-and-suspenders for the case where
|
||||
// no http(s) tab is open AND the worker idles out. Alarm fires every 30s,
|
||||
// wakes the worker, re-discovers if the ZAP socket is gone.
|
||||
const KEEP_ALIVE_ALARM = 'hanzo-zap-keepalive';
|
||||
try {
|
||||
// Wakes the worker every 15s; one-shot discovery probe if disconnected.
|
||||
chrome.alarms.create(KEEP_ALIVE_ALARM, { periodInMinutes: 0.25 });
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name !== KEEP_ALIVE_ALARM) return;
|
||||
if (!zapMgr.state.connected) {
|
||||
debugLog('[Hanzo/ZAP] keep-alive alarm: no connection, rediscovering via mDNS');
|
||||
void discoverZapServers(zapMgr, BROWSER_NAME, VERSION, undefined, debugLog);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[Hanzo] alarms unavailable:', e);
|
||||
}
|
||||
|
||||
// 2. Fallback: Connect to legacy MCP WebSocket
|
||||
connectToMCP();
|
||||
|
||||
@@ -1377,30 +1377,94 @@ export class BrowserControl {
|
||||
// Script Execution Helper
|
||||
// ===========================================================================
|
||||
|
||||
private executeScript<T = any>(tabId: number, code: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (chrome.scripting) {
|
||||
// MV3: use chrome.scripting.executeScript
|
||||
chrome.scripting.executeScript({
|
||||
/**
|
||||
* Run JS in a tab and return its result.
|
||||
*
|
||||
* Why MV3 + MAIN world + the Function trampoline?
|
||||
*
|
||||
* - `chrome.scripting.executeScript` only accepts a function (or files);
|
||||
* it does NOT take a code string the way MV2's `tabs.executeScript`
|
||||
* did. To support our existing call sites that pass dynamic code as
|
||||
* a string, we serialise the user's code through `args` and run it
|
||||
* via `Function(codeStr)()` inside the page.
|
||||
* - MAIN world is required so the page's own globals (window.fetch,
|
||||
* CustomElements, framework instances) are visible. ISOLATED world
|
||||
* can't see them.
|
||||
* - Async unwrap: if the caller's code yields a Promise, executeScript
|
||||
* serialises it as `{}` (the value-loss bug). We detect that shape
|
||||
* and re-run wrapping in `Promise.resolve(...)` so the resolved
|
||||
* value comes back instead of the placeholder.
|
||||
*
|
||||
* Falls back to the MV2 string API on browsers / runtimes that still
|
||||
* have it (older Firefox; no Chrome MV2 ships now).
|
||||
*/
|
||||
private async executeScript<T = any>(tabId: number, code: string): Promise<T> {
|
||||
const exec = async (wrapped: string): Promise<T> => {
|
||||
// MV3 path — preferred on every modern build.
|
||||
if (chrome.scripting?.executeScript) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: new Function(`return (${code})`) as () => T,
|
||||
}, (results) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0]?.result as T);
|
||||
}
|
||||
world: 'MAIN',
|
||||
args: [wrapped],
|
||||
func: (codeStr: string) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
return Function(codeStr)();
|
||||
} catch (e: any) {
|
||||
return { __hanzo_error: e?.message || String(e) };
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// MV2 fallback: use chrome.tabs.executeScript
|
||||
(chrome.tabs as any).executeScript(tabId, { code }, (results: any[]) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0] as T);
|
||||
}
|
||||
return (results?.[0] as any)?.result as T;
|
||||
}
|
||||
// MV2 fallback — only Chrome-MV2 / older Firefox would reach this.
|
||||
const legacy = (chrome.tabs as any).executeScript;
|
||||
if (legacy) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
legacy(tabId, { code: wrapped }, (results: any[]) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(results?.[0] as T);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
throw new Error('No executeScript API available (need MV3 scripting permission)');
|
||||
};
|
||||
|
||||
// Wrap the user's code so it ALWAYS has a top-level `return`. The
|
||||
// Function trampoline discards the value of an IIFE — without an
|
||||
// explicit `return` the result is undefined → JSON-encodes to `{}`.
|
||||
const trimmed = code.trim();
|
||||
const wrappedCode = /^\s*return\b/.test(trimmed)
|
||||
? trimmed
|
||||
: `try { return (${trimmed}); } catch(__e) { return { __hanzo_error: __e.message || String(__e) }; }`;
|
||||
|
||||
let result: any = await exec(wrappedCode);
|
||||
|
||||
// Unwrap our error sentinel so callers see a real Error.
|
||||
if (result && typeof result === 'object' && '__hanzo_error' in result) {
|
||||
throw new Error(result.__hanzo_error);
|
||||
}
|
||||
|
||||
// Detect the empty-Promise serialization. If the caller's code yielded
|
||||
// a Promise (e.g. (async () => 'x')()) executeScript drops it as `{}`.
|
||||
// Re-run wrapped in Promise.resolve so the resolved value comes back.
|
||||
const looksEmpty = result !== null
|
||||
&& typeof result === 'object'
|
||||
&& Object.keys(result).length === 0
|
||||
&& !Array.isArray(result);
|
||||
if (looksEmpty) {
|
||||
const promiseWrapped = `return Promise.resolve((function(){ ${wrappedCode} })()).then(function(__v){
|
||||
try { return JSON.parse(JSON.stringify(__v === undefined ? null : __v)); } catch(e) { return String(__v); }
|
||||
});`;
|
||||
result = await exec(promiseWrapped);
|
||||
if (result && typeof result === 'object' && '__hanzo_error' in result) {
|
||||
throw new Error(result.__hanzo_error);
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,13 @@ const { execSync } = require('child_process');
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
|
||||
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
|
||||
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
|
||||
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
|
||||
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
|
||||
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
|
||||
const canUseSharedUi =
|
||||
!forceLocalUiShim &&
|
||||
fs.existsSync(hanzoUiPrimitives) &&
|
||||
fs.existsSync(localReact) &&
|
||||
fs.existsSync(localReactDom);
|
||||
|
||||
if (canUseSharedUi) {
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
} else {
|
||||
console.warn(
|
||||
[
|
||||
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
|
||||
`Expected: ${hanzoUiPrimitives}`,
|
||||
`Expected: ${localReact}`,
|
||||
`Expected: ${localReactDom}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
// The extension does NOT depend on `@hanzo/ui`. Primitives are served by
|
||||
// a small local shim aliased as `@hanzo/gui` so call-sites read like the
|
||||
// canonical hanzo design system. The full tamagui-based @hanzo/gui will
|
||||
// land in a feature branch — when that ships, just point the alias below
|
||||
// at the published `@hanzo/gui` package and remove gui-primitives.tsx.
|
||||
const hanzoGuiShim = path.join(__dirname, 'gui-primitives.tsx');
|
||||
console.log('Using @hanzo/gui local primitives shim:', hanzoGuiShim);
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
@@ -80,16 +64,11 @@ async function build() {
|
||||
format: 'esm'
|
||||
});
|
||||
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
|
||||
const sidebarAliases = canUseSharedUi
|
||||
? {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
}
|
||||
: {
|
||||
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
|
||||
};
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback).
|
||||
// Primitives are aliased to the local @hanzo/gui shim (see gui-primitives.tsx).
|
||||
const sidebarAliases = {
|
||||
'@hanzo/gui': hanzoGuiShim,
|
||||
};
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CDP Bridge Server - Unified browser control interface
|
||||
* CDP Bridge Server — DEPRECATED in @hanzo/browser-extension 1.9.1.
|
||||
*
|
||||
* The canonical 2-process architecture removes this node bridge from the
|
||||
* critical path. Each Python `hanzo-mcp` now hosts a ZAP server directly
|
||||
* (one of ports 9999..9995); browser extensions discover and connect to
|
||||
* those MCPs without any intermediary. See
|
||||
* `python-sdk/pkg/hanzo-tools-browser/hanzo_tools/browser/zap_server.py`.
|
||||
*
|
||||
* This file remains only as a fallback for non-ZAP MCP clients (legacy
|
||||
* stdio-only consumers that still want to talk to the extension via the
|
||||
* old WS+HTTP API on 9223/9224). It is no longer auto-launched. Do not
|
||||
* extend it for new functionality — extend the Python ZAP server and the
|
||||
* extension's `shared/zap.ts` instead.
|
||||
*
|
||||
* Provides hanzo.browser(action, params) interface matching hanzo-mcp pattern.
|
||||
* Browser extension connects to this server for remote control.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// CDP Bridge for hanzo-mcp Browser Tool Integration
|
||||
// Enables Playwright to control browser via Chrome DevTools Protocol
|
||||
import { ActionRateLimiter, debugLog, loadDebugFlagFromStorage } from './runtime-guard';
|
||||
import { parseTabId, unwrapEvaluateResult } from './shared/tab-id.js';
|
||||
|
||||
interface CDPSession {
|
||||
tabId: number;
|
||||
@@ -164,27 +165,40 @@ export class CDPBridge {
|
||||
debugLog(`[CDP] Page.captureScreenshot failed, using captureVisibleTab fallback: ${e}`);
|
||||
}
|
||||
|
||||
// Fallback: use chrome.tabs.captureVisibleTab (no debugger needed)
|
||||
// Fallback: chrome.tabs.captureVisibleTab. This API only works on a
|
||||
// *foreground* tab in the target window — Firefox enforces this strictly,
|
||||
// Chrome will sometimes succeed with a backgrounded tab but is
|
||||
// inconsistent on Mac. Brief-activate the target tab if needed and
|
||||
// restore focus afterwards. Always pass an explicit windowId resolved
|
||||
// from the tab so multi-window automation captures the right window.
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
let activatedFor: number | null = null;
|
||||
let prevActiveId: number | undefined;
|
||||
if (!tab.active) {
|
||||
const prev = await new Promise<chrome.tabs.Tab | undefined>((resolve) => {
|
||||
chrome.tabs.query({ active: true, windowId: tab.windowId }, (tabs) => resolve(tabs[0]));
|
||||
});
|
||||
prevActiveId = prev?.id;
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
activatedFor = tabId;
|
||||
// One animation tick so the tab actually composes.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
let dataUrl: string;
|
||||
try {
|
||||
dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format: options?.format === 'jpeg' ? 'jpeg' : 'png',
|
||||
quality: options?.quality,
|
||||
});
|
||||
} finally {
|
||||
if (activatedFor !== null && prevActiveId) {
|
||||
chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Strip data:image/png;base64, prefix
|
||||
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
}
|
||||
|
||||
/** Accept tabId in any of: number | "tab-NNN" | "NNN" | undefined.
|
||||
* Fully anchored so "https://x.com/?tab-123" can't be coerced. */
|
||||
private parseTabId(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw !== 'string' || raw === '') return null;
|
||||
const m = raw.match(/^(?:tab-)?(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = Number(m[1]);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
async click(tabId: number, x: number, y: number): Promise<void> {
|
||||
await this.send(tabId, 'Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
@@ -218,11 +232,18 @@ export class CDPBridge {
|
||||
|
||||
async evaluate(tabId: number, expression: string): Promise<any> {
|
||||
await this.send(tabId, 'Runtime.enable');
|
||||
// awaitPromise: true makes CDP wait for any async IIFE the caller passed
|
||||
// to resolve before returning. Without this, expressions like
|
||||
// (async () => fetch('/x').then(r => r.json()))()
|
||||
// serialize to {} (a Promise placeholder) and the caller can't tell
|
||||
// success from failure. unwrapEvaluateResult further normalises every
|
||||
// shape (Chrome / CDP / hanzo-tools) to the bare value.
|
||||
const result = await this.send(tabId, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
});
|
||||
return result.result?.value;
|
||||
return unwrapEvaluateResult(result);
|
||||
}
|
||||
|
||||
async getDocument(tabId: number): Promise<any> {
|
||||
@@ -365,6 +386,24 @@ export class CDPBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry point for ZAP-server-initiated requests.
|
||||
*
|
||||
* Wraps {@link handleBridgeMessage} so background scripts can hand off
|
||||
* ``method`` + ``params`` from a ZAP MSG_REQUEST without synthesising a
|
||||
* fake numeric ``id``. Throws on bridge error so the caller can convert
|
||||
* to a MSG_RESPONSE error frame.
|
||||
*/
|
||||
async dispatchMethod(method: string, params: any): Promise<any> {
|
||||
const response = await this.handleBridgeMessage({ id: 0, method, params: params || {} });
|
||||
if (response.error) {
|
||||
const err: any = new Error(response.error.message);
|
||||
err.code = response.error.code;
|
||||
throw err;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async handleBridgeMessage(message: any): Promise<CDPResponse> {
|
||||
const { id, method, params } = message;
|
||||
|
||||
@@ -375,7 +414,7 @@ export class CDPBridge {
|
||||
// getTargets, plain numeric string. Without explicit targeting fall
|
||||
// back to the active tab in the focused window — which is fragile when
|
||||
// the user has many windows open. Always prefer explicit ids.
|
||||
const tabId = this.parseTabId(params?.tabId ?? params?.targetId)
|
||||
const tabId = parseTabId(params?.tabId ?? params?.targetId)
|
||||
?? await this.getActiveTabId();
|
||||
|
||||
switch (method) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@hanzo/ui/primitives-common';
|
||||
} from '@hanzo/gui';
|
||||
|
||||
function LoginPrompt() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// Hanzo Browser Extension - Content Script
|
||||
// Connects clicked elements to source code via source-maps
|
||||
//
|
||||
// Polyfill MUST be the first import so any subsequent code that touches
|
||||
// `browser.*` (the cross-browser API surface) sees the wrapped Promise
|
||||
// API on Chrome too. On native runtimes (Firefox / Safari) the import is
|
||||
// a no-op.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
@@ -109,12 +115,22 @@ class HanzoContentScript {
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
case 'page.overlay.toggle':
|
||||
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
|
||||
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
}
|
||||
sendResponse({
|
||||
success: true,
|
||||
open: pageOverlay.toggle(),
|
||||
});
|
||||
return true;
|
||||
case 'page.overlay.show':
|
||||
if (request.side === 'left' || request.side === 'right') pageOverlay.setSide(request.side);
|
||||
if (request.mode === 'push' || request.mode === 'overlay') pageOverlay.setMode(request.mode);
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
}
|
||||
pageOverlay.show();
|
||||
sendResponse({ success: true, open: true });
|
||||
return true;
|
||||
@@ -123,7 +139,44 @@ class HanzoContentScript {
|
||||
sendResponse({ success: true, open: false });
|
||||
return true;
|
||||
case 'page.overlay.status':
|
||||
sendResponse({ success: true, open: pageOverlay.isOpen() });
|
||||
sendResponse({
|
||||
success: true,
|
||||
open: pageOverlay.isOpen(),
|
||||
side: pageOverlay.getSide(),
|
||||
mode: pageOverlay.getMode(),
|
||||
});
|
||||
return true;
|
||||
case 'page.overlay.setSide':
|
||||
if (request.side === 'left' || request.side === 'right') {
|
||||
pageOverlay.setSide(request.side);
|
||||
sendResponse({ success: true, side: request.side });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'side must be "left" or "right"' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setMode':
|
||||
if (request.mode === 'push' || request.mode === 'overlay') {
|
||||
pageOverlay.setMode(request.mode);
|
||||
sendResponse({ success: true, mode: request.mode });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'mode must be "push" or "overlay"' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setWidth':
|
||||
if (request.width === 'compact' || request.width === 'default' || request.width === 'wide') {
|
||||
pageOverlay.setWidth(request.width);
|
||||
sendResponse({ success: true, width: request.width });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'width must be compact|default|wide' });
|
||||
}
|
||||
return true;
|
||||
case 'page.overlay.setEnabled':
|
||||
if (typeof request.enabled === 'boolean') {
|
||||
pageOverlay.setEnabled(request.enabled);
|
||||
sendResponse({ success: true, enabled: request.enabled });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'enabled must be boolean' });
|
||||
}
|
||||
return true;
|
||||
|
||||
// --- Popup Tool Actions ---
|
||||
@@ -748,10 +801,22 @@ interface OverlayElementContext {
|
||||
html: string;
|
||||
}
|
||||
|
||||
type OverlayMode = 'overlay' | 'push';
|
||||
type OverlayWidth = 'compact' | 'default' | 'wide';
|
||||
|
||||
const OVERLAY_WIDTH_PX: Record<OverlayWidth, number> = {
|
||||
compact: 320,
|
||||
default: 420,
|
||||
wide: 560,
|
||||
};
|
||||
|
||||
class HanzoPageOverlay {
|
||||
private mounted = false;
|
||||
private enabled = false;
|
||||
private open = false;
|
||||
private side: 'left' | 'right' = 'right';
|
||||
private mode: OverlayMode = 'overlay';
|
||||
private width: OverlayWidth = 'default';
|
||||
private sending = false;
|
||||
private watchMode = false;
|
||||
private editMode = false;
|
||||
@@ -778,6 +843,53 @@ class HanzoPageOverlay {
|
||||
this.injectStyles();
|
||||
this.mount();
|
||||
this.bindGlobalEvents();
|
||||
this.restorePrefs();
|
||||
}
|
||||
|
||||
private restorePrefs(): void {
|
||||
try {
|
||||
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
|
||||
if (!ss?.get) return;
|
||||
const promised = ss.get(['overlaySide', 'overlayMode', 'overlayWidth']);
|
||||
const apply = (v: any) => {
|
||||
if (v?.overlaySide === 'left' || v?.overlaySide === 'right') {
|
||||
this.side = v.overlaySide;
|
||||
this.root?.setAttribute('data-side', this.side);
|
||||
}
|
||||
if (v?.overlayMode === 'push' || v?.overlayMode === 'overlay') {
|
||||
this.mode = v.overlayMode;
|
||||
this.root?.setAttribute('data-mode', this.mode);
|
||||
}
|
||||
if (v?.overlayWidth === 'compact' || v?.overlayWidth === 'default' || v?.overlayWidth === 'wide') {
|
||||
this.width = v.overlayWidth;
|
||||
this.applyWidth();
|
||||
}
|
||||
this.applyPushIfOpen();
|
||||
};
|
||||
if (promised && typeof promised.then === 'function') {
|
||||
promised.then(apply).catch(() => {});
|
||||
} else {
|
||||
ss.get(['overlaySide', 'overlayMode', 'overlayWidth'], apply);
|
||||
}
|
||||
} catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
private applyWidth(): void {
|
||||
const px = OVERLAY_WIDTH_PX[this.width] ?? OVERLAY_WIDTH_PX.default;
|
||||
if (this.root) {
|
||||
this.root.style.setProperty('--hanzo-overlay-width', `${px}px`);
|
||||
this.root.style.width = `min(${px}px, 100vw)`;
|
||||
}
|
||||
document.documentElement.style.setProperty('--hanzo-overlay-width', `${px}px`);
|
||||
}
|
||||
|
||||
private applyPushIfOpen(): void {
|
||||
const html = document.documentElement;
|
||||
// Always strip first so we never stack margins.
|
||||
html.classList.remove('hanzo-overlay-push', 'hanzo-overlay-push-left', 'hanzo-overlay-push-right');
|
||||
if (this.open && this.enabled && this.mode === 'push') {
|
||||
html.classList.add('hanzo-overlay-push', `hanzo-overlay-push-${this.side}`);
|
||||
}
|
||||
}
|
||||
|
||||
toggle(): boolean {
|
||||
@@ -789,12 +901,59 @@ class HanzoPageOverlay {
|
||||
return false;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
}
|
||||
|
||||
getSide(): 'left' | 'right' {
|
||||
return this.side;
|
||||
}
|
||||
|
||||
getMode(): OverlayMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
setSide(side: 'left' | 'right'): void {
|
||||
this.side = side;
|
||||
this.root?.setAttribute('data-side', side);
|
||||
this.applyPushIfOpen();
|
||||
this.persist({ overlaySide: side });
|
||||
}
|
||||
|
||||
setMode(mode: OverlayMode): void {
|
||||
this.mode = mode;
|
||||
this.root?.setAttribute('data-mode', mode);
|
||||
this.applyPushIfOpen();
|
||||
this.persist({ overlayMode: mode });
|
||||
}
|
||||
|
||||
setWidth(width: OverlayWidth): void {
|
||||
this.width = width;
|
||||
this.applyWidth();
|
||||
this.persist({ overlayWidth: width });
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
this.root?.setAttribute('data-enabled', enabled ? 'true' : 'false');
|
||||
if (!enabled) this.hide();
|
||||
this.persist({ overlayEnabled: enabled });
|
||||
}
|
||||
|
||||
private persist(values: Record<string, unknown>): void {
|
||||
try {
|
||||
const ss = (typeof browser !== 'undefined' ? browser : (chrome as any))?.storage?.sync;
|
||||
ss?.set?.(values);
|
||||
} catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.ensureMounted();
|
||||
this.enabled = true;
|
||||
this.open = true;
|
||||
this.root?.setAttribute('data-enabled', 'true');
|
||||
this.root?.setAttribute('data-open', 'true');
|
||||
this.applyPushIfOpen();
|
||||
this.loadModels();
|
||||
this.focusInput();
|
||||
}
|
||||
@@ -802,16 +961,13 @@ class HanzoPageOverlay {
|
||||
hide(): void {
|
||||
this.open = false;
|
||||
this.root?.setAttribute('data-open', 'false');
|
||||
this.applyPushIfOpen();
|
||||
this.updateStatus('');
|
||||
this.clearHighlight();
|
||||
this.setWatchMode(false);
|
||||
this.setEditMode(false);
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.open;
|
||||
}
|
||||
|
||||
private ensureMounted(): void {
|
||||
if (this.mounted) return;
|
||||
this.mount();
|
||||
@@ -823,74 +979,68 @@ class HanzoPageOverlay {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hanzo-page-overlay-styles';
|
||||
style.textContent = `
|
||||
/* Edge-pinned sidebar panel — no FAB, opened from extension popup.
|
||||
Hanzo brand: monochrome (white primary, neutral surfaces). No blue. */
|
||||
#hanzo-page-overlay-root {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: min(var(--hanzo-overlay-width, 420px), 100vw);
|
||||
z-index: 2147483645;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-side="left"] {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-enabled="false"] .hanzo-page-launcher {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
pointer-events: none;
|
||||
/* Push mode: when the user pins the panel as a dock, html shifts so
|
||||
page content reflows beside it instead of being covered. Applied to
|
||||
<html> from setMode('push') + open. */
|
||||
html.hanzo-overlay-push.hanzo-overlay-push-right {
|
||||
margin-right: var(--hanzo-overlay-width, 420px) !important;
|
||||
transition: margin-right 0.18s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-launcher {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(135deg, rgba(22, 24, 31, 0.96), rgba(7, 8, 12, 0.96));
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-launcher:hover {
|
||||
transform: translateY(-2px);
|
||||
html.hanzo-overlay-push.hanzo-overlay-push-left {
|
||||
margin-left: var(--hanzo-overlay-width, 420px) !important;
|
||||
transition: margin-left 0.18s ease;
|
||||
}
|
||||
|
||||
.hanzo-page-panel {
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
max-height: min(620px, calc(100vh - 28px));
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(11, 13, 18, 0.97), rgba(7, 8, 12, 0.97));
|
||||
color: #eef2ff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(10, 10, 11, 0.97), rgba(7, 7, 8, 0.97));
|
||||
color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.45);
|
||||
transform-origin: bottom right;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
box-shadow: -22px 0 48px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
min-height: 0;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
#hanzo-page-overlay-root[data-side="left"] .hanzo-page-panel {
|
||||
border-left: none;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.16);
|
||||
box-shadow: 22px 0 48px rgba(0, 0, 0, 0.45);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-launcher {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
#hanzo-page-overlay-root[data-open="true"] .hanzo-page-panel {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hanzo-page-header {
|
||||
@@ -898,14 +1048,16 @@ class HanzoPageOverlay {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
font-size: 13px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.hanzo-page-header-actions {
|
||||
@@ -918,16 +1070,22 @@ class HanzoPageOverlay {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #d6deff;
|
||||
background: transparent;
|
||||
color: rgba(245, 245, 245, 0.85);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hanzo-chip-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hanzo-chip-btn[data-active="true"] {
|
||||
border-color: rgba(56, 189, 248, 0.62);
|
||||
background: rgba(14, 116, 144, 0.3);
|
||||
color: #e0f2fe;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hanzo-icon-btn {
|
||||
@@ -936,9 +1094,28 @@ class HanzoPageOverlay {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.hanzo-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hanzo-icon-btn[data-active="true"] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.hanzo-icon-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.hanzo-page-meta {
|
||||
@@ -947,11 +1124,12 @@ class HanzoPageOverlay {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-context {
|
||||
font-size: 11px;
|
||||
color: #a5b4fc;
|
||||
color: rgba(245, 245, 245, 0.65);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -959,11 +1137,12 @@ class HanzoPageOverlay {
|
||||
|
||||
.hanzo-page-status {
|
||||
font-size: 11px;
|
||||
color: #93c5fd;
|
||||
color: rgba(245, 245, 245, 0.55);
|
||||
}
|
||||
|
||||
.hanzo-model-row {
|
||||
padding: 0 14px 8px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-model-row select {
|
||||
@@ -971,15 +1150,16 @@ class HanzoPageOverlay {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #f1f5f9;
|
||||
color: #f5f5f5;
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The chat scroller: takes ALL remaining vertical space so the
|
||||
composer below stays sticky against the bottom of the panel. */
|
||||
.hanzo-page-messages {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
max-height: 370px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 14px 12px 14px;
|
||||
display: flex;
|
||||
@@ -998,23 +1178,27 @@ class HanzoPageOverlay {
|
||||
.hanzo-page-msg.user {
|
||||
align-self: flex-end;
|
||||
max-width: 88%;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.hanzo-page-msg.assistant {
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
/* Composer pinned to bottom of the panel. flex-shrink: 0 prevents
|
||||
it from collapsing when the messages scroller grows. */
|
||||
.hanzo-page-composer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.10);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hanzo-page-composer textarea {
|
||||
@@ -1025,21 +1209,31 @@ class HanzoPageOverlay {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.17);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #f8fafc;
|
||||
color: #f5f5f5;
|
||||
padding: 9px 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Primary action: WHITE per @hanzo/brand monochrome guidelines. */
|
||||
.hanzo-page-composer button {
|
||||
min-width: 68px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.65);
|
||||
background: linear-gradient(180deg, rgba(37, 99, 235, 0.95), rgba(30, 64, 175, 0.95));
|
||||
color: #eff6ff;
|
||||
border: 1px solid #ffffff;
|
||||
background: #ffffff;
|
||||
color: #0a0a0b;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, transform 0.08s;
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.hanzo-page-composer button:disabled {
|
||||
@@ -1051,13 +1245,13 @@ class HanzoPageOverlay {
|
||||
position: fixed;
|
||||
z-index: 2147483644;
|
||||
pointer-events: none;
|
||||
border: 2px solid rgba(56, 189, 248, 0.95);
|
||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(14, 116, 144, 0.2);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-hanzo-inline-editing="true"] {
|
||||
outline: 2px solid rgba(251, 191, 36, 0.92) !important;
|
||||
outline: 2px solid rgba(255, 255, 255, 0.92) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`;
|
||||
@@ -1071,14 +1265,22 @@ class HanzoPageOverlay {
|
||||
this.root.id = 'hanzo-page-overlay-root';
|
||||
this.root.setAttribute('data-open', 'false');
|
||||
this.root.setAttribute('data-enabled', 'false');
|
||||
this.root.setAttribute('data-side', this.side);
|
||||
this.root.setAttribute('data-mode', this.mode);
|
||||
this.applyWidth();
|
||||
this.root.innerHTML = `
|
||||
<div class="hanzo-page-panel" role="dialog" aria-label="Hanzo Page Assistant">
|
||||
<div class="hanzo-page-header">
|
||||
<div class="hanzo-page-brand">Hanzo Overlay</div>
|
||||
<div class="hanzo-page-brand">Hanzo</div>
|
||||
<div class="hanzo-page-header-actions">
|
||||
<button class="hanzo-chip-btn" data-role="watch" data-active="false" title="Watch elements on hover">Watch</button>
|
||||
<button class="hanzo-chip-btn" data-role="edit" data-active="false" title="Click any element to edit text">Edit</button>
|
||||
<button class="hanzo-icon-btn" data-role="close" title="Minimize">×</button>
|
||||
<button class="hanzo-icon-btn" data-role="pin-mode" title="Toggle: pin (push content) vs overlay (float over page)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M9 4v16M3 4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="hanzo-icon-btn" data-role="close" title="Hide overlay">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hanzo-page-meta">
|
||||
@@ -1102,11 +1304,10 @@ class HanzoPageOverlay {
|
||||
<button data-role="send" type="button">Ask</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hanzo-page-launcher" data-role="launcher" type="button" title="Open Hanzo Overlay">AI</button>
|
||||
`;
|
||||
|
||||
this.panel = this.root.querySelector('.hanzo-page-panel');
|
||||
this.launcher = this.root.querySelector('[data-role="launcher"]');
|
||||
this.launcher = null;
|
||||
this.messagesEl = this.root.querySelector('[data-role="messages"]');
|
||||
this.inputEl = this.root.querySelector('[data-role="input"]');
|
||||
this.sendBtn = this.root.querySelector('[data-role="send"]');
|
||||
@@ -1116,8 +1317,14 @@ class HanzoPageOverlay {
|
||||
this.editBtn = this.root.querySelector('[data-role="edit"]');
|
||||
this.statusEl = this.root.querySelector('.hanzo-page-status');
|
||||
|
||||
this.launcher?.addEventListener('click', () => this.show());
|
||||
this.root.querySelector('[data-role="close"]')?.addEventListener('click', () => this.hide());
|
||||
const pinModeBtn = this.root.querySelector('[data-role="pin-mode"]');
|
||||
pinModeBtn?.setAttribute('data-active', this.mode === 'push' ? 'true' : 'false');
|
||||
pinModeBtn?.addEventListener('click', () => {
|
||||
const next: OverlayMode = this.mode === 'push' ? 'overlay' : 'push';
|
||||
this.setMode(next);
|
||||
pinModeBtn.setAttribute('data-active', next === 'push' ? 'true' : 'false');
|
||||
});
|
||||
this.watchBtn?.addEventListener('click', () => this.setWatchMode(!this.watchMode));
|
||||
this.editBtn?.addEventListener('click', () => this.setEditMode(!this.editMode));
|
||||
this.sendBtn?.addEventListener('click', () => this.askPageQuestion());
|
||||
@@ -1733,3 +1940,27 @@ const inlineConsole = (() => {
|
||||
|
||||
// Initialize
|
||||
new HanzoContentScript();
|
||||
|
||||
// =============================================================================
|
||||
// Background keep-alive port.
|
||||
//
|
||||
// Firefox MV3 treats `background.scripts` as event pages; Chrome MV3 service
|
||||
// workers idle out after ~30s. An open `chrome.runtime` port from any
|
||||
// content script keeps the background page alive for the lifetime of the
|
||||
// port — no polling, no alarms required. Every loaded http(s) tab holds
|
||||
// one of these, so the background ZAP socket survives indefinitely as
|
||||
// long as the user has any non-privileged tab open.
|
||||
//
|
||||
// Heartbeats every 10s prevent Firefox's 4-minute idle-port disconnect.
|
||||
// =============================================================================
|
||||
try {
|
||||
const port = (browser as any)?.runtime?.connect?.({ name: 'zap-keepalive' })
|
||||
|| (chrome as any)?.runtime?.connect?.({ name: 'zap-keepalive' });
|
||||
if (port) {
|
||||
const tick = setInterval(() => {
|
||||
try { port.postMessage({ type: 'ping', t: Date.now() }); }
|
||||
catch { clearInterval(tick); }
|
||||
}, 10_000);
|
||||
port.onDisconnect?.addListener?.(() => clearInterval(tick));
|
||||
}
|
||||
} catch { /* not all pages let content scripts open ports — best effort */ }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Local @hanzo/gui primitives shim for the extension.
|
||||
*
|
||||
* This is the canonical surface the extension's React components import via
|
||||
* `@hanzo/gui`. It deliberately does NOT pull in the upstream tamagui-based
|
||||
* `@hanzo/gui` package because:
|
||||
*
|
||||
* 1. content-script + background bundles target the smallest possible
|
||||
* payload — RNW + tamagui's runtime is far too heavy for a script
|
||||
* injected into every page;
|
||||
* 2. Hanzo brand is strict monochrome (see ~/work/hanzo/brand/brand.json,
|
||||
* primaryColor #FFFFFF), so no color tokens travel with primitives
|
||||
* anyway — pure structural elements styled by sibling CSS;
|
||||
* 3. The full tamagui adoption ships in a future feature branch with the
|
||||
* compiler + RNW peer dep wired correctly.
|
||||
*
|
||||
* The build aliases `@hanzo/gui` → this file. When the tamagui adoption
|
||||
* lands, swap the alias target to the published `@hanzo/gui` package.
|
||||
*
|
||||
* Each primitive renders a plain DOM element with a stable, monochrome
|
||||
* class hook. All visuals come from sidebar.css / popup.css using the
|
||||
* brand token scale (white luminance ladder, no chromatic accents).
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-gui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-gui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { className, ...rest } = props;
|
||||
return <input {...rest} className={cx('hanzo-gui-input', className)} />;
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-gui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-gui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-gui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* Layout primitives — mirror the names used by the upstream Tamagui-based
|
||||
@hanzo/gui (XStack/YStack) so call-sites can be ported without churn
|
||||
when the full adoption lands. Pure flexbox under the hood. */
|
||||
type Stack = Classy & React.HTMLAttributes<HTMLDivElement> & {
|
||||
gap?: number | string;
|
||||
};
|
||||
|
||||
export function XStack({ className, gap, style, children, ...rest }: Stack) {
|
||||
const merged: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap,
|
||||
...style,
|
||||
};
|
||||
return (
|
||||
<div {...rest} style={merged} className={cx('hanzo-gui-xstack', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function YStack({ className, gap, style, children, ...rest }: Stack) {
|
||||
const merged: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap,
|
||||
...style,
|
||||
};
|
||||
return (
|
||||
<div {...rest} style={merged} className={cx('hanzo-gui-ystack', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Text({ className, children, ...rest }: Classy & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span {...rest} className={cx('hanzo-gui-text', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.15",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -10,7 +10,9 @@
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"scripting",
|
||||
"cookies"
|
||||
"cookies",
|
||||
"nativeMessaging",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
@@ -45,6 +47,15 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"sidebar_action": {
|
||||
"default_title": "Hanzo AI",
|
||||
"default_panel": "sidebar.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png"
|
||||
},
|
||||
"open_at_install": false
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
@@ -62,13 +73,5 @@
|
||||
"strict_min_version": "109.0"
|
||||
},
|
||||
"gecko_android": {}
|
||||
},
|
||||
"data_collection_permissions": {
|
||||
"purposes": [
|
||||
"functionality"
|
||||
],
|
||||
"data_categories": [
|
||||
"technical_data"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.15",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"identity",
|
||||
"sidePanel",
|
||||
"storage",
|
||||
"tabs",
|
||||
"webNavigation",
|
||||
"scripting",
|
||||
"cookies",
|
||||
"debugger"
|
||||
"debugger",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost/*",
|
||||
@@ -45,9 +45,6 @@
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidebar.html"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Hanzo AI Browser Extension Popup — aligned with @hanzo/ui dark theme */
|
||||
/* Hanzo AI Browser Extension Popup — @hanzo/gui dark theme, monochrome */
|
||||
|
||||
:root {
|
||||
--primary: #fafafa;
|
||||
@@ -13,9 +13,10 @@
|
||||
--border-strong: rgba(255,255,255,0.16);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--glass-strong: rgba(255,255,255,0.07);
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
/* Status: monochrome by hue per @hanzo/brand (white luminance scale). */
|
||||
--success: #fafafa;
|
||||
--warning: #d4d4d4;
|
||||
--error: rgba(255,255,255,0.55);
|
||||
--link: #a3a3a3;
|
||||
--link-hover: #fafafa;
|
||||
--radius: 10px;
|
||||
|
||||
@@ -57,20 +57,43 @@
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- Actions: single Open Chat button + on-page surface settings. -->
|
||||
<button id="open-panel" class="btn btn-secondary" style="margin-bottom: 8px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Open Chat Panel
|
||||
</button>
|
||||
<button id="open-page-overlay" class="btn btn-ghost" style="margin-bottom: 12px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v12H8l-4 4V4z"/>
|
||||
</svg>
|
||||
Toggle On-Page Overlay
|
||||
Open Chat
|
||||
</button>
|
||||
|
||||
<!-- On-page chat config -->
|
||||
<div class="onpage-config" style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;font-size:11px;">
|
||||
<div class="feature-toggle" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="overlay-enabled" checked>
|
||||
<span>On-page chat panel</span>
|
||||
</label>
|
||||
<span style="color:var(--text-muted,#888);font-size:10px;">overlay vs new tab</span>
|
||||
</div>
|
||||
|
||||
<div id="overlay-side-row" class="side-picker" style="display:flex;gap:6px;align-items:center;">
|
||||
<span style="color:var(--text-muted,#888);">Pin:</span>
|
||||
<button id="pin-left" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;">Left</button>
|
||||
<button id="pin-right" class="btn btn-ghost" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Right</button>
|
||||
</div>
|
||||
|
||||
<div id="overlay-width-row" style="display:flex;gap:6px;align-items:center;">
|
||||
<span style="color:var(--text-muted,#888);">Width:</span>
|
||||
<button class="btn btn-ghost width-btn" data-width="compact" style="flex:1;padding:4px 8px;font-size:11px;">Compact</button>
|
||||
<button class="btn btn-ghost width-btn" data-width="default" style="flex:1;padding:4px 8px;font-size:11px;" data-active="true">Default</button>
|
||||
<button class="btn btn-ghost width-btn" data-width="wide" style="flex:1;padding:4px 8px;font-size:11px;">Wide</button>
|
||||
</div>
|
||||
|
||||
<span style="color:var(--text-muted,#888);font-size:10px;line-height:1.4;">
|
||||
FF native sidebar: View → Sidebar → Hanzo AI.<br>
|
||||
Disable to fall back to a standalone tab — useful on sites with strict CSP.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tools -->
|
||||
@@ -313,7 +336,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- Footer: version + nav links -->
|
||||
<div class="footer-version" style="text-align:center;font-size:10px;color:var(--text-muted,#888);margin-top:14px;letter-spacing:0.04em;">
|
||||
Hanzo AI <span id="footer-version">…</span>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://docs.hanzo.ai" target="_blank">Docs</a>
|
||||
<a href="https://console.hanzo.ai" target="_blank">Console</a>
|
||||
|
||||
+112
-24
@@ -1,5 +1,10 @@
|
||||
// Hanzo AI — Popup Script
|
||||
// Uses background message passing for auth (not direct storage access).
|
||||
//
|
||||
// Polyfill the WebExtension API surface so this script can read/write
|
||||
// chrome.storage with Promise-returning calls on every browser. Chrome
|
||||
// gets the polyfill wrapper, Firefox/Safari get a no-op.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
DEFAULT_INSPECT_SHORTCUT,
|
||||
@@ -23,13 +28,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const mainSection = document.getElementById('main-section');
|
||||
const settingsSection = document.getElementById('settings-section');
|
||||
|
||||
// Footer version — pulled from manifest at runtime so it always tracks build.
|
||||
const versionEl = document.getElementById('footer-version');
|
||||
if (versionEl) {
|
||||
try {
|
||||
versionEl.textContent = `v${chrome.runtime.getManifest().version}`;
|
||||
} catch { /* manifest unavailable in some contexts */ }
|
||||
}
|
||||
|
||||
// Auth
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
// Open panel
|
||||
const openPanel = document.getElementById('open-panel');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay'); // legacy id, may not exist
|
||||
const pinLeftBtn = document.getElementById('pin-left');
|
||||
const pinRightBtn = document.getElementById('pin-right');
|
||||
const overlayEnabledCheckbox = document.getElementById('overlay-enabled') as HTMLInputElement | null;
|
||||
const widthBtns = Array.from(document.querySelectorAll('.width-btn')) as HTMLButtonElement[];
|
||||
const overlaySideRow = document.getElementById('overlay-side-row');
|
||||
const overlayWidthRow = document.getElementById('overlay-width-row');
|
||||
|
||||
type OverlayWidth = 'compact' | 'default' | 'wide';
|
||||
|
||||
function syncSettingsUi(enabled: boolean, side: 'left' | 'right', width: OverlayWidth) {
|
||||
if (overlayEnabledCheckbox) overlayEnabledCheckbox.checked = enabled;
|
||||
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
|
||||
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
|
||||
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
|
||||
// Grey out side/width when on-page overlay is disabled.
|
||||
[overlaySideRow, overlayWidthRow].forEach((row) => {
|
||||
if (!row) return;
|
||||
(row as HTMLElement).style.opacity = enabled ? '1' : '0.5';
|
||||
(row as HTMLElement).style.pointerEvents = enabled ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Status dots
|
||||
const zapStatus = document.getElementById('zap-status');
|
||||
@@ -100,34 +134,88 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Open chat panel ---
|
||||
// Always uses the in-page right-anchored overlay (content-script). The
|
||||
// browser-native sidebar (chrome.sidePanel / browser.sidebarAction) is
|
||||
// not used because:
|
||||
// - Firefox always renders its sidebar on the user's preferred side
|
||||
// (default left) and surrounds it with the native sidebar-icon strip,
|
||||
// which the user explicitly does NOT want.
|
||||
// - Chrome's sidePanel is right-anchored, but the position is OS-managed
|
||||
// and doesn't match the in-page overlay's behaviour.
|
||||
// Routing every "Open Chat Panel" click through page.overlay gives a
|
||||
// consistent right-anchored chat across every browser.
|
||||
// --- Open chat ---
|
||||
// Default surface: in-page edge-pinned overlay (content-script). Honours
|
||||
// the user's `overlayEnabled`, `overlaySide`, and `overlayWidth` prefs.
|
||||
// When `overlayEnabled` is false (or the active tab is privileged), opens
|
||||
// the chat surface in a standalone tab. Firefox users can also reach it
|
||||
// via View → Sidebar → Hanzo AI (registered via sidebar_action).
|
||||
function openSidebarTab() {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
|
||||
window.close();
|
||||
}
|
||||
|
||||
openPanel?.addEventListener('click', () => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
const target = isInjectableTab(activeTab) ? activeTab : null;
|
||||
if (!target?.id) {
|
||||
// Fallback: open a standalone window with the chat surface so the
|
||||
// popup-button still does something on chrome:// / about: pages.
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('sidebar.html') });
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.show', tabId: target.id }, () => {
|
||||
window.close();
|
||||
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
|
||||
const enabled = vals?.overlayEnabled !== false;
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
if (!enabled) { openSidebarTab(); return; }
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
const target = isInjectableTab(activeTab) ? activeTab : null;
|
||||
if (!target?.id) { openSidebarTab(); return; }
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'page.overlay.show',
|
||||
tabId: target.id,
|
||||
side,
|
||||
width,
|
||||
}, () => window.close());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setActiveSide(side: 'left' | 'right') {
|
||||
chrome.storage?.sync?.set?.({ overlaySide: side });
|
||||
pinLeftBtn?.setAttribute('data-active', side === 'left' ? 'true' : 'false');
|
||||
pinRightBtn?.setAttribute('data-active', side === 'right' ? 'true' : 'false');
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setSide', tabId: id, side });
|
||||
});
|
||||
}
|
||||
function setActiveWidth(width: OverlayWidth) {
|
||||
chrome.storage?.sync?.set?.({ overlayWidth: width });
|
||||
widthBtns.forEach((b) => b.setAttribute('data-active', b.getAttribute('data-width') === width ? 'true' : 'false'));
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setWidth', tabId: id, width });
|
||||
});
|
||||
}
|
||||
function setOverlayEnabled(enabled: boolean) {
|
||||
chrome.storage?.sync?.set?.({ overlayEnabled: enabled });
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const id = tabs[0]?.id;
|
||||
if (id) chrome.runtime.sendMessage({ action: 'page.overlay.setEnabled', tabId: id, enabled });
|
||||
});
|
||||
chrome.storage?.sync?.get?.(['overlaySide', 'overlayWidth'], (vals) => {
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
syncSettingsUi(enabled, side, width);
|
||||
});
|
||||
}
|
||||
|
||||
pinLeftBtn?.addEventListener('click', () => setActiveSide('left'));
|
||||
pinRightBtn?.addEventListener('click', () => setActiveSide('right'));
|
||||
widthBtns.forEach((b) => b.addEventListener('click', () => {
|
||||
const w = b.getAttribute('data-width') as OverlayWidth | null;
|
||||
if (w === 'compact' || w === 'default' || w === 'wide') setActiveWidth(w);
|
||||
}));
|
||||
overlayEnabledCheckbox?.addEventListener('change', (e) => {
|
||||
setOverlayEnabled((e.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
// Initial UI sync from storage.
|
||||
chrome.storage?.sync?.get?.(['overlayEnabled', 'overlaySide', 'overlayWidth'], (vals) => {
|
||||
const enabled = vals?.overlayEnabled !== false;
|
||||
const side = vals?.overlaySide === 'left' ? 'left' : 'right';
|
||||
const width: OverlayWidth = vals?.overlayWidth === 'compact'
|
||||
? 'compact' : vals?.overlayWidth === 'wide' ? 'wide' : 'default';
|
||||
syncSettingsUi(enabled, side, width);
|
||||
});
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-ui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
@@ -339,47 +339,66 @@ export async function getAuthStatus(storage: BrowserStorage): Promise<{
|
||||
return { authenticated: true, user };
|
||||
}
|
||||
|
||||
// ── Chrome/Firefox Adapter Factories ────────────────────────────────────
|
||||
|
||||
/** Create a BrowserAdapter from Chrome extension APIs */
|
||||
export function chromeAdapter(): BrowserAdapter {
|
||||
return {
|
||||
storage: {
|
||||
get: (keys) => new Promise((resolve) => chrome.storage.local.get(keys, resolve)),
|
||||
set: (items) => chrome.storage.local.set(items),
|
||||
remove: (keys) => chrome.storage.local.remove(keys),
|
||||
},
|
||||
tabs: {
|
||||
create: (opts) => new Promise((resolve, reject) => {
|
||||
chrome.tabs.create(opts, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab?.id) {
|
||||
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
|
||||
} else {
|
||||
resolve(tab);
|
||||
}
|
||||
});
|
||||
}),
|
||||
remove: (tabId) => chrome.tabs.remove(tabId),
|
||||
onUpdated: chrome.tabs.onUpdated,
|
||||
onRemoved: chrome.tabs.onRemoved,
|
||||
},
|
||||
};
|
||||
// ── WebExtension Adapter Factory ────────────────────────────────────────
|
||||
//
|
||||
// Unified across Chrome (MV3), Firefox (MV3), Safari (WebExtension), and
|
||||
// Edge (Chromium MV3). Uses webextension-polyfill at the entrypoint level
|
||||
// so `globalThis.browser` is the canonical Promise-returning API on every
|
||||
// runtime. On Firefox/Safari `browser.*` is native; on Chrome the polyfill
|
||||
// wraps `chrome.*` callbacks. The result is ONE codepath that works
|
||||
// identically everywhere — no more separate `chromeAdapter` /
|
||||
// `firefoxAdapter` factories with subtly different behavior.
|
||||
//
|
||||
// The adapter intentionally still targets a small surface (storage +
|
||||
// tabs) because that's all auth.ts needs. If we ever migrate the wider
|
||||
// codebase to `browser.*` we'd extend this — but for now keeping the
|
||||
// surface minimal avoids unnecessary churn in callback-style listeners
|
||||
// elsewhere (e.g. the runtime.onMessage handlers, which are still chrome
|
||||
// callback style by design).
|
||||
function getBrowser(): typeof browser {
|
||||
// Prefer the polyfilled `browser` if present (Chrome with the polyfill
|
||||
// imported at entrypoint, or any native browser.*-supporting runtime).
|
||||
// Fall back to wrapping `chrome.*` directly if neither is present
|
||||
// (which only happens in tests / non-extension contexts).
|
||||
return (globalThis as any).browser ?? (globalThis as any).chrome;
|
||||
}
|
||||
|
||||
/** Create a BrowserAdapter from Firefox WebExtension APIs */
|
||||
export function firefoxAdapter(): BrowserAdapter {
|
||||
const b = (globalThis as any).browser;
|
||||
/**
|
||||
* Create a BrowserAdapter that works on Chrome/Firefox/Edge/Safari.
|
||||
* Requires `globalThis.browser` to be defined (which the polyfill
|
||||
* import at the entrypoint guarantees on Chrome).
|
||||
*/
|
||||
export function webExtensionAdapter(): BrowserAdapter {
|
||||
const b: any = getBrowser();
|
||||
return {
|
||||
storage: {
|
||||
get: (keys) => b.storage.local.get(keys),
|
||||
set: (items) => b.storage.local.set(items),
|
||||
remove: (keys) => b.storage.local.remove(keys),
|
||||
// Promise-returning on every runtime once the polyfill is loaded.
|
||||
get: (keys) => Promise.resolve(b.storage.local.get(keys)),
|
||||
set: (items) => Promise.resolve(b.storage.local.set(items)),
|
||||
remove: (keys) => Promise.resolve(b.storage.local.remove(keys)),
|
||||
},
|
||||
tabs: {
|
||||
create: (opts) => b.tabs.create(opts),
|
||||
remove: (tabId) => b.tabs.remove(tabId),
|
||||
create: (opts) => Promise.resolve(b.tabs.create(opts)),
|
||||
remove: (tabId) => Promise.resolve(b.tabs.remove(tabId)),
|
||||
onUpdated: b.tabs.onUpdated,
|
||||
onRemoved: b.tabs.onRemoved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Chrome adapter — kept for backwards compatibility with callers
|
||||
* that imported it before 1.9.0. Now just an alias for
|
||||
* `webExtensionAdapter`. Prefer `webExtensionAdapter` in new code.
|
||||
*/
|
||||
export function chromeAdapter(): BrowserAdapter {
|
||||
return webExtensionAdapter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Firefox adapter — same deal as `chromeAdapter`. Both are
|
||||
* aliases now that the polyfill gives us one identical surface.
|
||||
*/
|
||||
export function firefoxAdapter(): BrowserAdapter {
|
||||
return webExtensionAdapter();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ export const MSG_RESPONSE = 0x11;
|
||||
export const MSG_PING = 0xFE;
|
||||
export const MSG_PONG = 0xFF;
|
||||
|
||||
export const DEFAULT_ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
/**
|
||||
* Canonical Hanzo service-type per HIP-0069. Discovery is mDNS-only —
|
||||
* the legacy [9999..9995] port-probe was removed in 1.9.13 to surface
|
||||
* non-compliant deployments instead of papering over them.
|
||||
*/
|
||||
export const HANZO_SERVICE_TYPE = '_hanzo._tcp.local.';
|
||||
export const ZAP_RECONNECT_DELAY = 3000;
|
||||
export const ZAP_DISCOVERY_TIMEOUT = 2000;
|
||||
|
||||
@@ -50,11 +55,24 @@ export function decodeZapMessage(buf: ArrayBuffer): { type: number; payload: any
|
||||
|
||||
// ── Connection Management ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler invoked when a ZAP server pushes a request TO the extension.
|
||||
* Returns a result (or throws) and the framework converts that into a
|
||||
* MSG_RESPONSE frame back to the server.
|
||||
*
|
||||
* Keeping this at the manager level means the extension's existing
|
||||
* request-dispatchers (handleExtensionRequest, executeMethod) can be
|
||||
* mounted once and serve every connected MCP without duplication.
|
||||
*/
|
||||
export type ZapRequestHandler = (method: string, params: any) => Promise<any> | any;
|
||||
|
||||
export interface ZapManager {
|
||||
state: ZapState;
|
||||
connections: Map<string, WebSocket>;
|
||||
pendingRequests: Map<string, { resolve: Function; reject: Function }>;
|
||||
requestIdCounter: number;
|
||||
/** Optional inbound request handler (set via setZapRequestHandler). */
|
||||
requestHandler?: ZapRequestHandler;
|
||||
}
|
||||
|
||||
export function createZapManager(): ZapManager {
|
||||
@@ -70,6 +88,11 @@ export function createZapManager(): ZapManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Install (or replace) the inbound request handler. */
|
||||
export function setZapRequestHandler(mgr: ZapManager, handler: ZapRequestHandler | undefined): void {
|
||||
mgr.requestHandler = handler;
|
||||
}
|
||||
|
||||
/** Probe a ZAP server on given port */
|
||||
export function probeZapServer(
|
||||
port: number,
|
||||
@@ -149,6 +172,11 @@ export async function connectZap(
|
||||
tools: (info.tools || []).map((t: any) => t.name),
|
||||
});
|
||||
log(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
|
||||
// No application-level heartbeat: ZAP is a long-lived WS and the
|
||||
// OS already delivers FIN/RST on real disconnects, which fires
|
||||
// ws.onclose → our reconnect chain (added 1.9.7). The 1.9.9
|
||||
// PING/PONG was both unnecessary and buggy (it stacked
|
||||
// ws.onmessage wrappers on every tick).
|
||||
resolve(mcpId);
|
||||
break;
|
||||
}
|
||||
@@ -162,12 +190,43 @@ export async function connectZap(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MSG_REQUEST: {
|
||||
// Server-initiated RPC: a Python hanzo-mcp is asking the
|
||||
// extension to perform a browser action. Dispatch via the
|
||||
// installed handler and reply with MSG_RESPONSE.
|
||||
const { id: reqId, method: reqMethod, params: reqParams } = msg.payload || {};
|
||||
const respond = (payload: object) => {
|
||||
try {
|
||||
ws.send(encodeZapMessage(MSG_RESPONSE, payload));
|
||||
} catch (e) {
|
||||
// ws.send throws if the socket is closing; nothing we can do.
|
||||
}
|
||||
};
|
||||
const handler = mgr.requestHandler;
|
||||
if (!handler) {
|
||||
respond({ id: reqId, error: { code: -32601, message: `No request handler installed (method: ${reqMethod})` } });
|
||||
break;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => handler(reqMethod, reqParams || {}))
|
||||
.then((result) => respond({ id: reqId, result: result === undefined ? null : result }))
|
||||
.catch((e: any) => respond({ id: reqId, error: { code: -1, message: e?.message || String(e) } }));
|
||||
break;
|
||||
}
|
||||
case MSG_PING:
|
||||
ws.send(encodeZapMessage(MSG_PONG, {}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let scheduledRetry = false;
|
||||
const scheduleRetry = (reason: string) => {
|
||||
if (scheduledRetry) return;
|
||||
scheduledRetry = true;
|
||||
log(`[Hanzo/ZAP] ${reason} — reconnecting to ${url} in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
for (const [id, conn] of mgr.connections) {
|
||||
if (conn === ws) {
|
||||
@@ -177,12 +236,17 @@ export async function connectZap(
|
||||
}
|
||||
}
|
||||
mgr.state.connected = mgr.connections.size > 0;
|
||||
log(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
setTimeout(() => connectZap(url, mgr, browserName, version, log), ZAP_RECONNECT_DELAY);
|
||||
scheduleRetry('Disconnected');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(connTimer);
|
||||
// onerror fires when the bind window misses, the server is restarting,
|
||||
// or the connection is reset mid-handshake. Without scheduling a retry
|
||||
// here the entire reconnect chain dies after a single failed attempt
|
||||
// (1.9.6 and earlier) — fixed in 1.9.7 by chaining a retry the same
|
||||
// way onclose does.
|
||||
scheduleRetry('Connect error');
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
@@ -256,26 +320,118 @@ export function zapGetPrompt(mgr: ZapManager, mcpId: string, name: string, args?
|
||||
return zapRequest(mgr, mcpId, 'prompts/get', { name, arguments: args });
|
||||
}
|
||||
|
||||
/** Discover and connect to ZAP servers */
|
||||
/**
|
||||
* Ask the native messaging helper (ai.hanzo.zap_mdns) for a live mDNS browse
|
||||
* of `_hanzo._tcp.local.` services per HIP-0069. Returns ws:// URLs the
|
||||
* extension can connect to. Returns [] when the helper isn't installed or
|
||||
* the browse times out empty.
|
||||
*/
|
||||
async function discoverViaMdns(timeoutMs: number = 1500): Promise<string[]> {
|
||||
const api: any = (typeof browser !== 'undefined' ? browser : (chrome as any));
|
||||
const send = api?.runtime?.sendNativeMessage;
|
||||
if (typeof send !== 'function') {
|
||||
throw new Error(
|
||||
'[Hanzo/ZAP] runtime.sendNativeMessage unavailable — ai.hanzo.zap_mdns helper required for discovery',
|
||||
);
|
||||
}
|
||||
const resp: any = await new Promise((resolve, reject) => {
|
||||
// Two dialects:
|
||||
// - Firefox / promise-returning Chrome (MV3): sendNativeMessage(name, msg) → Promise
|
||||
// - Chrome MV2 callback API: sendNativeMessage(name, msg, callback)
|
||||
// Detect by whether the first call returns a thenable; only fall back to
|
||||
// the callback shape when it doesn't, so we never send the same browse
|
||||
// request twice.
|
||||
try {
|
||||
const result = send.call(api.runtime, 'ai.hanzo.zap_mdns', { op: 'browse', timeout_ms: timeoutMs }, (r: any) => {
|
||||
// Callback dialect: result of the call() above is undefined and the
|
||||
// helper response arrives here. Promise dialect ignores callbacks.
|
||||
if (r !== undefined) resolve(r);
|
||||
});
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(resolve).catch(reject);
|
||||
}
|
||||
// If result was undefined and the callback never fires, the discovery
|
||||
// loop's interval handles the retry — no need for a per-call timer.
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
if (!resp || !resp.ok || !Array.isArray(resp.services)) return [];
|
||||
return resp.services
|
||||
.map((s: any) => s.url)
|
||||
.filter((u: any): u is string => typeof u === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and connect to ZAP servers via mDNS only (HIP-0069). One probe.
|
||||
* Use ``startZapDiscoveryLoop`` for the always-on watchdog that keeps trying
|
||||
* until a connection sticks and re-runs whenever the connection list empties.
|
||||
* The native-messaging helper `ai.hanzo.zap_mdns` MUST be installed; install
|
||||
* instructions live at `~/work/zap/mdns/ext/native-messaging-host.json`.
|
||||
*/
|
||||
export async function discoverZapServers(
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
ports: number[] = DEFAULT_ZAP_PORTS,
|
||||
_ports: number[] | undefined = undefined, // unused — retained for ABI continuity
|
||||
log: (msg: string) => void = console.log,
|
||||
): Promise<void> {
|
||||
log('[Hanzo/ZAP] Discovering MCP servers...');
|
||||
const results = await Promise.all(ports.map(p => probeZapServer(p, mgr, browserName, version)));
|
||||
const available = results.filter(Boolean) as string[];
|
||||
|
||||
if (available.length === 0) {
|
||||
log('[Hanzo/ZAP] No servers found, retrying in 10s...');
|
||||
setTimeout(() => discoverZapServers(mgr, browserName, version, ports, log), 10000);
|
||||
log('[Hanzo/ZAP] Discovering MCP via mDNS (_hanzo._tcp.local.)...');
|
||||
let mdnsUrls: string[] = [];
|
||||
try {
|
||||
mdnsUrls = await discoverViaMdns(1500);
|
||||
} catch (e: any) {
|
||||
log(`[Hanzo/ZAP] mDNS browse failed: ${e?.message || e}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
|
||||
await Promise.all(available.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
if (mdnsUrls.length === 0) {
|
||||
log('[Hanzo/ZAP] No services advertising _hanzo._tcp.local.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip URLs we're already connected to so a watchdog re-tick is a no-op
|
||||
// when everything is already wired.
|
||||
const known = new Set<string>();
|
||||
for (const m of mgr.state.mcps) known.add(m.url);
|
||||
const fresh = mdnsUrls.filter(u => !known.has(u));
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
log(`[Hanzo/ZAP] mDNS found ${fresh.length} new server(s): ${fresh.join(', ')}`);
|
||||
await Promise.all(fresh.map(url => connectZap(url, mgr, browserName, version, log)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-on discovery watchdog. Ticks every ``intervalMs`` and re-runs
|
||||
* discovery whenever the manager has zero live connections (or a fresh server
|
||||
* has appeared on mDNS that we're not yet connected to). This is the canonical
|
||||
* entry point — replaces the fragile setTimeout-on-failure chain that died
|
||||
* the moment any code-path missed a re-arm. Returns the interval handle so
|
||||
* tests can stop it; in production the page lives for the extension lifetime.
|
||||
*/
|
||||
export function startZapDiscoveryLoop(
|
||||
mgr: ZapManager,
|
||||
browserName: string,
|
||||
version: string,
|
||||
intervalMs: number = 5000,
|
||||
log: (msg: string) => void = console.log,
|
||||
): { stop: () => void } {
|
||||
let inFlight = false;
|
||||
const tick = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
await discoverZapServers(mgr, browserName, version, undefined, log);
|
||||
} catch (e: any) {
|
||||
log(`[Hanzo/ZAP] discovery tick failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
// Run an immediate first probe; then settle into the watchdog cadence.
|
||||
void tick();
|
||||
const handle = setInterval(tick, intervalMs);
|
||||
return { stop: () => clearInterval(handle) };
|
||||
}
|
||||
|
||||
// ── Message Handler Helpers ─────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Hanzo AI Browser Extension Sidebar — aligned with @hanzo/ui dark theme */
|
||||
/* Hanzo AI Browser Extension Sidebar — Hanzo brand: strictly monochrome.
|
||||
See ~/work/hanzo/brand/brand.json — primaryColor #FFFFFF, no chromatic
|
||||
accents. Status states are conveyed by intensity, not hue. */
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
@@ -15,9 +17,11 @@
|
||||
--accent-dim: rgba(255,255,255,0.06);
|
||||
--accent-glow: rgba(255,255,255,0.03);
|
||||
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
/* Status: monochrome by hue, distinguished by opacity / weight.
|
||||
Brand is strict monochrome, so success/warn/error use luminance. */
|
||||
--success: #fafafa;
|
||||
--warning: #d4d4d4;
|
||||
--error: rgba(255,255,255,0.55);
|
||||
|
||||
--border: rgba(255,255,255,0.10);
|
||||
--border-strong: rgba(255,255,255,0.16);
|
||||
@@ -259,7 +263,7 @@ html {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Sign-in button — matches @hanzo/ui primary button on dark */
|
||||
/* Sign-in button — matches @hanzo/gui primary button on dark (monochrome) */
|
||||
.auth-signin-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
@@ -1046,16 +1050,18 @@ html {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Element-syntax tinting — kept monochrome via opacity, not hue. */
|
||||
.inspector-result .element-tag {
|
||||
color: #7cc4f8;
|
||||
color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inspector-result .element-id {
|
||||
color: #c792ea;
|
||||
color: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.inspector-result .element-class {
|
||||
color: #c3e88d;
|
||||
color: rgba(255,255,255,0.62);
|
||||
}
|
||||
|
||||
.inspector-result .element-size {
|
||||
@@ -1681,11 +1687,13 @@ select option {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar-blue { background: #3B82F6; }
|
||||
.bar-violet { background: #8B5CF6; }
|
||||
.bar-emerald { background: #10B981; }
|
||||
.bar-amber { background: #F59E0B; }
|
||||
.bar-red { background: #EF4444; }
|
||||
/* Usage bars: monochrome scale (brightest → dimmest) per @hanzo/brand.
|
||||
Class names kept for compatibility with existing markup. */
|
||||
.bar-blue { background: rgba(255,255,255,0.95); }
|
||||
.bar-violet { background: rgba(255,255,255,0.75); }
|
||||
.bar-emerald { background: rgba(255,255,255,0.55); }
|
||||
.bar-amber { background: rgba(255,255,255,0.40); }
|
||||
.bar-red { background: rgba(255,255,255,0.25); }
|
||||
|
||||
.usage-bar-group {
|
||||
margin-top: 8px;
|
||||
@@ -1761,9 +1769,9 @@ select option {
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-cloud { background: rgba(59,130,246,0.15); color: #60A5FA; }
|
||||
.badge-local { background: rgba(16,185,129,0.15); color: #34D399; }
|
||||
.badge-hub { background: rgba(139,92,246,0.15); color: #A78BFA; }
|
||||
.badge-cloud { background: rgba(255,255,255,0.10); color: rgba(255,255,255,0.92); }
|
||||
.badge-local { background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.78); }
|
||||
.badge-hub { background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.65); }
|
||||
|
||||
.model-hub-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Polyfill the WebExtension API on Chrome so storage calls return Promises
|
||||
// like Firefox / Safari natively do. No-op on those runtimes.
|
||||
import 'webextension-polyfill';
|
||||
|
||||
import {
|
||||
CHAT_BUDGETS,
|
||||
debugLog,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Multi-browser routing through the CDP bridge server.
|
||||
*
|
||||
* The bridge is the SINGLE entrypoint for hanzo-mcp / Playwright clients.
|
||||
* When two extensions register against the same bridge (e.g. Chrome AND
|
||||
* Firefox both running locally), the user must be able to route a command
|
||||
* to a specific browser via `{ ...command, browser: 'firefox' }`. This
|
||||
* suite asserts that resolution works deterministically.
|
||||
*
|
||||
* The bridge exposes `assignClientId`, `resolveClient`, and the public
|
||||
* `browser({ action, browser })` action namespace. We exercise those
|
||||
* directly with mocked WebSocket clients so we never hit a real port.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CDPBridgeServer } from '../src/cdp-bridge-server';
|
||||
|
||||
// jsdom's `WebSocket` doesn't behave like the `ws` server expects, so we
|
||||
// use the real ws lib but on a unique port per test to avoid contention.
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
let server: CDPBridgeServer;
|
||||
const PORTS_BASE = 39220; // unique band so CI doesn't collide with prod 9223
|
||||
let portCursor = PORTS_BASE;
|
||||
function nextPort(): number {
|
||||
return portCursor++;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Server created per-test with fresh port.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (server) {
|
||||
server.close();
|
||||
server = undefined as any;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Spin up a real CDPBridgeServer and connect a fake "extension" client
|
||||
* over WebSocket. The fake client immediately registers and then echoes
|
||||
* any command back as the result. Returns the connected client so the
|
||||
* test can drive responses.
|
||||
*/
|
||||
async function setupBridgeWithClient(
|
||||
browser: string,
|
||||
): Promise<{ port: number; client: WebSocket; opened: Promise<void> }> {
|
||||
const port = nextPort();
|
||||
server = new CDPBridgeServer(port);
|
||||
// Give the WS server one tick to start listening.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const client = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
const opened = new Promise<void>((resolve) => client.on('open', () => resolve()));
|
||||
await opened;
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser,
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
// One tick for register to land.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Echo back any command as a successful result so the test doesn't time out.
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, params: msg.params } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return { port, client, opened };
|
||||
}
|
||||
|
||||
describe('CDPBridgeServer.assignClientId', () => {
|
||||
it('first client of a browser type gets its bare name', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
// Internal: the server stores by browser name. Verify via list_browsers.
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(1);
|
||||
expect(result.browsers[0].id).toBe('chrome');
|
||||
expect(result.browsers[0].browser).toBe('chrome');
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('second client of same browser type gets a -2 suffix', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
// Open a second connection registered as `chrome`.
|
||||
const port = (server as any).httpPort;
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.browsers).toHaveLength(2);
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'chrome-2']);
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('different browser types each get their bare names', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
const ids = result.browsers.map((b: any) => b.id).sort();
|
||||
expect(ids).toEqual(['chrome', 'firefox']);
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.browser routing', () => {
|
||||
it('routes by browser=firefox to the firefox client', async () => {
|
||||
const { client: chromeClient } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
const ffClient = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => ffClient.on('open', () => resolve()));
|
||||
ffClient.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'firefox',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ffClient.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
ffClient.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, browser: 'firefox' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'firefox',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
// Result should come from firefox echoer, not chrome's.
|
||||
expect(result.__echoed.browser).toBe('firefox');
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
|
||||
chromeClient.close();
|
||||
ffClient.close();
|
||||
});
|
||||
|
||||
it('routes by chrome-2 unique ID to the second chrome client', async () => {
|
||||
const { client: c1 } = await setupBridgeWithClient('chrome');
|
||||
const port = (server as any).httpPort;
|
||||
// c1 already echoes generically; install a label-specific echoer on c2.
|
||||
const c2 = new WebSocket(`ws://localhost:${port}/cdp`);
|
||||
await new Promise<void>((resolve) => c2.on('open', () => resolve()));
|
||||
c2.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
browser: 'chrome',
|
||||
capabilities: ['navigate'],
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
c2.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method) {
|
||||
c2.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { __echoed: { method: msg.method, label: 'chrome-2' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
browser: 'chrome-2',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.label).toBe('chrome-2');
|
||||
|
||||
c1.close();
|
||||
c2.close();
|
||||
});
|
||||
|
||||
it('throws on unknown browser target', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
await expect(
|
||||
server.browser({ action: 'navigate', browser: 'safari', url: 'x' }),
|
||||
).rejects.toThrow(/Browser "safari" not connected/);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('list_browsers returns count and details', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
const result = await server.browser({ action: 'list_browsers' });
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.browsers).toEqual([
|
||||
{ id: 'chrome', browser: 'chrome', capabilities: ['navigate'] },
|
||||
]);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('default routing (no browser specified) picks the first registered client', async () => {
|
||||
const { client } = await setupBridgeWithClient('firefox');
|
||||
const result = await server.browser({
|
||||
action: 'navigate',
|
||||
url: 'https://example.com',
|
||||
});
|
||||
expect(result.__echoed.method).toBe('Page.navigate');
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CDPBridgeServer.evaluate result unwrapping', () => {
|
||||
it('passes awaitPromise: true to Runtime.evaluate', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
let receivedParams: any = null;
|
||||
// Replace generic echoer with a custom one that captures params.
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
receivedParams = msg.params;
|
||||
client.send(JSON.stringify({
|
||||
id: msg.id,
|
||||
result: { result: { value: 42, type: 'number' } },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({
|
||||
action: 'evaluate',
|
||||
code: '1 + 1',
|
||||
});
|
||||
|
||||
expect(receivedParams.awaitPromise).toBe(true);
|
||||
expect(receivedParams.returnByValue).toBe(true);
|
||||
// unwrapEvaluateResult unwraps {result: {value: 42}} to 42.
|
||||
expect(result.result).toBe(42);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('unwraps an empty {} (Promise drop) to undefined', async () => {
|
||||
const { client } = await setupBridgeWithClient('chrome');
|
||||
client.removeAllListeners('message');
|
||||
client.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.id !== undefined && msg.method === 'Runtime.evaluate') {
|
||||
client.send(JSON.stringify({ id: msg.id, result: {} }));
|
||||
}
|
||||
});
|
||||
|
||||
const result = await server.browser({ action: 'evaluate', code: 'foo()' });
|
||||
expect(result.result).toBeUndefined();
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Cross-browser parity contracts.
|
||||
*
|
||||
* These tests assert that the things which were broken in 1.8.x stay
|
||||
* fixed in 1.9.0 and beyond. They probe BOTH the Chrome (background.ts +
|
||||
* cdp-bridge.ts) and Firefox (background-firefox.ts) source files for
|
||||
* the structural patterns that distinguish a working build from a
|
||||
* regressed one.
|
||||
*
|
||||
* Why string-search instead of behavior tests? The actual page execution
|
||||
* path requires a real browser. These contracts run as `vitest run` in
|
||||
* CI without a browser, so they're a fast lint-class signal that no
|
||||
* future PR silently re-introduces the bug.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function read(p: string): string {
|
||||
return fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
|
||||
}
|
||||
|
||||
const chromeBg = read('src/background.ts');
|
||||
const firefoxBg = read('src/background-firefox.ts');
|
||||
const cdpBridge = read('src/cdp-bridge.ts');
|
||||
const cdpBridgeServer = read('src/cdp-bridge-server.ts');
|
||||
const browserControl = read('src/browser-control.ts');
|
||||
const popupTs = read('src/popup.ts');
|
||||
const contentScript = read('src/content-script.ts');
|
||||
const sharedAuth = read('src/shared/auth.ts');
|
||||
const sharedConfig = read('src/shared/config.ts');
|
||||
const manifestChrome = read('src/manifest.json');
|
||||
const manifestFirefox = read('src/manifest-firefox.json');
|
||||
|
||||
describe('IAM URL conventions', () => {
|
||||
it('IAM_API_PATH is /v1/iam (NOT /api/, NOT bare /oauth)', () => {
|
||||
expect(sharedConfig).toContain("IAM_API_PATH = '/v1/iam'");
|
||||
});
|
||||
|
||||
it('shared auth uses /login/oauth/access_token (not bare /oauth/token)', () => {
|
||||
expect(sharedAuth).toContain('/login/oauth/access_token');
|
||||
// The bare /oauth/token path 404'd before 1.8.7. We only care about
|
||||
// the URL appearing in non-comment code lines (the doc-comment
|
||||
// intentionally references it to explain the bug). Strip comments
|
||||
// and re-scan.
|
||||
const stripped = sharedAuth
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
.replace(/(^|\n)\s*\/\/.*?(?=\n|$)/g, '$1'); // line comments
|
||||
expect(stripped).not.toMatch(/['"`]\/oauth\/token['"`]/);
|
||||
});
|
||||
|
||||
it('token exchange uses application/x-www-form-urlencoded (RFC 6749)', () => {
|
||||
// application/json was the bug pre-1.8.7. Verify the body shape.
|
||||
const tokenBlock = sharedAuth.slice(
|
||||
sharedAuth.indexOf('login/oauth/access_token'),
|
||||
sharedAuth.indexOf('login/oauth/access_token') + 1500,
|
||||
);
|
||||
expect(tokenBlock).toContain('application/x-www-form-urlencoded');
|
||||
expect(tokenBlock).toContain('URLSearchParams');
|
||||
});
|
||||
|
||||
it('Chrome background uses ${IAM_V1} for IAM URLs (no /api/ prefix)', () => {
|
||||
// Find the IAM_V1 declaration and assert it.
|
||||
expect(chromeBg).toContain('const IAM_V1 = `${IAM_API_URL}${IAM_API_PATH}`');
|
||||
// No raw /api/login or /api/get-account anywhere in chrome bg.
|
||||
expect(chromeBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
|
||||
it('Firefox background uses ${IAM_V1} for IAM URLs', () => {
|
||||
expect(firefoxBg).toContain('const IAM_V1 = `${IAM_API}${IAM_API_PATH}`');
|
||||
expect(firefoxBg).not.toMatch(/iam\.hanzo\.ai\/api\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId centralization', () => {
|
||||
it('cdp-bridge.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(cdpBridge).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts no longer defines its own parseTabId', () => {
|
||||
// The class private parseTabId existed as a duplicate before 1.9.0.
|
||||
expect(cdpBridge).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts imports parseTabId from shared/tab-id', () => {
|
||||
expect(firefoxBg).toMatch(/import\s+\{[^}]*parseTabId[^}]*\}\s+from\s+['"]\.\/shared\/tab-id\.js['"]/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts no longer defines its own parseTabId', () => {
|
||||
expect(firefoxBg).not.toMatch(/private\s+parseTabId\s*\(/);
|
||||
});
|
||||
|
||||
it('background-firefox.ts uses parseTabId in Target.closeTarget (not parseInt fallback)', () => {
|
||||
// The pre-1.9.0 code did `parseInt((params.targetId).replace('tab-',''),10)`.
|
||||
// That was lossy on non-numeric strings and missed the anchored regex
|
||||
// protection of parseTabId.
|
||||
expect(firefoxBg).not.toMatch(/parseInt\(\(params\.targetId\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('awaitPromise propagation', () => {
|
||||
it('cdp-bridge.ts handleBridgeMessage Runtime.evaluate sets awaitPromise: true', () => {
|
||||
// Find the handleBridgeMessage Runtime.evaluate path.
|
||||
const evalSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf("case 'Runtime.evaluate':"),
|
||||
cdpBridge.indexOf("case 'DOM.getDocument':"),
|
||||
);
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
});
|
||||
|
||||
it('cdp-bridge.ts evaluate() helper uses awaitPromise: true', () => {
|
||||
// The local `evaluate(tabId, expression)` method was the one missed
|
||||
// before 1.9.0 — the bridge handler had it but this method didn't.
|
||||
const helperSection = cdpBridge.slice(
|
||||
cdpBridge.indexOf('async evaluate(tabId: number'),
|
||||
cdpBridge.indexOf('async evaluate(tabId: number') + 600,
|
||||
);
|
||||
expect(helperSection).toContain('awaitPromise: true');
|
||||
expect(helperSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
|
||||
it('cdp-bridge-server.ts evaluate action sets awaitPromise: true', () => {
|
||||
const evalSection = cdpBridgeServer.slice(
|
||||
cdpBridgeServer.indexOf("case 'evaluate':"),
|
||||
cdpBridgeServer.indexOf("case 'evaluate':") + 800,
|
||||
);
|
||||
expect(evalSection).toContain('awaitPromise: true');
|
||||
expect(evalSection).toContain('unwrapEvaluateResult');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeScript MV3 contract', () => {
|
||||
it('background-firefox.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The Firefox path has used the MV3 scripting API since 1.8.6.
|
||||
expect(firefoxBg).toMatch(/scripting\.executeScript/);
|
||||
expect(firefoxBg).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
});
|
||||
|
||||
it('browser-control.ts uses scripting.executeScript with world: MAIN', () => {
|
||||
// The big regression in 1.8.x: browser-control still used `func: new Function(...)`
|
||||
// without the MAIN world hint, so async results were silently lost. 1.9.0
|
||||
// mirrors the Firefox pattern.
|
||||
expect(browserControl).toMatch(/scripting\.executeScript/);
|
||||
expect(browserControl).toMatch(/world:\s*['"]MAIN['"]/);
|
||||
expect(browserControl).toContain('Function(codeStr)()');
|
||||
});
|
||||
|
||||
it('browser-control.ts handles the empty-{} promise drop', () => {
|
||||
// Detect the looksEmpty / Promise.resolve fallback pattern.
|
||||
expect(browserControl).toMatch(/Promise\.resolve\(/);
|
||||
expect(browserControl).toMatch(/__hanzo_error/);
|
||||
});
|
||||
|
||||
it('shipping code does NOT call the removed MV2 tabs.executeScript directly', () => {
|
||||
// Allowed: reference inside the MV2 fallback branch (gated by `if`).
|
||||
// Forbidden: as the primary call path. Probe that any such reference
|
||||
// is wrapped behind a "legacy" / fallback comment.
|
||||
const usages = [
|
||||
...chromeBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...firefoxBg.matchAll(/\btabs\.executeScript\b/g),
|
||||
...browserControl.matchAll(/\btabs\.executeScript\b/g),
|
||||
];
|
||||
// Each remaining match must be in a fallback context (not the primary
|
||||
// path). Heuristic: surrounding 200 chars include the words 'fallback'
|
||||
// or 'legacy' or 'MV2'.
|
||||
for (const m of usages) {
|
||||
const idx = m.index!;
|
||||
const file = m.input!;
|
||||
const ctx = file.slice(Math.max(0, idx - 200), idx + 200);
|
||||
expect(ctx).toMatch(/fallback|legacy|MV2/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar / chat-panel surface', () => {
|
||||
it('Chrome manifest does NOT declare side_panel', () => {
|
||||
const m = JSON.parse(manifestChrome);
|
||||
expect(m.side_panel).toBeUndefined();
|
||||
expect(m.permissions).not.toContain('sidePanel');
|
||||
});
|
||||
|
||||
it('Firefox manifest sidebar_action panel points at sidebar.html (Firefox-only fallback)', () => {
|
||||
// Firefox has no chrome.sidePanel API, so a manifest sidebar_action
|
||||
// is the only native sidebar entrypoint. Chrome MV3 must NOT have
|
||||
// either side_panel or sidePanel permission — the Chrome chat
|
||||
// surface is the page-overlay content-script.
|
||||
const m = JSON.parse(manifestFirefox);
|
||||
if (m.sidebar_action) {
|
||||
expect(m.sidebar_action.default_panel).toBe('sidebar.html');
|
||||
}
|
||||
});
|
||||
|
||||
it('background.ts does NOT call chrome.sidePanel.setOptions', () => {
|
||||
expect(chromeBg).not.toMatch(/chrome\.sidePanel\.setOptions/);
|
||||
});
|
||||
|
||||
it('content-script chat overlay anchors to the right edge (not left)', () => {
|
||||
// The right-anchor is the user's hard requirement. The chat panel
|
||||
// is the #hanzo-page-overlay-root element. It anchors with
|
||||
// `right: 0` (flush) by default and flips to `left: 0` only when
|
||||
// [data-side="left"] is set explicitly.
|
||||
const overlayBlock = contentScript.slice(
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {'),
|
||||
contentScript.indexOf('#hanzo-page-overlay-root {') + 400,
|
||||
);
|
||||
expect(overlayBlock).toMatch(/right:\s*0/);
|
||||
// The default (no data-side) block must NOT have a `left:` declaration.
|
||||
expect(overlayBlock).not.toMatch(/^\s*left:\s/m);
|
||||
});
|
||||
|
||||
it('popup.ts routes "Open Chat Panel" via page.overlay.show (NOT chrome.sidePanel.open)', () => {
|
||||
expect(popupTs).toContain("action: 'page.overlay.show'");
|
||||
// Double-check no leftover sidepanel call paths.
|
||||
expect(popupTs).not.toMatch(/chrome\.sidePanel\.(open|toggle)/);
|
||||
expect(popupTs).not.toMatch(/sidebarAction\.(open|toggle)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inspect-modifier shortcut', () => {
|
||||
it('default is Ctrl alone (NOT Alt — Alt collides with macOS)', () => {
|
||||
const shortcut = read('src/shared/shortcut.ts');
|
||||
const block = shortcut.slice(
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT'),
|
||||
shortcut.indexOf('DEFAULT_INSPECT_SHORTCUT') + 400,
|
||||
);
|
||||
expect(block).toMatch(/ctrl:\s*true/);
|
||||
expect(block).toMatch(/alt:\s*false/);
|
||||
});
|
||||
|
||||
it('content-script reloads shortcut on storage change (no page reload needed)', () => {
|
||||
expect(contentScript).toMatch(/storage\.onChanged\.addListener/);
|
||||
expect(contentScript).toMatch(/STORAGE_KEY_INSPECT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth client ID convention', () => {
|
||||
it('client ID is `app-hanzo` (Hanzo IAM <org>-<app> convention)', () => {
|
||||
expect(sharedConfig).toContain("DEFAULT_CLIENT_ID = 'app-hanzo'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('webextension-polyfill integration', () => {
|
||||
it('polyfill is the FIRST import in every entrypoint', () => {
|
||||
// Order matters: the polyfill must define globalThis.browser before
|
||||
// any other import reads it. ESBuild preserves import order during
|
||||
// bundling, so the source order is what ships.
|
||||
const entrypoints = [
|
||||
['background.ts', chromeBg],
|
||||
['background-firefox.ts', firefoxBg],
|
||||
['content-script.ts', contentScript],
|
||||
['popup.ts', popupTs],
|
||||
];
|
||||
for (const [name, src] of entrypoints) {
|
||||
const polyfillIdx = src.indexOf("import 'webextension-polyfill'");
|
||||
expect(polyfillIdx, `${name} missing polyfill import`).toBeGreaterThanOrEqual(0);
|
||||
// No other `import` line should come before it.
|
||||
const before = src.slice(0, polyfillIdx);
|
||||
expect(before, `${name} has imports before the polyfill`)
|
||||
.not.toMatch(/^\s*import\s/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('shared/auth.ts exports a unified webExtensionAdapter', () => {
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(/);
|
||||
});
|
||||
|
||||
it('chromeAdapter and firefoxAdapter still exist as compatibility aliases', () => {
|
||||
// We cannot break callers that imported them.
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter\s*\(/);
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bridge server hygiene', () => {
|
||||
it('no duplicate switch case for go_back / go_forward / etc.', () => {
|
||||
// Count occurrences of the dead-duplicate cases. Each name MUST appear
|
||||
// at most once as `case 'X':`.
|
||||
const tokens = [
|
||||
'go_back', 'go_forward', 'navigate_back', 'navigate_forward',
|
||||
'get_url', 'get_title', 'get_tab_info', 'get_history',
|
||||
'wait_for_navigation', 'create_tab',
|
||||
// close_tab is allowed twice because the mid-section uses it as an
|
||||
// alias on the navigation block AND the tabs block. Same for tab_info.
|
||||
];
|
||||
for (const t of tokens) {
|
||||
const re = new RegExp(`case ['"]${t}['"]:`, 'g');
|
||||
const count = (cdpBridgeServer.match(re) || []).length;
|
||||
expect(count, `case '${t}' appears ${count} times in cdp-bridge-server.ts`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Firefox register identity', () => {
|
||||
it('Firefox sends `browser: BROWSER_NAME` so multi-browser routing works', () => {
|
||||
// Pre-1.9.0 the register payload omitted `browser`, so the bridge
|
||||
// server stored the client as 'unknown' and the user couldn't route
|
||||
// commands by browser name. Verify the field is present.
|
||||
const registerBlock = firefoxBg.slice(
|
||||
firefoxBg.indexOf('private register():'),
|
||||
firefoxBg.indexOf('private register():') + 1500,
|
||||
);
|
||||
expect(registerBlock).toMatch(/browser:\s*BROWSER_NAME/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* executeScript pattern coverage.
|
||||
*
|
||||
* The pre-1.9.0 bug was: `chrome.scripting.executeScript({ func: new Function(...) })`
|
||||
* silently lost async results because:
|
||||
* - Default world is ISOLATED, which can't see the page's globals
|
||||
* (window.fetch in some sandboxes, framework instances, etc).
|
||||
* - The MV3 API doesn't await Promises returned by `func`.
|
||||
* - When the user's IIFE returned `undefined`, that JSON-encoded as `{}`
|
||||
* and the caller couldn't tell success from failure.
|
||||
*
|
||||
* The 1.9.0 fix mirrors the Firefox pattern:
|
||||
* - `world: 'MAIN'` so the page's own globals are visible.
|
||||
* - A `Function(codeStr)()` trampoline so dynamic code strings still work.
|
||||
* - Empty-{} detection that re-runs wrapped in `Promise.resolve(...)` to
|
||||
* capture the resolved value.
|
||||
*
|
||||
* These tests exercise the helpers in isolation. End-to-end coverage
|
||||
* lives in the e2e specs (Playwright with extension loaded).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unwrapEvaluateResult, parseTabId, tabTarget } from '../src/shared/tab-id.js';
|
||||
|
||||
describe('Empty-{} Promise drop detection (the 1.8.x regression)', () => {
|
||||
it('unwrapEvaluateResult({}) returns undefined (sentinel for "Promise was dropped")', () => {
|
||||
expect(unwrapEvaluateResult({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-empty object that has none of the known keys is preserved', () => {
|
||||
const x = { foo: 1 };
|
||||
expect(unwrapEvaluateResult(x)).toEqual(x);
|
||||
});
|
||||
|
||||
it('arrays are preserved (NOT collapsed to undefined)', () => {
|
||||
expect(unwrapEvaluateResult([])).toEqual([]);
|
||||
expect(unwrapEvaluateResult([1, 2])).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTabId security and parity', () => {
|
||||
it('rejects URL-embedded tab-N (security: prevents tabId injection)', () => {
|
||||
expect(parseTabId('https://x.com/?tab-123')).toBeNull();
|
||||
expect(parseTabId('javascript:alert(tab-1)')).toBeNull();
|
||||
expect(parseTabId('data:tab-1,foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts the bare wire formats Target.getTargets returns', () => {
|
||||
expect(parseTabId('tab-1')).toBe(1);
|
||||
expect(parseTabId('tab-1888868904')).toBe(1888868904);
|
||||
expect(parseTabId('1888868904')).toBe(1888868904);
|
||||
});
|
||||
|
||||
it('returns null on every invalid input (no exceptions, no NaN)', () => {
|
||||
expect(parseTabId(undefined)).toBeNull();
|
||||
expect(parseTabId(null)).toBeNull();
|
||||
expect(parseTabId('')).toBeNull();
|
||||
expect(parseTabId('NaN')).toBeNull();
|
||||
expect(parseTabId('Infinity')).toBeNull();
|
||||
expect(parseTabId({ tabId: 1 })).toBeNull();
|
||||
expect(parseTabId(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('the 1.8.4 anchored-regex hardening is in place', () => {
|
||||
// `1.5e10abc` and `123abc` were the cases the 1.8.4 hardening fixed.
|
||||
expect(parseTabId('1.5e10abc')).toBeNull();
|
||||
expect(parseTabId('123abc')).toBeNull();
|
||||
expect(parseTabId('abc123')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabTarget composes cleanly', () => {
|
||||
it('empty source returns empty (or extra-only) object', () => {
|
||||
expect(tabTarget(undefined)).toEqual({});
|
||||
expect(tabTarget(undefined, { url: 'x' })).toEqual({ url: 'x' });
|
||||
});
|
||||
|
||||
it('tabId wins over targetId', () => {
|
||||
expect(tabTarget({ tabId: 5, targetId: 'tab-9' })).toEqual({ tabId: 5 });
|
||||
});
|
||||
|
||||
it('targetId is fallback when tabId absent', () => {
|
||||
expect(tabTarget({ targetId: 'tab-9' })).toEqual({ tabId: 'tab-9' });
|
||||
});
|
||||
|
||||
it('null fields are dropped (so they do not collide with extras)', () => {
|
||||
expect(tabTarget({ tabId: null, targetId: null } as any)).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves tabIndex when only that is provided', () => {
|
||||
expect(tabTarget({ tabIndex: 3 })).toEqual({ tabIndex: 3 });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createZapManager,
|
||||
hasZapTool,
|
||||
handleZapMessage,
|
||||
setZapRequestHandler,
|
||||
zapCallTool,
|
||||
zapListResources,
|
||||
zapReadResource,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
MSG_RESPONSE,
|
||||
MSG_PING,
|
||||
MSG_PONG,
|
||||
DEFAULT_ZAP_PORTS,
|
||||
HANZO_SERVICE_TYPE,
|
||||
ZAP_RECONNECT_DELAY,
|
||||
ZAP_DISCOVERY_TIMEOUT,
|
||||
} from '../src/shared/zap';
|
||||
@@ -40,8 +41,11 @@ describe('Protocol constants', () => {
|
||||
expect(MSG_PONG).toBe(0xFF);
|
||||
});
|
||||
|
||||
it('DEFAULT_ZAP_PORTS is correct', () => {
|
||||
expect(DEFAULT_ZAP_PORTS).toEqual([9999, 9998, 9997, 9996, 9995]);
|
||||
it('HANZO_SERVICE_TYPE is _hanzo._tcp.local. (HIP-0069 mDNS-only)', () => {
|
||||
// Replaces the legacy DEFAULT_ZAP_PORTS port-probe list. Discovery is
|
||||
// now exclusively mDNS — the OS-assigned ZAP port is advertised by
|
||||
// hanzo-zap-mdns and consumed via the extension's mDNS resolver.
|
||||
expect(HANZO_SERVICE_TYPE).toBe('_hanzo._tcp.local.');
|
||||
});
|
||||
|
||||
it('timing constants are defined', () => {
|
||||
@@ -286,3 +290,35 @@ describe('handleZapMessage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setZapRequestHandler — closes the loop for ZAP-server-initiated RPC
|
||||
// (the 2-process architecture where Python hanzo-mcps push browser
|
||||
// actions TO the extension over the same socket).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setZapRequestHandler', () => {
|
||||
it('attaches the handler onto the manager', () => {
|
||||
const mgr = createZapManager();
|
||||
expect(mgr.requestHandler).toBeUndefined();
|
||||
const handler = async () => ({ ok: true });
|
||||
setZapRequestHandler(mgr, handler);
|
||||
expect(mgr.requestHandler).toBe(handler);
|
||||
});
|
||||
|
||||
it('replaces an existing handler', () => {
|
||||
const mgr = createZapManager();
|
||||
const a = async () => 'a';
|
||||
const b = async () => 'b';
|
||||
setZapRequestHandler(mgr, a);
|
||||
setZapRequestHandler(mgr, b);
|
||||
expect(mgr.requestHandler).toBe(b);
|
||||
});
|
||||
|
||||
it('clears the handler when undefined', () => {
|
||||
const mgr = createZapManager();
|
||||
setZapRequestHandler(mgr, async () => null);
|
||||
setZapRequestHandler(mgr, undefined);
|
||||
expect(mgr.requestHandler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* webextension-polyfill integration.
|
||||
*
|
||||
* The 1.9.0 unification: every entrypoint imports `webextension-polyfill`
|
||||
* as its first import. On Chrome/Edge that defines `globalThis.browser`
|
||||
* as the Promise-returning WebExtension API; on Firefox/Safari it's a
|
||||
* no-op because `browser.*` is already native.
|
||||
*
|
||||
* The unified `webExtensionAdapter()` factory in `shared/auth.ts` then
|
||||
* picks up that global so we have ONE adapter codepath instead of two
|
||||
* (chromeAdapter / firefoxAdapter) with subtly different behaviours.
|
||||
*
|
||||
* This suite verifies the contract structurally (the polyfill is in
|
||||
* deps, the entrypoints import it, the adapter exists) — runtime
|
||||
* verification is in the e2e specs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'),
|
||||
);
|
||||
|
||||
describe('webextension-polyfill is a runtime dependency', () => {
|
||||
it('listed in dependencies (not devDependencies)', () => {
|
||||
expect(pkg.dependencies['webextension-polyfill']).toBeDefined();
|
||||
});
|
||||
|
||||
it('pinned to a known version (no caret) so MV3 behavior is reproducible', () => {
|
||||
// Caret ranges across major versions of the polyfill have historically
|
||||
// changed return-value shapes. Pin to a known version.
|
||||
const v = pkg.dependencies['webextension-polyfill'];
|
||||
expect(v).not.toMatch(/^\^/);
|
||||
expect(v).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared/auth.ts unified adapter', () => {
|
||||
const sharedAuth = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src/shared/auth.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('exports webExtensionAdapter with storage.get returning a Promise', () => {
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+webExtensionAdapter\s*\(\s*\)\s*:\s*BrowserAdapter/);
|
||||
// Verify the adapter wraps with Promise.resolve so the surface is
|
||||
// identical regardless of underlying API style.
|
||||
expect(sharedAuth).toMatch(/Promise\.resolve\(b\.storage\.local\.get/);
|
||||
});
|
||||
|
||||
it('falls back to globalThis.chrome if globalThis.browser is missing', () => {
|
||||
// The legacy fallback handles tests / non-extension contexts where
|
||||
// neither has been set up. It uses ?? so an empty browser object
|
||||
// doesn't shadow chrome.
|
||||
expect(sharedAuth).toMatch(/globalThis as any\)\.browser\s*\?\?\s*\(globalThis as any\)\.chrome/);
|
||||
});
|
||||
|
||||
it('chromeAdapter and firefoxAdapter are aliases for back-compat', () => {
|
||||
// Existing callers from before 1.9.0 must continue to work.
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+chromeAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
|
||||
expect(sharedAuth).toMatch(/export\s+function\s+firefoxAdapter[\s\S]*?return\s+webExtensionAdapter\(\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('build pipeline ships the polyfill', () => {
|
||||
it('chrome bundle includes webextension-polyfill code', () => {
|
||||
const distChrome = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'dist/browser-extension/chrome/background.js',
|
||||
);
|
||||
if (!fs.existsSync(distChrome)) {
|
||||
// Build hasn't run yet — skip. CI runs build before tests.
|
||||
return;
|
||||
}
|
||||
const bg = fs.readFileSync(distChrome, 'utf8');
|
||||
// The polyfill module sets `globalThis.browser`. We grep for its
|
||||
// distinctive marker so we know it actually ended up in the bundle.
|
||||
expect(bg).toMatch(/webextension-polyfill|browserPolyfill|chromeApiAvailable|globalThis\.browser/);
|
||||
});
|
||||
|
||||
it('firefox bundle does not break if the polyfill is a no-op', () => {
|
||||
const distFirefox = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'dist/browser-extension/firefox/background.js',
|
||||
);
|
||||
if (!fs.existsSync(distFirefox)) return;
|
||||
const bg = fs.readFileSync(distFirefox, 'utf8');
|
||||
// The Firefox bundle should still be valid JS regardless of whether
|
||||
// the polyfill no-ops or wraps. Probe for any obvious syntax failure.
|
||||
expect(bg.length).toBeGreaterThan(1000);
|
||||
// Must NOT contain leftover NodeJS-only syntax (require()) since the
|
||||
// bundle target is browser.
|
||||
expect(bg).not.toMatch(/^const\s+\w+\s*=\s*require\(/m);
|
||||
});
|
||||
});
|
||||
@@ -51,12 +51,16 @@ function zapDecode(data: Uint8Array): { type: number; payload: any } | null {
|
||||
|
||||
// ── Protocol Constants ────────────────────────────────────────────────
|
||||
|
||||
const MSG_HANDSHAKE = 0x01; // Init
|
||||
const MSG_HANDSHAKE_OK = 0x02; // InitAck
|
||||
const MSG_REQUEST = 0x10; // Push
|
||||
const MSG_RESPONSE = 0x12; // Resolve
|
||||
const MSG_PING = 0xf0;
|
||||
const MSG_PONG = 0xf1;
|
||||
// Wire constants must match @hanzo/browser-extension/src/shared/zap.ts.
|
||||
// Earlier drafts used 0x12/0xf0/0xf1 — those were wrong and silently broke
|
||||
// extension <-> mcp sessions. Canonical values live in shared/zap.ts and
|
||||
// are pinned by tests.
|
||||
const MSG_HANDSHAKE = 0x01;
|
||||
const MSG_HANDSHAKE_OK = 0x02;
|
||||
const MSG_REQUEST = 0x10;
|
||||
const MSG_RESPONSE = 0x11;
|
||||
const MSG_PING = 0xFE;
|
||||
const MSG_PONG = 0xFF;
|
||||
|
||||
const ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
const SERVER_ID = `mcp-${Date.now().toString(36)}`;
|
||||
|
||||
Generated
+16
-1
@@ -129,6 +129,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
webextension-polyfill:
|
||||
specifier: 0.12.0
|
||||
version: 0.12.0
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.18.3
|
||||
@@ -2602,6 +2605,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vitest/coverage-v8@0.34.6':
|
||||
resolution: {integrity: sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==}
|
||||
@@ -3967,11 +3971,12 @@ packages:
|
||||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global@4.4.0:
|
||||
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
||||
@@ -5401,11 +5406,13 @@ packages:
|
||||
prebuild-install@5.3.6:
|
||||
resolution: {integrity: sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==}
|
||||
engines: {node: '>=6'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -5980,6 +5987,7 @@ packages:
|
||||
tar@7.4.3:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
terser@5.43.1:
|
||||
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
|
||||
@@ -6300,10 +6308,12 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
@@ -6435,6 +6445,9 @@ packages:
|
||||
web-vitals@4.2.4:
|
||||
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
||||
|
||||
webextension-polyfill@0.12.0:
|
||||
resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -13524,6 +13537,8 @@ snapshots:
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
webextension-polyfill@0.12.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user