Compare commits

...

4 Commits

Author SHA1 Message Date
jackwener 35ff2b65fb fix: context isolation, initial sync, cookie batching, camoufox stop
Critical fixes:
- CamoufoxPool: shared browser/context singleton — LiveSyncService and
  CamoufoxBridge now share the same context, so real-time synced cookies
  are visible to adapter pages
- CamoufoxBridge uses pool instead of creating isolated contexts
- LiveSyncService: initial full export-state from Chrome before watching
- Cookie debounce batching: 50ms window with deduplication
- camoufox stop command: finds and kills server process
- Python launcher: Windows-compatible (threading.Event vs signal.pause)
2026-03-22 21:08:49 +08:00
jackwener 61b7a804f3 feat: real-time sync, P0 bug fixes, camoufox improvements
Bug fixes:
- CamoufoxPage.evaluate() now uses wrapForEval() for consistency
- Cookie import order: addCookies BEFORE navigate
- camoufox start: use Python launcher for reliable WS path parsing
- --no-headless flag (was broken with default=true)

Real-time sync (daemon architecture):
- Extension: chrome.cookies.onChanged → SyncEvent → daemon
- Extension: watch-state/unwatch-state commands, domain filtering
- Daemon: /sync WebSocket path for sync subscribers
- LiveSyncService: subscribes to daemon, applies events to camoufox
- CLI: opencli browser watch --domain github.com

Improvements:
- scripts/camoufox_server.py: reliable launcher via launch_server()
- protocol.ts: SyncEvent type for real-time state changes
- BROWSER-STATE-SYNC.md: comprehensive implementation docs
2026-03-22 14:36:31 +08:00
jackwener f97e863e7a feat: CamoufoxBridge, CLI commands, playwright-core integration
- CamoufoxPage: full IPage implementation via Juggler protocol
  - Reuses all DOM snapshot/helper JS from shared modules
  - exportState/importState natively via Playwright context API
- CamoufoxBridge: IBrowserFactory connecting to camoufox server
- runtime.ts: auto-select CamoufoxBridge via OPENCLI_CAMOUFOX_WS
- CLI commands:
  - opencli browser export-state/import-state/sync
  - opencli camoufox setup/start/status
- Added playwright-core dependency (~3MB, no browser binaries)
2026-03-22 14:16:21 +08:00
jackwener 7d37ab3782 feat: browser state export/import with IndexedDB support
- Extension: add export-state/import-state handlers
  - Export: Cookie + localStorage + sessionStorage + IndexedDB
  - Import: reverse (navigate, set cookies, inject storage)
  - IndexedDB: enumerate all databases/objectStores/records
- CLI types: BrowserState, IndexedDBSnapshot interfaces
- IPage: add exportState() and importState() methods
- Page: implement via daemon commands
- CDPPage: stub methods (not supported in CDP mode)
- manifest.json: add storage permission + host_permissions

