Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35ff2b65fb | |||
| 61b7a804f3 | |||
| f97e863e7a | |||
| 7d37ab3782 |
@@ -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
|
||||
Vendored
+268
-6
@@ -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);
|
||||
});
|
||||
|
||||
@@ -7,9 +7,13 @@
|
||||
"debugger",
|
||||
"tabs",
|
||||
"cookies",
|
||||
"storage",
|
||||
"activeTab",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "dist/background.js",
|
||||
"type": "module"
|
||||
|
||||
+268
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user