Part of: browser state sync Chrome → Camoufox
2026-03-22 14:11:17 +08:00
23 changed files with 2096 additions and 13 deletions
+185
View File
@@ -0,0 +1,185 @@
# Browser State Sync: Chrome → Camoufox
## 目标
`opencli` 无缝对接 camoufox 无头浏览器:登录态从 Chrome 实时导出,注入 camoufox 后直接自动化。
## 架构
```
Chrome Extension ──WebSocket──→ opencli daemon ←──HTTP──→ CLI
playwright-core ──Juggler WS──→ Camoufox
```
- **Chrome 侧**: 现有 Extension + daemon 架构不变,新增 `export-state` / `import-state` action
- **Camoufox 侧**: `playwright-core`Node.js 轻量 client ~3MB)通过 Juggler WS 直连,**不需要 Extension**
- **CLI**: `CamoufoxBridge` 实现 `IBrowserFactory`,所有现有 adapter 自动兼容 camoufox
## 浏览器状态覆盖
| 类型 | 导出(Chrome Extension | 导入(Camoufox Playwright |
|------|---|---|
| Cookie | `chrome.cookies.getAll()` | `context.addCookies()` |
| localStorage | CDP `Runtime.evaluate` 遍历 | `page.evaluate(setItem)` |
| sessionStorage | CDP `Runtime.evaluate` 遍历 | `page.evaluate(setItem)` |
| IndexedDB | CDP `Runtime.evaluate` 全量读取 | `page.evaluate(indexedDB.put)` |
> 覆盖 99%+ 登录态场景。Playwright `page.evaluate()` 走 Juggler 协议,**完全绕过 CSP**。
---
## 文件变更清单
### Phase 1: Chrome Extension — export-state / import-state
| 文件 | 变更 |
|------|------|
| `extension/src/protocol.ts` | `export-state`/`import-state` actions + IndexedDB 类型 |
| `extension/manifest.json` | `storage` permission + `host_permissions: ["<all_urls>"]` |
| `extension/src/background.ts` | +200 行: `handleExportState` / `handleImportState` |
### Phase 2: CLI Types & IPage
| 文件 | 变更 |
|------|------|
| `src/types.ts` | `BrowserState`, `IndexedDBSnapshot` 接口 + `IPage.exportState/importState` |
| `src/browser/daemon-client.ts` | 新 action 类型 + `state` 字段 |
| `src/browser/page.ts` | `exportState()` / `importState()` 通过 daemon 调用 |
| `src/browser/cdp.ts` | Stub methods (CDP 模式不支持) |
### Phase 3: CamoufoxBridge (playwright-core)
| 文件 | 变更 |
|------|------|
| `src/browser/camoufox-page.ts` | **[NEW]** 376 行 — 完整 `IPage` via Playwright Juggler |
| `src/browser/camoufox-bridge.ts` | **[NEW]** 43 行 — `IBrowserFactory` |
| `src/runtime.ts` | `OPENCLI_CAMOUFOX_WS` 环境变量自动切换后端 |
| `src/browser/index.ts` | 导出 `CamoufoxBridge` / `CamoufoxPage` |
### Phase 4: CLI Commands
| 文件 | 变更 |
|------|------|
| `src/cli.ts` | +212 行: `browser` + `camoufox` 命令组 |
### Phase 5: Dependencies
| 文件 | 变更 |
|------|------|
| `package.json` | `playwright-core` (~3MB, 不含浏览器二进制) |
---
## CLI 命令
### Browser 状态管理
```bash
# 一次性快照导出/导入
opencli browser export-state --domain github.com -o github-state.json
opencli browser import-state github-state.json
# 一键同步 Chrome → Camoufox
opencli browser sync --domain github.com
# 🔥 实时同步(长驻进程,Chrome cookie 变化自动推送到 camoufox
opencli browser watch --domain github.com,twitter.com
```
### Camoufox 生命周期
```bash
opencli camoufox setup # 安装 camoufox
opencli camoufox start [--no-headless] # 启动 server
opencli camoufox start --import state.json # 启动并导入状态
opencli camoufox status # 查看状态
```
---
## 🔥 实时同步架构
解决"登录态过期"的核心方案——**Chrome cookie 变化实时推送到 camoufox**
```
chrome.cookies.onChanged ──→ Extension ──WS──→ Daemon ──/sync WS──→ LiveSyncService
context.addCookies() / evaluate()
Camoufox
```
| 层 | 文件 | 职责 |
|----|------|------|
| Extension | `background.ts` | 监听 `chrome.cookies.onChanged`,按 domain 过滤,推送 `SyncEvent` |
| Daemon | `daemon.ts` | `/sync` WS path 接收订阅者,fan-out `state-change` 事件 |
| CLI | `live-sync.ts` | 订阅 daemon,实时 `context.addCookies()` 到 camoufox |
**特性**:
- domain 过滤:只同步你关心的站点
- 自动重连:daemon 或 camoufox 断线后自动恢复
- Cookie 新增/修改/删除 全覆盖
- 统计信息:`Ctrl+C` 退出时显示同步计数
---
## 端到端使用流
```bash
# 1. 安装 camoufox(一次性)
opencli camoufox setup
# 2. 启动 camoufox 无头服务
opencli camoufox start
# 3. 一次性同步当前登录态
opencli browser sync --domain github.com
# 4. 启动实时同步(保持运行)
opencli browser watch --domain github.com &
# 5. 在 camoufox 上运行任何 adapter
OPENCLI_CAMOUFOX_WS=ws://... opencli run github/notifications
```
---
## 导出格式 BrowserState
```json
{
"version": 1,
"url": "https://github.com/jackwener",
"domain": "github.com",
"timestamp": 1711094210000,
"cookies": [
{ "name": "user_session", "value": "abc...", "domain": ".github.com",
"path": "/", "secure": true, "httpOnly": true, "expirationDate": 1742630210 }
],
"localStorage": { "colorMode": "{\"mode\":\"dark\"}" },
"sessionStorage": {},
"indexedDB": [
{
"name": "firebaseLocalStorageDb",
"version": 1,
"objectStores": [
{ "name": "firebaseLocalStorage", "keyPath": "fbase_key",
"autoIncrement": false,
"records": [{ "key": "...", "value": { "token": "..." } }] }
]
}
]
}
```
---
## 关键设计决策
1. **Juggler 而非 Firefox Extension**: Camoufox 原生支持 Playwright Server,通过 Juggler WS 连接
2. **playwright-core 而非 playwright**: 只装协议 client(~3MB),不含浏览器二进制
3. **IPage 统一接口**: CamoufoxPage 和 Page 都实现 IPage,所有 adapter 零修改
4. **实时同步 > 快照**: `chrome.cookies.onChanged` 流式推送,Cookie 永不过期
5. **CSP 绕过**: Playwright evaluate 走 Juggler 协议,等同 CDP
6. **Python launcher**: `launch_server()` JSON 输出 WS endpoint,不猜测端口/path
+268 -6
View File
@@ -254,6 +254,14 @@ async function handleCommand(cmd) {
return await handleCloseWindow(cmd, workspace);
case "sessions":
return await handleSessions(cmd);
case "export-state":
return await handleExportState(cmd, workspace);
case "import-state":
return await handleImportState(cmd, workspace);
case "watch-state":
return handleWatchState(cmd);
case "unwatch-state":
return handleUnwatchState(cmd);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
@@ -265,7 +273,7 @@ async function handleCommand(cmd) {
};
}
}
function isWebUrl(url) {
function isDebuggableUrl(url) {
if (!url) return false;
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
}
@@ -273,9 +281,13 @@ async function resolveTabId(tabId, workspace) {
if (tabId !== void 0) return tabId;
const windowId = await getAutomationWindow(workspace);
const tabs = await chrome.tabs.query({ windowId });
const webTab = tabs.find((t) => t.id && isWebUrl(t.url));
if (webTab?.id) return webTab.id;
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
if (debuggableTab?.id) return debuggableTab.id;
const reuseTab = tabs.find((t) => t.id);
if (reuseTab?.id) {
await chrome.tabs.update(reuseTab.id, { url: "about:blank" });
return reuseTab.id;
}
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
if (!newTab.id) throw new Error("Failed to create tab in automation window");
return newTab.id;
@@ -292,7 +304,7 @@ async function listAutomationTabs(workspace) {
}
async function listAutomationWebTabs(workspace) {
const tabs = await listAutomationTabs(workspace);
return tabs.filter((tab) => isWebUrl(tab.url));
return tabs.filter((tab) => isDebuggableUrl(tab.url));
}
async function handleExec(cmd, workspace) {
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
@@ -425,8 +437,258 @@ async function handleSessions(cmd) {
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
workspace,
windowId: session.windowId,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isWebUrl(tab.url)).length,
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
})));
return { id: cmd.id, ok: true, data };
}
const readStorageJs = (type) => `
(() => {
try {
const s = window.${type};
const result = {};
for (let i = 0; i < s.length; i++) {
const key = s.key(i);
if (key !== null) result[key] = s.getItem(key);
}
return result;
} catch (e) { return {}; }
})()
`;
const READ_INDEXEDDB_JS = `
(async () => {
try {
if (!window.indexedDB || !window.indexedDB.databases) return [];
const dbs = await window.indexedDB.databases();
const results = [];
for (const dbInfo of dbs) {
if (!dbInfo.name) continue;
try {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => { req.transaction.abort(); reject(new Error('upgrade')); };
});
const storeNames = Array.from(db.objectStoreNames);
const objectStores = [];
for (const storeName of storeNames) {
try {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const records = await new Promise((resolve, reject) => {
const allReq = store.getAll();
const keysReq = store.getAllKeys();
allReq.onsuccess = () => {
keysReq.onsuccess = () => {
const r = [];
for (let i = 0; i < allReq.result.length; i++) {
r.push({ key: keysReq.result[i], value: allReq.result[i] });
}
resolve(r);
};
keysReq.onerror = () => resolve([]);
};
allReq.onerror = () => reject(allReq.error);
});
objectStores.push({
name: storeName,
keyPath: store.keyPath,
autoIncrement: store.autoIncrement,
records
});
} catch { /* skip unreadable stores */ }
}
db.close();
results.push({ name: dbInfo.name, version: dbInfo.version || 1, objectStores });
} catch { /* skip unreadable databases */ }
}
return results;
} catch { return []; }
})()
`;
async function handleExportState(cmd, workspace) {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
const tab = await chrome.tabs.get(tabId);
const url = tab.url || "";
let domain = "";
try {
domain = new URL(url).hostname;
} catch {
}
const cookieQuery = {};
if (cmd.domain) cookieQuery.domain = cmd.domain;
else if (url) cookieQuery.url = url;
const rawCookies = await chrome.cookies.getAll(cookieQuery);
const cookies = rawCookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate
}));
const localStorage = await evaluate(tabId, readStorageJs("localStorage")) ?? {};
const sessionStorage = await evaluate(tabId, readStorageJs("sessionStorage")) ?? {};
const indexedDB = await evaluate(tabId, READ_INDEXEDDB_JS) ?? [];
return {
id: cmd.id,
ok: true,
data: {
version: 1,
url,
domain: cmd.domain || domain,
timestamp: Date.now(),
cookies,
localStorage,
sessionStorage,
indexedDB
}
};
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleImportState(cmd, workspace) {
if (!cmd.state) return { id: cmd.id, ok: false, error: "Missing state data" };
const { cookies, localStorage, sessionStorage, indexedDB, url } = cmd.state;
const errors = [];
if (url) {
const tabId2 = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId2, { url });
await new Promise((resolve) => {
const listener = (id, info) => {
if (id === tabId2 && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, 15e3);
});
}
if (cookies?.length) {
for (const c of cookies) {
try {
await chrome.cookies.set({
url: c.url || `http${c.secure ? "s" : ""}://${c.domain.replace(/^\\./, "")}${c.path || "/"}`,
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || "/",
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expirationDate
});
} catch (e) {
errors.push(`Cookie ${c.name}: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
const tabId = await resolveTabId(cmd.tabId, workspace);
for (const [type, data] of [["localStorage", localStorage], ["sessionStorage", sessionStorage]]) {
if (data && Object.keys(data).length > 0) {
try {
const entries = JSON.stringify(data);
await evaluate(tabId, `(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.${type}.setItem(k,v); return Object.keys(e).length; })()`);
} catch (e) {
errors.push(`${type}: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
if (indexedDB?.length) {
const idbData = JSON.stringify(indexedDB);
try {
await evaluate(tabId, `
(async () => {
const dbs = ${idbData};
for (const dbInfo of dbs) {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onupgradeneeded = () => {
const d = req.result;
for (const s of dbInfo.objectStores) {
if (!d.objectStoreNames.contains(s.name)) {
d.createObjectStore(s.name, { keyPath: s.keyPath || undefined, autoIncrement: s.autoIncrement });
}
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const s of dbInfo.objectStores) {
if (!db.objectStoreNames.contains(s.name)) continue;
const tx = db.transaction(s.name, 'readwrite');
const os = tx.objectStore(s.name);
for (const r of s.records) os.put(r.value, s.keyPath ? undefined : r.key);
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
}
db.close();
}
return { ok: true };
})()
`);
} catch (e) {
errors.push(`IndexedDB: ${e instanceof Error ? e.message : String(e)}`);
}
}
return { id: cmd.id, ok: true, data: { imported: true, ...errors.length ? { errors } : {} } };
}
let watchedDomains = null;
function handleWatchState(cmd) {
if (cmd.domains?.length) {
watchedDomains = new Set(cmd.domains);
} else {
watchedDomains = /* @__PURE__ */ new Set();
}
console.log(`[opencli] Watching state changes for: ${watchedDomains.size > 0 ? [...watchedDomains].join(", ") : "all domains"}`);
return { id: cmd.id, ok: true, data: { watching: true, domains: cmd.domains ?? [] } };
}
function handleUnwatchState(cmd) {
watchedDomains = null;
console.log("[opencli] Stopped watching state changes");
return { id: cmd.id, ok: true, data: { watching: false } };
}
function isDomainWatched(domain) {
if (watchedDomains === null) return false;
if (watchedDomains.size === 0) return true;
const clean = domain.replace(/^\./, "");
for (const watched of watchedDomains) {
if (clean === watched || clean.endsWith("." + watched)) return true;
}
return false;
}
function sendSyncEvent(event) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(event));
} catch {
}
}
chrome.cookies.onChanged.addListener((changeInfo) => {
const { cookie, removed, cause } = changeInfo;
if (!isDomainWatched(cookie.domain)) return;
const event = {
type: "state-change",
changeType: "cookie",
domain: cookie.domain.replace(/^\./, ""),
cookie: {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
expirationDate: cookie.expirationDate,
removed,
cause
},
timestamp: Date.now()
};
sendSyncEvent(event);
});
+4
View File
@@ -7,9 +7,13 @@
"debugger",
"tabs",
"cookies",
"storage",
"activeTab",
"alarms"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
+268 -1
View File
@@ -5,7 +5,7 @@
* dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
*/
import type { Command, Result } from './protocol';
import type { Command, Result, SyncEvent } from './protocol';
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
import * as executor from './cdp';
@@ -212,6 +212,14 @@ async function handleCommand(cmd: Command): Promise<Result> {
return await handleCloseWindow(cmd, workspace);
case 'sessions':
return await handleSessions(cmd);
case 'export-state':
return await handleExportState(cmd, workspace);
case 'import-state':
return await handleImportState(cmd, workspace);
case 'watch-state':
return handleWatchState(cmd);
case 'unwatch-state':
return handleUnwatchState(cmd);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
@@ -428,6 +436,265 @@ async function handleSessions(cmd: Command): Promise<Result> {
return { id: cmd.id, ok: true, data };
}
// ─── State export/import handlers ──────────────────────────────────────────
/** JS code to read all key-value pairs from localStorage or sessionStorage */
const readStorageJs = (type: 'localStorage' | 'sessionStorage') => `
(() => {
try {
const s = window.${type};
const result = {};
for (let i = 0; i < s.length; i++) {
const key = s.key(i);
if (key !== null) result[key] = s.getItem(key);
}
return result;
} catch (e) { return {}; }
})()
`;
/** JS code to enumerate all IndexedDB databases and read all records */
const READ_INDEXEDDB_JS = `
(async () => {
try {
if (!window.indexedDB || !window.indexedDB.databases) return [];
const dbs = await window.indexedDB.databases();
const results = [];
for (const dbInfo of dbs) {
if (!dbInfo.name) continue;
try {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => { req.transaction.abort(); reject(new Error('upgrade')); };
});
const storeNames = Array.from(db.objectStoreNames);
const objectStores = [];
for (const storeName of storeNames) {
try {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const records = await new Promise((resolve, reject) => {
const allReq = store.getAll();
const keysReq = store.getAllKeys();
allReq.onsuccess = () => {
keysReq.onsuccess = () => {
const r = [];
for (let i = 0; i < allReq.result.length; i++) {
r.push({ key: keysReq.result[i], value: allReq.result[i] });
}
resolve(r);
};
keysReq.onerror = () => resolve([]);
};
allReq.onerror = () => reject(allReq.error);
});
objectStores.push({
name: storeName,
keyPath: store.keyPath,
autoIncrement: store.autoIncrement,
records
});
} catch { /* skip unreadable stores */ }
}
db.close();
results.push({ name: dbInfo.name, version: dbInfo.version || 1, objectStores });
} catch { /* skip unreadable databases */ }
}
return results;
} catch { return []; }
})()
`;
async function handleExportState(cmd: Command, workspace: string): Promise<Result> {
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
// 1. Get current tab info
const tab = await chrome.tabs.get(tabId);
const url = tab.url || '';
let domain = '';
try { domain = new URL(url).hostname; } catch {}
// 2. Read cookies
const cookieQuery: chrome.cookies.GetAllDetails = {};
if (cmd.domain) cookieQuery.domain = cmd.domain;
else if (url) cookieQuery.url = url;
const rawCookies = await chrome.cookies.getAll(cookieQuery);
const cookies = rawCookies.map(c => ({
name: c.name, value: c.value, domain: c.domain, path: c.path,
secure: c.secure, httpOnly: c.httpOnly, expirationDate: c.expirationDate,
}));
// 3. Read localStorage
const localStorage = await executor.evaluate(tabId, readStorageJs('localStorage')) as Record<string, string> ?? {};
// 4. Read sessionStorage
const sessionStorage = await executor.evaluate(tabId, readStorageJs('sessionStorage')) as Record<string, string> ?? {};
// 5. Read IndexedDB
const indexedDB = await executor.evaluate(tabId, READ_INDEXEDDB_JS) ?? [];
return {
id: cmd.id, ok: true,
data: { version: 1, url, domain: cmd.domain || domain, timestamp: Date.now(),
cookies, localStorage, sessionStorage, indexedDB },
};
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function handleImportState(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.state) return { id: cmd.id, ok: false, error: 'Missing state data' };
const { cookies, localStorage, sessionStorage, indexedDB, url } = cmd.state;
const errors: string[] = [];
// 1. Navigate to target URL (required for same-origin storage access)
if (url) {
const tabId = await resolveTabId(cmd.tabId, workspace);
await chrome.tabs.update(tabId, { url });
await new Promise<void>((resolve) => {
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
if (id === tabId && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
});
}
// 2. Import cookies
if (cookies?.length) {
for (const c of cookies) {
try {
await chrome.cookies.set({
url: c.url || `http${c.secure ? 's' : ''}://${c.domain.replace(/^\\./, '')}${c.path || '/'}`,
name: c.name, value: c.value, domain: c.domain, path: c.path || '/',
secure: c.secure, httpOnly: c.httpOnly, expirationDate: c.expirationDate,
});
} catch (e) { errors.push(`Cookie ${c.name}: ${e instanceof Error ? e.message : String(e)}`); }
}
}
// 3. Import localStorage / sessionStorage via CDP
const tabId = await resolveTabId(cmd.tabId, workspace);
for (const [type, data] of [['localStorage', localStorage], ['sessionStorage', sessionStorage]] as const) {
if (data && Object.keys(data).length > 0) {
try {
const entries = JSON.stringify(data);
await executor.evaluate(tabId, `(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.${type}.setItem(k,v); return Object.keys(e).length; })()`);
} catch (e) { errors.push(`${type}: ${e instanceof Error ? e.message : String(e)}`); }
}
}
// 4. Import IndexedDB
if (indexedDB?.length) {
const idbData = JSON.stringify(indexedDB);
try {
await executor.evaluate(tabId, `
(async () => {
const dbs = ${idbData};
for (const dbInfo of dbs) {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onupgradeneeded = () => {
const d = req.result;
for (const s of dbInfo.objectStores) {
if (!d.objectStoreNames.contains(s.name)) {
d.createObjectStore(s.name, { keyPath: s.keyPath || undefined, autoIncrement: s.autoIncrement });
}
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const s of dbInfo.objectStores) {
if (!db.objectStoreNames.contains(s.name)) continue;
const tx = db.transaction(s.name, 'readwrite');
const os = tx.objectStore(s.name);
for (const r of s.records) os.put(r.value, s.keyPath ? undefined : r.key);
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
}
db.close();
}
return { ok: true };
})()
`);
} catch (e) { errors.push(`IndexedDB: ${e instanceof Error ? e.message : String(e)}`); }
}
return { id: cmd.id, ok: true, data: { imported: true, ...(errors.length ? { errors } : {}) } };
}
// ─── Real-time state sync ──────────────────────────────────────────────
/** Set of domains being watched for state changes. Empty = all domains. */
let watchedDomains: Set<string> | null = null; // null = not watching
function handleWatchState(cmd: Command): Result {
if (cmd.domains?.length) {
watchedDomains = new Set(cmd.domains);
} else {
watchedDomains = new Set(); // empty = watch all
}
console.log(`[opencli] Watching state changes for: ${watchedDomains.size > 0 ? [...watchedDomains].join(', ') : 'all domains'}`);
return { id: cmd.id, ok: true, data: { watching: true, domains: cmd.domains ?? [] } };
}
function handleUnwatchState(cmd: Command): Result {
watchedDomains = null;
console.log('[opencli] Stopped watching state changes');
return { id: cmd.id, ok: true, data: { watching: false } };
}
function isDomainWatched(domain: string): boolean {
if (watchedDomains === null) return false;
if (watchedDomains.size === 0) return true; // watch all
// Check exact and parent domain match (e.g. .github.com matches github.com)
const clean = domain.replace(/^\./, '');
for (const watched of watchedDomains) {
if (clean === watched || clean.endsWith('.' + watched)) return true;
}
return false;
}
function sendSyncEvent(event: SyncEvent): void {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
ws.send(JSON.stringify(event));
} catch { /* ignore */ }
}
// Cookie change listener — fires whenever any cookie is set, deleted, or modified
chrome.cookies.onChanged.addListener((changeInfo) => {
const { cookie, removed, cause } = changeInfo;
if (!isDomainWatched(cookie.domain)) return;
const event: SyncEvent = {
type: 'state-change',
changeType: 'cookie',
domain: cookie.domain.replace(/^\./, ''),
cookie: {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
expirationDate: cookie.expirationDate,
removed,
cause,
},
timestamp: Date.now(),
};
sendSyncEvent(event);
});
export const __test__ = {
handleTabs,
handleSessions,
+62 -2
View File
@@ -1,11 +1,11 @@
/**
* opencli browser protocol — shared types between daemon, extension, and CLI.
*
* 5 actions: exec, navigate, tabs, cookies, screenshot.
* Actions: exec, navigate, tabs, cookies, screenshot, export-state, import-state.
* Everything else is just JS code sent via 'exec'.
*/
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'export-state' | 'import-state' | 'watch-state' | 'unwatch-state';
export interface Command {
/** Unique request ID */
@@ -32,6 +32,66 @@ export interface Command {
quality?: number;
/** Whether to capture full page (not just viewport) */
fullPage?: boolean;
/** Storage type to export (export-state action) */
storageType?: 'localStorage' | 'sessionStorage' | 'all';
/** Domain filter for watch-state */
domains?: string[];
/** Browser state to import (import-state action) */
state?: {
cookies?: Array<{
name: string;
value: string;
domain: string;
path?: string;
secure?: boolean;
httpOnly?: boolean;
expirationDate?: number;
url?: string;
}>;
localStorage?: Record<string, string>;
sessionStorage?: Record<string, string>;
indexedDB?: Array<{
name: string;
version: number;
objectStores: Array<{
name: string;
keyPath: string | string[] | null;
autoIncrement: boolean;
records: Array<{ key: unknown; value: unknown }>;
}>;
}>;
url?: string;
};
}
/** Real-time state change event pushed from extension → daemon → sync subscribers */
export interface SyncEvent {
type: 'state-change';
changeType: 'cookie' | 'localStorage' | 'sessionStorage';
/** The domain this change applies to */
domain: string;
/** For cookies: the changed cookie */
cookie?: {
name: string;
value: string;
domain: string;
path: string;
secure: boolean;
httpOnly: boolean;
expirationDate?: number;
removed: boolean;
/** Reason for removal: 'expired', 'explicit', 'overwrite', etc */
cause?: string;
};
/** For storage: key-value change */
storage?: {
key: string;
newValue: string | null;
oldValue: string | null;
storageArea: 'localStorage' | 'sessionStorage';
url: string;
};
timestamp: number;
}
export interface Result {
+23
View File
@@ -14,6 +14,7 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"playwright-core": "^1.58.2",
"ws": "^8.18.0"
},
"bin": {
@@ -193,6 +194,7 @@
"integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "5.49.2",
"@algolia/requester-browser-xhr": "5.49.2",
@@ -2157,6 +2159,7 @@
"integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/abtesting": "1.15.2",
"@algolia/client-abtesting": "5.49.2",
@@ -2485,6 +2488,7 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"tabbable": "^6.4.0"
}
@@ -2613,6 +2617,7 @@
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -3084,6 +3089,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3091,6 +3097,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -3472,6 +3490,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3492,6 +3511,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3616,6 +3636,7 @@
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
@@ -4181,6 +4202,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -4323,6 +4345,7 @@
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
+1
View File
@@ -53,6 +53,7 @@
"cli-table3": "^0.6.5",
"commander": "^14.0.3",
"js-yaml": "^4.1.0",
"playwright-core": "^1.58.2",
"ws": "^8.18.0"
},
"devDependencies": {
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
Camoufox server launcher for opencli.
Starts a Camoufox browser in server mode and outputs the WebSocket endpoint
as JSON for the Node.js caller to parse.
Usage:
python3 scripts/camoufox_server.py [--headless] [--port PORT]
"""
import sys
import json
def main():
headless = "--headless" in sys.argv
port = None
if "--port" in sys.argv:
idx = sys.argv.index("--port")
if idx + 1 < len(sys.argv):
port = int(sys.argv[idx + 1])
try:
from camoufox.server import launch_server
except ImportError:
print(json.dumps({"error": "camoufox not installed. Run: pip3 install camoufox"}), flush=True)
sys.exit(1)
try:
kwargs = {"headless": headless}
if port is not None:
kwargs["port"] = port
ws_endpoint = launch_server(**kwargs)
print(json.dumps({"ws_endpoint": ws_endpoint}), flush=True)
# Keep the process running — the server lives as long as this process
# Use threading.Event instead of signal.pause for cross-platform compat
import threading
threading.Event().wait()
except KeyboardInterrupt:
pass
except Exception as e:
print(json.dumps({"error": str(e)}), flush=True)
sys.exit(1)
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
/**
* CamoufoxBridge — IBrowserFactory that connects to a running Camoufox server
* via playwright-core's Juggler (Firefox) WebSocket protocol.
*
* Uses CamoufoxPool for shared context — ensures LiveSyncService cookies
* are visible to adapter pages.
*/
import * as CamoufoxPool from './camoufox-pool.js';
import type { IBrowserFactory } from '../runtime.js';
import type { IPage } from '../types.js';
import { CamoufoxPage } from './camoufox-page.js';
export class CamoufoxBridge implements IBrowserFactory {
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS ?? 'ws://127.0.0.1:19826';
const context = await CamoufoxPool.acquire(wsEndpoint, { timeout: opts?.timeout });
const page = await context.newPage();
return new CamoufoxPage(page, context);
}
async close(): Promise<void> {
// Release our reference — pool stays alive if other users (LiveSyncService) hold refs
await CamoufoxPool.release();
}
}
+376
View File
@@ -0,0 +1,376 @@
/**
* CamoufoxPage — implements IPage by talking to Camoufox via playwright-core Juggler protocol.
*
* This is a drop-in replacement for the daemon-based Page class.
* All browser interactions go through Playwright's Firefox protocol (Juggler),
* which means page.evaluate() bypasses CSP just like CDP does.
*
* The DOM snapshot engine, click/type/scroll helpers are reused from the shared
* dom-snapshot.ts and dom-helpers.ts — same JS code, different transport.
*/
import type { Page as PwPage, BrowserContext } from 'playwright-core';
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { formatSnapshot } from '../snapshotFormatter.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import {
clickJs,
typeTextJs,
pressKeyJs,
waitForTextJs,
scrollJs,
autoScrollJs,
networkRequestsJs,
} from './dom-helpers.js';
export class CamoufoxPage implements IPage {
constructor(
private readonly page: PwPage,
private readonly context: BrowserContext,
) {}
async goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void> {
await this.page.goto(url, {
waitUntil: options?.waitUntil === 'none' ? undefined : 'load',
timeout: 30000,
});
if (options?.waitUntil !== 'none') {
const settleMs = options?.settleMs ?? 1000;
await this.page.waitForTimeout(settleMs);
}
}
async evaluate(js: string): Promise<unknown> {
// Apply same IIFE wrapping as the daemon-based Page class.
// This ensures adapter code that passes arrow functions or statements
// works identically across both backends.
const code = wrapForEval(js);
return this.page.evaluate(code);
}
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<BrowserCookie[]> {
const urls = opts.url ? [opts.url] : undefined;
const cookies = await this.context.cookies(urls);
const filtered = opts.domain
? cookies.filter(c => c.domain.includes(opts.domain!))
: cookies;
return filtered.map(c => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expirationDate: c.expires !== -1 ? c.expires : undefined,
}));
}
async snapshot(opts: SnapshotOptions = {}): Promise<unknown> {
const snapshotJs = generateSnapshotJs({
viewportExpand: opts.viewportExpand ?? 800,
maxDepth: Math.max(1, Math.min(Number(opts.maxDepth) || 50, 200)),
interactiveOnly: opts.interactive ?? false,
maxTextLength: opts.maxTextLength ?? 120,
includeScrollInfo: true,
bboxDedup: true,
});
try {
return await this.page.evaluate(snapshotJs);
} catch {
// Fallback: basic snapshot
const raw = await this.page.evaluate(`
(function() {
function buildTree(node, depth) {
if (depth > 50) return '';
const role = node.getAttribute?.('role') || node.tagName?.toLowerCase() || 'generic';
const name = node.getAttribute?.('aria-label') || node.textContent?.trim().slice(0, 80) || '';
let line = ' '.repeat(depth) + role;
if (name) line += ' "' + name.replace(/"/g, '\\\\"') + '"';
let result = line + '\\n';
if (node.children) { for (const child of node.children) result += buildTree(child, depth + 1); }
return result;
}
return buildTree(document.body, 0);
})()
`);
if (typeof raw === 'string') return formatSnapshot(raw, opts);
return raw;
}
}
async click(ref: string): Promise<void> {
await this.page.evaluate(clickJs(ref));
}
async typeText(ref: string, text: string): Promise<void> {
await this.page.evaluate(typeTextJs(ref, text));
}
async pressKey(key: string): Promise<void> {
await this.page.evaluate(pressKeyJs(key));
}
async scrollTo(ref: string): Promise<unknown> {
return this.page.evaluate(scrollToRefJs(ref));
}
async getFormState(): Promise<Record<string, unknown>> {
return (await this.page.evaluate(getFormStateJs())) as Record<string, unknown>;
}
async wait(options: number | WaitOptions): Promise<void> {
if (typeof options === 'number') {
await this.page.waitForTimeout(options * 1000);
return;
}
if (options.time) {
await this.page.waitForTimeout(options.time * 1000);
return;
}
if (options.text) {
const timeout = (options.timeout ?? 30) * 1000;
await this.page.evaluate(waitForTextJs(options.text, timeout));
}
}
async tabs(): Promise<unknown[]> {
return this.context.pages().map((p, i) => ({
index: i,
url: p.url(),
title: '', // title() is async, simplify
active: p === this.page,
}));
}
async closeTab(index?: number): Promise<void> {
const pages = this.context.pages();
const target = index !== undefined ? pages[index] : this.page;
if (target) await target.close();
}
async newTab(): Promise<void> {
await this.context.newPage();
}
async selectTab(index: number): Promise<void> {
const pages = this.context.pages();
if (pages[index]) await pages[index].bringToFront();
}
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
const result = await this.page.evaluate(networkRequestsJs(includeStatic));
return Array.isArray(result) ? result : [];
}
async consoleMessages(_level?: string): Promise<unknown[]> {
return [];
}
async screenshot(options: ScreenshotOptions = {}): Promise<string> {
const buffer = await this.page.screenshot({
type: options.format ?? 'png',
quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
fullPage: options.fullPage ?? false,
});
const base64 = buffer.toString('base64');
if (options.path) {
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.dirname(options.path);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(options.path, buffer);
}
return base64;
}
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
await this.page.evaluate(scrollJs(direction, amount));
}
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
const times = options?.times ?? 3;
const delayMs = options?.delayMs ?? 2000;
await this.page.evaluate(autoScrollJs(times, delayMs));
}
async installInterceptor(pattern: string): Promise<void> {
const { generateInterceptorJs } = await import('../interceptor.js');
await this.page.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
arrayName: '__opencli_xhr',
patchGuard: '__opencli_interceptor_patched',
}));
}
async getInterceptedRequests(): Promise<unknown[]> {
const { generateReadInterceptedJs } = await import('../interceptor.js');
const result = await this.page.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async exportState(opts: { domain?: string } = {}): Promise<BrowserState> {
const url = this.page.url();
let domain = '';
try { domain = new URL(url).hostname; } catch {}
// Cookies — via Playwright context API
const rawCookies = await this.context.cookies(opts.domain ? undefined : [url]);
const allCookies = opts.domain
? rawCookies.filter(c => c.domain.includes(opts.domain!))
: rawCookies;
const cookies = allCookies.map(c => ({
name: c.name, value: c.value, domain: c.domain, path: c.path,
secure: c.secure, httpOnly: c.httpOnly,
expirationDate: c.expires !== -1 ? c.expires : undefined,
}));
// localStorage
const localStorage = await this.page.evaluate(`
(() => {
try { const r = {}; for (let i = 0; i < window.localStorage.length; i++) { const k = window.localStorage.key(i); if (k) r[k] = window.localStorage.getItem(k); } return r; }
catch { return {}; }
})()
`) as Record<string, string>;
// sessionStorage
const sessionStorage = await this.page.evaluate(`
(() => {
try { const r = {}; for (let i = 0; i < window.sessionStorage.length; i++) { const k = window.sessionStorage.key(i); if (k) r[k] = window.sessionStorage.getItem(k); } return r; }
catch { return {}; }
})()
`) as Record<string, string>;
// IndexedDB
const indexedDB = await this.page.evaluate(`
(async () => {
try {
if (!window.indexedDB || !window.indexedDB.databases) return [];
const dbs = await window.indexedDB.databases();
const results = [];
for (const dbInfo of dbs) {
if (!dbInfo.name) continue;
try {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
req.onupgradeneeded = () => { req.transaction.abort(); reject(new Error('upgrade')); };
});
const storeNames = Array.from(db.objectStoreNames);
const objectStores = [];
for (const storeName of storeNames) {
try {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const records = await new Promise((resolve, reject) => {
const allReq = store.getAll();
const keysReq = store.getAllKeys();
allReq.onsuccess = () => {
keysReq.onsuccess = () => {
const r = [];
for (let i = 0; i < allReq.result.length; i++) r.push({ key: keysReq.result[i], value: allReq.result[i] });
resolve(r);
};
keysReq.onerror = () => resolve([]);
};
allReq.onerror = () => reject(allReq.error);
});
objectStores.push({ name: storeName, keyPath: store.keyPath, autoIncrement: store.autoIncrement, records });
} catch {}
}
db.close();
results.push({ name: dbInfo.name, version: dbInfo.version || 1, objectStores });
} catch {}
}
return results;
} catch { return []; }
})()
`) as BrowserState['indexedDB'];
return {
version: 1,
url,
domain: opts.domain || domain,
timestamp: Date.now(),
cookies,
localStorage: localStorage ?? {},
sessionStorage: sessionStorage ?? {},
indexedDB: indexedDB ?? [],
};
}
async importState(state: BrowserState): Promise<void> {
// 1. Cookies FIRST — before navigation, so the initial request has cookies
if (state.cookies?.length) {
const pwCookies = state.cookies.map(c => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
secure: c.secure ?? false,
httpOnly: c.httpOnly ?? false,
expires: c.expirationDate ?? -1,
}));
await this.context.addCookies(pwCookies);
}
// 2. Navigate — now the first request already carries cookies
if (state.url) {
await this.page.goto(state.url, { waitUntil: 'load', timeout: 30000 });
await this.page.waitForTimeout(1000);
}
// 3. Import localStorage via Playwright storageState-compatible path +
// manual evaluate for completeness (storageState only covers origins)
if (state.localStorage && Object.keys(state.localStorage).length > 0) {
const entries = JSON.stringify(state.localStorage);
await this.page.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.localStorage.setItem(k,v); })()`);
}
// 4. Import sessionStorage
if (state.sessionStorage && Object.keys(state.sessionStorage).length > 0) {
const entries = JSON.stringify(state.sessionStorage);
await this.page.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.sessionStorage.setItem(k,v); })()`);
}
// 5. Import IndexedDB
if (state.indexedDB?.length) {
const idbData = JSON.stringify(state.indexedDB);
await this.page.evaluate(`
(async () => {
const dbs = ${idbData};
for (const dbInfo of dbs) {
const db = await new Promise((resolve, reject) => {
const req = window.indexedDB.open(dbInfo.name, dbInfo.version);
req.onupgradeneeded = () => {
const d = req.result;
for (const s of dbInfo.objectStores) {
if (!d.objectStoreNames.contains(s.name)) {
d.createObjectStore(s.name, { keyPath: s.keyPath || undefined, autoIncrement: s.autoIncrement });
}
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const s of dbInfo.objectStores) {
if (!db.objectStoreNames.contains(s.name)) continue;
const tx = db.transaction(s.name, 'readwrite');
const os = tx.objectStore(s.name);
for (const r of s.records) os.put(r.value, s.keyPath ? undefined : r.key);
await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); });
}
db.close();
}
})()
`);
}
// 6. Reload to apply all injected state
await this.page.reload({ waitUntil: 'load', timeout: 30000 });
await this.page.waitForTimeout(1000);
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* CamoufoxPool — Global singleton for shared Camoufox browser/context.
*
* Solves the context isolation problem: LiveSyncService and CamoufoxBridge
* must share the same BrowserContext so that cookies synced in real-time
* are visible to adapter pages.
*
* Usage pattern:
* const ctx = await CamoufoxPool.acquire(wsEndpoint);
* const page = await ctx.newPage();
* // ... use page ...
* // Don't close context — pool manages lifecycle
*/
import { firefox } from 'playwright-core';
import type { Browser, BrowserContext } from 'playwright-core';
let _browser: Browser | null = null;
let _context: BrowserContext | null = null;
let _wsEndpoint: string | null = null;
let _refCount = 0;
/**
* Acquire the shared Camoufox context. Creates browser/context on first call,
* reuses on subsequent calls. Increments reference count.
*/
export async function acquire(wsEndpoint: string, opts?: { timeout?: number }): Promise<BrowserContext> {
// If already connected to a different endpoint, close old connection
if (_browser && _wsEndpoint !== wsEndpoint) {
await release(true);
}
if (!_browser || !_context) {
const timeoutMs = (opts?.timeout ?? 30) * 1000;
_browser = await firefox.connect(wsEndpoint, { timeout: timeoutMs });
_context = await _browser.newContext();
_wsEndpoint = wsEndpoint;
// Handle browser disconnect
_browser.on('disconnected', () => {
_browser = null;
_context = null;
_wsEndpoint = null;
_refCount = 0;
});
}
_refCount++;
return _context;
}
/**
* Release a reference to the pool. When all references are released,
* optionally close the context (default: keep alive for future use).
* Pass force=true to close immediately regardless of refcount.
*/
export async function release(force = false): Promise<void> {
_refCount = Math.max(0, _refCount - 1);
if (force || _refCount === 0) {
try {
await _context?.close();
} catch { /* already closed */ }
_context = null;
// Don't close browser — camoufox server manages its lifecycle
_browser = null;
_wsEndpoint = null;
_refCount = 0;
}
}
/** Get the current shared context without incrementing refcount (for inspection). */
export function getContext(): BrowserContext | null {
return _context;
}
/** Check if pool has an active connection. */
export function isConnected(): boolean {
return _browser !== null && _context !== null;
}
/** Get connection info. */
export function getInfo(): { wsEndpoint: string | null; refCount: number; connected: boolean } {
return {
wsEndpoint: _wsEndpoint,
refCount: _refCount,
connected: isConnected(),
};
}
+9 -1
View File
@@ -9,7 +9,7 @@
*/
import { WebSocket, type RawData } from 'ws';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
import {
@@ -323,6 +323,14 @@ class CDPPage implements IPage {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async exportState(_opts?: { domain?: string }): Promise<BrowserState> {
throw new Error('exportState is not supported in CDP mode. Use daemon mode instead.');
}
async importState(_state: BrowserState): Promise<void> {
throw new Error('importState is not supported in CDP mode. Use daemon mode instead.');
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
+2 -1
View File
@@ -15,7 +15,7 @@ function generateId(): string {
export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'export-state' | 'import-state';
tabId?: number;
code?: string;
workspace?: string;
@@ -26,6 +26,7 @@ export interface DaemonCommand {
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
state?: unknown;
}
export interface DaemonResult {
+5
View File
@@ -8,7 +8,12 @@
export { Page } from './page.js';
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
export { CDPBridge } from './cdp.js';
export { CamoufoxBridge } from './camoufox-bridge.js';
export { CamoufoxPage } from './camoufox-page.js';
export * as CamoufoxPool from './camoufox-pool.js';
export { isDaemonRunning } from './daemon-client.js';
export { LiveSyncService } from './live-sync.js';
export type { SyncServiceOptions } from './live-sync.js';
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
export type { SnapshotOptions } from './dom-snapshot.js';
+322
View File
@@ -0,0 +1,322 @@
/**
* Live state sync service: Chrome → Camoufox.
*
* Three-phase approach:
* 1. Initial full sync: export-state from Chrome → import-state to Camoufox
* 2. Real-time watch: subscribe to daemon /sync WS for cookie changes
* 3. Debounced batching: accumulate changes over 50ms windows, apply in bulk
*
* Uses CamoufoxPool for shared context — adapters see the same cookies.
*/
import { WebSocket } from 'ws';
import type { BrowserContext } from 'playwright-core';
import * as CamoufoxPool from './camoufox-pool.js';
export interface SyncServiceOptions {
/** Daemon WebSocket URL base (default: ws://127.0.0.1:19825) */
daemonUrl?: string;
/** Camoufox WebSocket endpoint */
camoufoxWs: string;
/** Domains to watch (empty = all) */
domains?: string[];
/** Skip initial full sync (only watch changes) */
skipInitialSync?: boolean;
/** Callback on each synced event */
onSync?: (event: SyncEvent) => void;
/** Callback on initial sync complete */
onInitialSync?: (stats: { cookies: number; localStorage: number }) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
interface SyncEvent {
type: 'state-change';
changeType: 'cookie' | 'localStorage' | 'sessionStorage';
domain: string;
cookie?: {
name: string;
value: string;
domain: string;
path: string;
secure: boolean;
httpOnly: boolean;
expirationDate?: number;
removed: boolean;
cause?: string;
};
storage?: {
key: string;
newValue: string | null;
oldValue: string | null;
storageArea: 'localStorage' | 'sessionStorage';
url: string;
};
timestamp: number;
}
/** Pending cookie change for batching */
interface PendingCookie {
name: string;
value: string;
domain: string;
path: string;
secure: boolean;
httpOnly: boolean;
expires: number;
removed: boolean;
}
export class LiveSyncService {
private daemonWs: WebSocket | null = null;
private context: BrowserContext | null = null;
private running = false;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stats = { cookies: 0, storage: 0, errors: 0, initialSync: false };
// Debounce batching
private pendingCookies: PendingCookie[] = [];
private batchTimer: ReturnType<typeof setTimeout> | null = null;
private readonly BATCH_WINDOW_MS = 50;
constructor(private readonly opts: SyncServiceOptions) {}
async start(): Promise<void> {
this.running = true;
// Acquire shared context from pool
this.context = await CamoufoxPool.acquire(this.opts.camoufoxWs);
// Phase 1: Initial full sync (unless skipped)
if (!this.opts.skipInitialSync) {
await this.initialSync();
}
// Phase 2: Start real-time watch
this.connectToDaemon();
await this.sendWatchCommand();
}
async stop(): Promise<void> {
this.running = false;
await this.sendUnwatchCommand().catch(() => {});
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.batchTimer) clearTimeout(this.batchTimer);
this.daemonWs?.close();
this.daemonWs = null;
// Release pool reference (don't force-close — other users may hold refs)
await CamoufoxPool.release();
this.context = null;
}
getStats() { return { ...this.stats }; }
// ── Phase 1: Initial full sync ───────────────────────────────────────
private async initialSync(): Promise<void> {
const daemonHttp = this.getDaemonHttp();
// Export state from Chrome via daemon
const domains = this.opts.domains ?? [];
const domain = domains[0]; // Use first domain for navigation
const exportBody = JSON.stringify({
id: `init-export-${Date.now()}`,
action: 'export-state',
...(domain ? { domain } : {}),
});
const exportResp = await fetch(`${daemonHttp}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: exportBody,
});
const exportResult = await exportResp.json() as { ok: boolean; data?: any; error?: string };
if (!exportResult.ok) {
this.opts.onError?.(new Error(`Initial export failed: ${exportResult.error}`));
return;
}
const state = exportResult.data;
if (!state || !this.context) return;
// Import cookies to camoufox context
if (state.cookies?.length) {
const pwCookies = state.cookies.map((c: any) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
secure: c.secure ?? false,
httpOnly: c.httpOnly ?? false,
expires: c.expirationDate ?? -1,
}));
await this.context.addCookies(pwCookies);
}
// Import localStorage — need a page on the target domain
if (state.localStorage && Object.keys(state.localStorage).length > 0 && state.url) {
let tempPage;
try {
tempPage = await this.context.newPage();
await tempPage.goto(state.url, { waitUntil: 'load', timeout: 15000 });
const entries = JSON.stringify(state.localStorage);
await tempPage.evaluate(`(() => { const e = ${entries}; for (const [k,v] of Object.entries(e)) window.localStorage.setItem(k,v); })()`);
} catch { /* non-critical */ }
finally {
try { await tempPage?.close(); } catch {}
}
}
this.stats.initialSync = true;
this.opts.onInitialSync?.({
cookies: state.cookies?.length ?? 0,
localStorage: Object.keys(state.localStorage ?? {}).length,
});
}
// ── Phase 2: Real-time watch ─────────────────────────────────────────
private connectToDaemon(): void {
const base = this.opts.daemonUrl ?? 'ws://127.0.0.1:19825';
const syncUrl = `${base}/sync`;
this.daemonWs = new WebSocket(syncUrl);
this.daemonWs.on('message', (data) => {
try {
const event: SyncEvent = JSON.parse(data.toString());
if (event.type === 'state-change') {
this.handleSyncEvent(event);
}
} catch { /* ignore malformed */ }
});
this.daemonWs.on('close', () => {
if (this.running) {
this.reconnectTimer = setTimeout(() => this.connectToDaemon(), 2000);
}
});
this.daemonWs.on('error', () => { /* triggers close */ });
}
// ── Phase 3: Debounced batching ──────────────────────────────────────
private handleSyncEvent(event: SyncEvent): void {
if (event.changeType === 'cookie' && event.cookie) {
// Accumulate in batch
this.pendingCookies.push({
name: event.cookie.name,
value: event.cookie.removed ? '' : event.cookie.value,
domain: event.cookie.domain,
path: event.cookie.path,
secure: event.cookie.secure,
httpOnly: event.cookie.httpOnly,
expires: event.cookie.removed ? 0 : (event.cookie.expirationDate ?? -1),
removed: event.cookie.removed,
});
// Schedule batch flush
if (!this.batchTimer) {
this.batchTimer = setTimeout(() => this.flushCookieBatch(), this.BATCH_WINDOW_MS);
}
this.opts.onSync?.(event);
}
if (event.changeType === 'localStorage' && event.storage) {
this.handleStorageEvent(event).catch(err => {
this.stats.errors++;
this.opts.onError?.(err);
});
this.opts.onSync?.(event);
}
}
private async flushCookieBatch(): Promise<void> {
this.batchTimer = null;
if (!this.context || this.pendingCookies.length === 0) return;
const batch = this.pendingCookies.splice(0);
// Deduplicate: keep last change per name+domain+path
const deduped = new Map<string, PendingCookie>();
for (const c of batch) {
deduped.set(`${c.name}|${c.domain}|${c.path}`, c);
}
const cookies = [...deduped.values()].map(c => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
httpOnly: c.httpOnly,
expires: c.expires,
}));
try {
await this.context.addCookies(cookies);
this.stats.cookies += cookies.length;
} catch (err) {
this.stats.errors++;
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
}
private async handleStorageEvent(event: SyncEvent): Promise<void> {
if (!this.context || !event.storage) return;
const pages = this.context.pages();
for (const page of pages) {
try {
const pageUrl = page.url();
if (!pageUrl.includes(event.domain)) continue;
if (event.storage.newValue !== null) {
await page.evaluate(`window.localStorage.setItem(${JSON.stringify(event.storage.key)}, ${JSON.stringify(event.storage.newValue)})`);
} else {
await page.evaluate(`window.localStorage.removeItem(${JSON.stringify(event.storage.key)})`);
}
this.stats.storage++;
} catch { /* page might be navigating */ }
}
}
// ── Helpers ──────────────────────────────────────────────────────────
private getDaemonHttp(): string {
return (this.opts.daemonUrl ?? 'ws://127.0.0.1:19825').replace('ws://', 'http://');
}
private async sendWatchCommand(): Promise<void> {
const body = JSON.stringify({
id: `watch-${Date.now()}`,
action: 'watch-state',
domains: this.opts.domains ?? [],
});
const resp = await fetch(`${this.getDaemonHttp()}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const result = await resp.json() as { ok: boolean; error?: string };
if (!result.ok) throw new Error(`watch-state failed: ${result.error}`);
}
private async sendUnwatchCommand(): Promise<void> {
const body = JSON.stringify({
id: `unwatch-${Date.now()}`,
action: 'unwatch-state',
});
await fetch(`${this.getDaemonHttp()}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {});
}
}
+24 -1
View File
@@ -11,7 +11,7 @@
*/
import { formatSnapshot } from '../snapshotFormatter.js';
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import type { BrowserCookie, BrowserState, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
import { sendCommand } from './daemon-client.js';
import { wrapForEval } from './utils.js';
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
@@ -266,6 +266,29 @@ export class Page implements IPage {
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
return Array.isArray(result) ? result : [];
}
async exportState(opts: { domain?: string } = {}): Promise<BrowserState> {
const result = await sendCommand('export-state', {
...this._workspaceOpt(),
...this._tabOpt(),
...(opts.domain ? { domain: opts.domain } : {}),
});
return result as BrowserState;
}
async importState(state: BrowserState): Promise<void> {
await sendCommand('import-state', {
...this._workspaceOpt(),
...this._tabOpt(),
state: {
cookies: state.cookies,
localStorage: state.localStorage,
sessionStorage: state.sessionStorage,
indexedDB: state.indexedDB,
url: state.url,
},
});
}
}
// (End of file)
+309
View File
@@ -231,6 +231,315 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
printCompletionScript(shell);
});
// ── Built-in: browser state management ────────────────────────────────────
const browserCmd = program.command('browser').description('Browser state management');
browserCmd
.command('export-state')
.description('Export browser state (cookies, storage, IndexedDB) to JSON')
.option('-d, --domain <domain>', 'Filter cookies by domain')
.option('-o, --output <file>', 'Output file path', 'browser-state.json')
.action(async (opts) => {
const fs = await import('node:fs');
const BrowserFactory = getBrowserFactory();
const state = await browserSession(BrowserFactory, async (page) => {
if (opts.domain) {
try { await page.goto(`https://${opts.domain}`); await page.wait(2); } catch {}
}
return page.exportState({ domain: opts.domain });
}, { workspace: 'state:export' });
fs.writeFileSync(opts.output, JSON.stringify(state, null, 2));
const stats = {
cookies: state.cookies?.length ?? 0,
localStorage: Object.keys(state.localStorage ?? {}).length,
sessionStorage: Object.keys(state.sessionStorage ?? {}).length,
indexedDB: (state.indexedDB ?? []).length,
};
console.log(chalk.green(`✅ Browser state exported to ${opts.output}`));
console.log(chalk.dim(` ${stats.cookies} cookies, ${stats.localStorage} localStorage, ${stats.sessionStorage} sessionStorage, ${stats.indexedDB} IndexedDB databases`));
});
browserCmd
.command('import-state')
.description('Import browser state from JSON file')
.argument('<file>', 'Path to browser state JSON file')
.action(async (file) => {
const fs = await import('node:fs');
if (!fs.existsSync(file)) {
console.error(chalk.red(`File not found: ${file}`));
process.exitCode = 1;
return;
}
const state = JSON.parse(fs.readFileSync(file, 'utf-8'));
const BrowserFactory = getBrowserFactory();
await browserSession(BrowserFactory, async (page) => {
await page.importState(state);
}, { workspace: 'state:import' });
console.log(chalk.green(`✅ Browser state imported from ${file}`));
});
browserCmd
.command('sync')
.description('Sync browser state from Chrome to Camoufox')
.option('-d, --domain <domain>', 'Filter by domain')
.action(async (opts) => {
// Step 1: Export from Chrome (daemon mode)
const { BrowserBridge } = await import('./browser/index.js');
const chromeBridge = new BrowserBridge();
let state;
try {
const chromePage = await chromeBridge.connect({ workspace: 'state:sync-export' });
if (opts.domain) {
try { await chromePage.goto(`https://${opts.domain}`); await chromePage.wait(2); } catch {}
}
state = await chromePage.exportState({ domain: opts.domain });
console.log(chalk.cyan(`📤 Exported from Chrome: ${state.cookies.length} cookies, ${Object.keys(state.localStorage).length} localStorage entries`));
} finally {
await chromeBridge.close().catch(() => {});
}
// Step 2: Import to Camoufox
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
if (!wsEndpoint) {
// Save to file if camoufox is not running
const fs = await import('node:fs');
const outFile = `${opts.domain || 'browser'}-state.json`;
fs.writeFileSync(outFile, JSON.stringify(state, null, 2));
console.log(chalk.yellow(`⚠️ Camoufox not running (OPENCLI_CAMOUFOX_WS not set). State saved to ${outFile}`));
console.log(chalk.dim(` Start camoufox, then: opencli browser import-state ${outFile}`));
return;
}
const { CamoufoxBridge } = await import('./browser/index.js');
const cfBridge = new CamoufoxBridge();
try {
const cfPage = await cfBridge.connect({ workspace: 'state:sync-import' });
await cfPage.importState(state);
console.log(chalk.green(`📥 Imported to Camoufox — login state synced!`));
} finally {
await cfBridge.close().catch(() => {});
}
});
browserCmd
.command('watch')
.description('Live sync: initial full sync + stream changes from Chrome → Camoufox')
.option('-d, --domain <domains>', 'Domains to watch (comma-separated)', '')
.option('--skip-initial', 'Skip initial full sync, only watch changes')
.action(async (opts) => {
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
if (!wsEndpoint) {
console.error(chalk.red('OPENCLI_CAMOUFOX_WS is not set. Start camoufox first: opencli camoufox start'));
process.exitCode = 1;
return;
}
const domains = opts.domain ? opts.domain.split(',').map((d: string) => d.trim()).filter(Boolean) : [];
const { LiveSyncService } = await import('./browser/index.js');
const service = new LiveSyncService({
camoufoxWs: wsEndpoint,
domains,
skipInitialSync: opts.skipInitial,
onInitialSync: (stats) => {
console.log(chalk.green(`📦 Initial sync done: ${stats.cookies} cookies, ${stats.localStorage} localStorage entries`));
},
onSync: (event) => {
if (event.changeType === 'cookie' && event.cookie) {
const action = event.cookie.removed ? chalk.red('DEL') : chalk.green('SET');
console.log(`${chalk.dim(new Date().toLocaleTimeString())} ${action} cookie ${chalk.cyan(event.cookie.name)} @ ${event.domain}`);
}
if (event.changeType === 'localStorage' && event.storage) {
console.log(`${chalk.dim(new Date().toLocaleTimeString())} ${chalk.yellow('UPD')} localStorage ${chalk.cyan(event.storage.key)} @ ${event.domain}`);
}
},
onError: (err) => {
console.error(chalk.red(`Sync error: ${err.message}`));
},
});
console.log(chalk.cyan(`🔄 Live sync starting: Chrome → Camoufox`));
if (domains.length) console.log(chalk.dim(` Watching: ${domains.join(', ')}`));
else console.log(chalk.dim(` Watching: all domains`));
await service.start();
console.log(chalk.cyan('👀 Watching for changes... (Ctrl+C to stop)'));
// Keep running until Ctrl+C
process.on('SIGINT', async () => {
console.log(chalk.yellow('\n🛑 Stopping live sync...'));
const stats = service.getStats();
await service.stop();
console.log(chalk.dim(` Synced ${stats.cookies} cookies, ${stats.storage} storage changes, ${stats.errors} errors`));
process.exit(0);
});
});
// ── Built-in: camoufox lifecycle ──────────────────────────────────────────
const camoufoxCmd = program.command('camoufox').description('Manage Camoufox browser');
camoufoxCmd
.command('setup')
.description('Install Camoufox (requires Python)')
.action(async () => {
const { execSync } = await import('node:child_process');
try {
// Check Python
try {
execSync('python3 --version', { stdio: 'pipe' });
} catch {
console.error(chalk.red('Python 3 is required but not found. Install it first.'));
process.exitCode = 1;
return;
}
console.log(chalk.cyan('📦 Installing camoufox...'));
execSync('pip3 install -U camoufox', { stdio: 'inherit' });
console.log(chalk.cyan('📥 Fetching camoufox browser...'));
execSync('python3 -m camoufox fetch', { stdio: 'inherit' });
console.log(chalk.green('✅ Camoufox installed successfully!'));
console.log(chalk.dim(' Start with: opencli camoufox start'));
} catch (err: any) {
console.error(chalk.red(`Setup failed: ${err.message}`));
process.exitCode = 1;
}
});
camoufoxCmd
.command('start')
.description('Start Camoufox server')
.option('-p, --port <port>', 'WebSocket port')
.option('--no-headless', 'Run with visible GUI')
.option('--import <file>', 'Import state file after starting')
.action(async (opts) => {
const { spawn } = await import('node:child_process');
const path = await import('node:path');
const url = await import('node:url');
console.log(chalk.cyan(`🦊 Starting Camoufox server...`));
// Use our Python launcher script for reliable WS endpoint parsing
const scriptDir = path.dirname(url.fileURLToPath(import.meta.url));
const launcherScript = path.resolve(scriptDir, '..', 'scripts', 'camoufox_server.py');
const args = [launcherScript];
if (opts.headless) args.push('--headless');
if (opts.port) args.push('--port', opts.port);
const child = spawn('python3', args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
// Parse the first line of stdout — JSON with ws_endpoint
const wsEndpoint = await new Promise<string>((resolve, reject) => {
let output = '';
const timeout = setTimeout(() => reject(new Error('Camoufox failed to start within 30 seconds')), 30000);
child.stdout!.on('data', (chunk: Buffer) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const data = JSON.parse(trimmed);
clearTimeout(timeout);
if (data.error) { reject(new Error(data.error)); return; }
if (data.ws_endpoint) { resolve(data.ws_endpoint); return; }
} catch { /* not JSON yet, keep reading */ }
}
});
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
child.on('exit', (code) => {
clearTimeout(timeout);
if (code !== 0) reject(new Error(`Camoufox exited with code ${code}`));
});
});
child.unref();
console.log(chalk.green(`✅ Camoufox running at ${wsEndpoint}`));
console.log(chalk.dim(` Set: export OPENCLI_CAMOUFOX_WS=${wsEndpoint}`));
console.log(chalk.dim(` PID: ${child.pid}`));
// Import state if requested
if (opts.import) {
const fs = await import('node:fs');
if (fs.existsSync(opts.import)) {
const state = JSON.parse(fs.readFileSync(opts.import, 'utf-8'));
const { CamoufoxBridge } = await import('./browser/index.js');
process.env.OPENCLI_CAMOUFOX_WS = wsEndpoint;
const bridge = new CamoufoxBridge();
try {
const page = await bridge.connect({ workspace: 'camoufox:import' });
await page.importState(state);
console.log(chalk.green(`📥 State imported from ${opts.import}`));
} finally {
await bridge.close().catch(() => {});
}
} else {
console.error(chalk.yellow(`⚠️ State file not found: ${opts.import}`));
}
}
});
camoufoxCmd
.command('status')
.description('Check Camoufox server status')
.action(async () => {
const wsEndpoint = process.env.OPENCLI_CAMOUFOX_WS;
if (!wsEndpoint) {
console.log(chalk.yellow(`❌ OPENCLI_CAMOUFOX_WS is not set`));
console.log(chalk.dim(' Start with: opencli camoufox start'));
return;
}
try {
const { firefox } = await import('playwright-core');
const browser = await firefox.connect(wsEndpoint, { timeout: 3000 });
const version = browser.version();
await browser.close();
console.log(chalk.green(`✅ Camoufox running at ${wsEndpoint}`));
console.log(chalk.dim(` Browser version: ${version}`));
} catch {
console.log(chalk.yellow(`❌ Camoufox not reachable at ${wsEndpoint}`));
console.log(chalk.dim(' Restart with: opencli camoufox start'));
}
});
camoufoxCmd
.command('stop')
.description('Stop a running Camoufox server')
.action(async () => {
const { execSync } = await import('node:child_process');
try {
// Find camoufox server processes
const result = execSync("ps aux | grep 'camoufox_server\\|camoufox.*server' | grep -v grep", { encoding: 'utf-8' }).trim();
if (!result) {
console.log(chalk.yellow('No camoufox server process found'));
return;
}
const lines = result.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[1];
if (pid) {
try {
process.kill(parseInt(pid, 10), 'SIGTERM');
console.log(chalk.green(`✅ Stopped camoufox server (PID: ${pid})`));
} catch { /* already dead */ }
}
}
} catch {
console.log(chalk.yellow('No camoufox server process found'));
}
});
// ── Plugin management ──────────────────────────────────────────────────────
const pluginCmd = program.command('plugin').description('Manage opencli plugins');
@@ -33,6 +33,8 @@ function createPageMock(evaluateResult: any): IPage {
getInterceptedRequests: vi.fn().mockResolvedValue([]),
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
exportState: vi.fn().mockResolvedValue({}),
importState: vi.fn().mockResolvedValue(undefined),
};
}
@@ -37,6 +37,8 @@ function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): I
getInterceptedRequests,
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
exportState: vi.fn().mockResolvedValue({}),
importState: vi.fn().mockResolvedValue(undefined),
};
}
+39 -1
View File
@@ -27,6 +27,9 @@ const pending = new Map<string, {
}>();
let idleTimer: ReturnType<typeof setTimeout> | null = null;
// Sync subscribers: CLI processes connected via /sync WebSocket path
const syncSubscribers = new Set<WebSocket>();
// Extension log ring buffer
interface LogEntry { level: string; msg: string; ts: number; }
const LOG_BUFFER_SIZE = 200;
@@ -136,7 +139,31 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
// ─── WebSocket for Extension ─────────────────────────────────────────
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
const wss = new WebSocketServer({ noServer: true });
// Handle WebSocket upgrade — route /ext to extension, /sync to sync subscribers
httpServer.on('upgrade', (req, socket, head) => {
const pathname = req.url?.split('?')[0] ?? '/';
if (pathname === '/ext') {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
} else if (pathname === '/sync') {
wss.handleUpgrade(req, socket, head, (ws) => {
console.error('[daemon] Sync subscriber connected');
syncSubscribers.add(ws);
ws.on('close', () => {
console.error('[daemon] Sync subscriber disconnected');
syncSubscribers.delete(ws);
});
ws.on('error', () => syncSubscribers.delete(ws));
});
} else {
socket.destroy();
}
});
wss.on('connection', (ws: WebSocket) => {
console.error('[daemon] Extension connected');
@@ -154,6 +181,17 @@ wss.on('connection', (ws: WebSocket) => {
return;
}
// Handle state-change events — forward to all sync subscribers
if (msg.type === 'state-change') {
const payload = JSON.stringify(msg);
for (const sub of syncSubscribers) {
if (sub.readyState === WebSocket.OPEN) {
try { sub.send(payload); } catch { /* ignore */ }
}
}
return;
}
// Handle command results
const p = pending.get(msg.id);
if (p) {
+2
View File
@@ -30,6 +30,8 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
installInterceptor: vi.fn(),
getInterceptedRequests: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
exportState: vi.fn().mockResolvedValue({}),
importState: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
+2
View File
@@ -1,4 +1,5 @@
import { BrowserBridge, CDPBridge } from './browser/index.js';
import { CamoufoxBridge } from './browser/camoufox-bridge.js';
import type { IPage } from './types.js';
/**
@@ -6,6 +7,7 @@ import type { IPage } from './types.js';
* Uses CDPBridge when OPENCLI_CDP_ENDPOINT is set, otherwise BrowserBridge.
*/
export function getBrowserFactory(): new () => IBrowserFactory {
if (process.env.OPENCLI_CAMOUFOX_WS) return CamoufoxBridge as any;
return (process.env.OPENCLI_CDP_ENDPOINT ? CDPBridge : BrowserBridge) as any;
}
+26
View File
@@ -43,6 +43,30 @@ export interface BrowserSessionInfo {
[key: string]: unknown;
}
export interface IndexedDBObjectStore {
name: string;
keyPath: string | string[] | null;
autoIncrement: boolean;
records: Array<{ key: unknown; value: unknown }>;
}
export interface IndexedDBSnapshot {
name: string;
version: number;
objectStores: IndexedDBObjectStore[];
}
export interface BrowserState {
version: number;
url: string;
domain: string;
timestamp: number;
cookies: BrowserCookie[];
localStorage: Record<string, string>;
sessionStorage: Record<string, string>;
indexedDB: IndexedDBSnapshot[];
}
export interface IPage {
goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise<void>;
evaluate(js: string): Promise<any>;
@@ -65,4 +89,6 @@ export interface IPage {
installInterceptor(pattern: string): Promise<void>;
getInterceptedRequests(): Promise<any[]>;
screenshot(options?: ScreenshotOptions): Promise<string>;
exportState(opts?: { domain?: string }): Promise<BrowserState>;
importState(state: BrowserState): Promise<void>;
}