mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.74.0 - Chrome Bridge 浏览器自动化集成
🎉 重大功能 - Chrome Bridge: 完整的浏览器自动化控制系统 - 零配置自动连接:扩展自动加载和配置 - AI 原生集成:作为 MCP 工具支持自然语言控制 - 支持导航、页面读取、元素交互、表单操作等 🐛 Bug 修复 - WebSocket 路由修复 (Axum 路径参数语法) - Chrome 扩展存储清理 - 扩展重复注入防护 - 剪贴板权限添加 🔧 代码质量 - 修复 33+ Clippy 警告 - 所有 259 个测试通过 - ESLint 无警告 📝 文档 - Chrome Bridge 使用指南 - API 技术文档 - 快速参考卡片
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Proxycast Chrome Bridge 扩展
|
||||
|
||||
用于把 Chrome 页面能力接入 Proxycast 的浏览器桥接通道,供各业务 AI Agent 通过统一 `browser_execute_action` / MCP 浏览器工具调用。
|
||||
|
||||
## 功能
|
||||
|
||||
- Observer 通道自动连接:`/proxycast-chrome-observer/Proxycast_Key=...`
|
||||
- 页面信息上报:标题、URL、Markdown
|
||||
- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `go_back` 等
|
||||
- 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取
|
||||
|
||||
## 安装
|
||||
|
||||
1. 打开 Chrome `chrome://extensions`
|
||||
2. 打开右上角「开发者模式」
|
||||
3. 点击「加载已解压的扩展程序」
|
||||
4. 选择目录:`extensions/proxycast-chrome`
|
||||
|
||||
## 配置
|
||||
|
||||
点击扩展图标打开弹窗,配置:
|
||||
|
||||
- `Server URL`:Proxycast 服务地址,例如 `ws://127.0.0.1:8999`
|
||||
- `Bridge Key`:Proxycast 服务 API Key(与后端 `Proxycast_Key` 一致)
|
||||
- `Profile Key`:浏览器会话隔离键(建议与业务场景对应,如 `research_a`)
|
||||
|
||||
点击「保存并重连」后,扩展会建立 observer WebSocket 连接。
|
||||
|
||||
## 验证
|
||||
|
||||
1. 在 Proxycast 设置中查看 `get_chrome_bridge_status`,`observer_count` 应大于 0
|
||||
2. 调用 `browser_execute_action`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_key": "default",
|
||||
"action": "navigate",
|
||||
"args": { "url": "https://example.com" }
|
||||
}
|
||||
```
|
||||
|
||||
3. 再调用 `browser_execute_action`:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "read_page"
|
||||
}
|
||||
```
|
||||
|
||||
如果返回 `success=true` 且 `data.markdown` 有内容,说明链路可用。
|
||||
|
||||
## 自动化联调脚本
|
||||
|
||||
仓库提供了桥接链路的端到端联调脚本(模拟 observer/control 双端):
|
||||
|
||||
```bash
|
||||
npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default
|
||||
```
|
||||
|
||||
脚本会验证:
|
||||
|
||||
- observer/control 握手
|
||||
- 双向心跳 ack
|
||||
- `wait_for_page_info=true` 命令链路(`command_result` + `page_info_update`)
|
||||
- 普通命令链路(`command_result`)
|
||||
|
||||
## 兼容说明
|
||||
|
||||
- 扩展只负责浏览器侧采集与动作执行。
|
||||
- Agent 侧通过 `aster_agent_cmd` 与 `unified_chat_cmd` 注册的浏览器 MCP 兼容工具访问。
|
||||
- 若你同时使用独立 Chrome Profile(Tauri `open_chrome_profile_window`),请在对应 Profile 内安装该扩展,并使用不同 `profileKey` 做隔离。
|
||||
@@ -0,0 +1,667 @@
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const RECONNECT_MIN_DELAY_MS = 1000;
|
||||
const RECONNECT_MAX_DELAY_MS = 30000;
|
||||
const PAGE_CAPTURE_RETRY_LIMIT = 3;
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
serverUrl: "ws://127.0.0.1:8999",
|
||||
bridgeKey: "",
|
||||
profileKey: "default",
|
||||
monitoringEnabled: true,
|
||||
};
|
||||
|
||||
let ws = null;
|
||||
let isConnected = false;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer = null;
|
||||
let heartbeatTimer = null;
|
||||
let activeTabId = null;
|
||||
let monitoringEnabled = true;
|
||||
let latestPageInfo = null;
|
||||
let lastSettings = { ...DEFAULT_SETTINGS };
|
||||
|
||||
function logInfo(message, payload) {
|
||||
if (payload === undefined) {
|
||||
console.log(`[ProxycastBridge] ${message}`);
|
||||
} else {
|
||||
console.log(`[ProxycastBridge] ${message}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function logWarn(message, payload) {
|
||||
if (payload === undefined) {
|
||||
console.warn(`[ProxycastBridge] ${message}`);
|
||||
} else {
|
||||
console.warn(`[ProxycastBridge] ${message}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function readSettings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
|
||||
resolve({ ...DEFAULT_SETTINGS, ...stored });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function writeSettings(partial) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.set(partial, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function buildObserverUrl(settings) {
|
||||
const serverUrl = String(settings.serverUrl || "").trim();
|
||||
const bridgeKey = String(settings.bridgeKey || "").trim();
|
||||
if (!serverUrl || !bridgeKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = serverUrl.replace(/\/$/, "");
|
||||
const profileKey = encodeURIComponent(settings.profileKey || "default");
|
||||
return `${normalized}/proxycast-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`;
|
||||
}
|
||||
|
||||
async function connectObserver(forceReconnect = false) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
clearHeartbeatTimer();
|
||||
|
||||
const settings = await readSettings();
|
||||
lastSettings = settings;
|
||||
monitoringEnabled = Boolean(settings.monitoringEnabled);
|
||||
|
||||
const url = buildObserverUrl(settings);
|
||||
if (!url) {
|
||||
logWarn("缺少 serverUrl 或 bridgeKey,无法建立连接");
|
||||
setConnectionState(false);
|
||||
broadcastStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceReconnect && ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
logInfo(`连接 observer: ${url}`);
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
setConnectionState(true);
|
||||
startHeartbeat();
|
||||
broadcastStatus();
|
||||
triggerPageCapture("ws_open");
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
await handleObserverMessage(payload);
|
||||
} catch (error) {
|
||||
logWarn("解析消息失败", error?.message || String(error));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionState(false);
|
||||
clearHeartbeatTimer();
|
||||
scheduleReconnect();
|
||||
broadcastStatus();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
logWarn("WebSocket 错误", error?.message || error);
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectObserver(manual = true) {
|
||||
clearReconnectTimer();
|
||||
clearHeartbeatTimer();
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (manual) {
|
||||
setConnectionState(false);
|
||||
broadcastStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function setConnectionState(connected) {
|
||||
isConnected = connected;
|
||||
chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" });
|
||||
chrome.action.setBadgeBackgroundColor({ color: connected ? "#16a34a" : "#dc2626" });
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
clearHeartbeatTimer();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
sendObserverMessage({ type: "heartbeat", timestamp: Date.now() });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function clearHeartbeatTimer() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
const delay = Math.min(
|
||||
RECONNECT_MAX_DELAY_MS,
|
||||
RECONNECT_MIN_DELAY_MS * Math.pow(2, reconnectAttempts - 1),
|
||||
);
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null;
|
||||
await connectObserver();
|
||||
}, delay);
|
||||
logInfo(`连接断开,${delay}ms 后重连(第 ${reconnectAttempts} 次)`);
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendObserverMessage(payload) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
return true;
|
||||
}
|
||||
|
||||
function broadcastStatus(extra) {
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: "STATUS_UPDATE",
|
||||
data: {
|
||||
isConnected,
|
||||
monitoringEnabled,
|
||||
activeTabId,
|
||||
latestPageInfo,
|
||||
settings: {
|
||||
...lastSettings,
|
||||
bridgeKey: lastSettings.bridgeKey ? "***" : "",
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function handleObserverMessage(payload) {
|
||||
const type = payload?.type;
|
||||
if (type === "heartbeat_ack" || type === "connection_ack") {
|
||||
return;
|
||||
}
|
||||
if (type !== "command" || !payload.data) {
|
||||
return;
|
||||
}
|
||||
await executeRemoteCommand(payload.data);
|
||||
}
|
||||
|
||||
async function executeRemoteCommand(commandData) {
|
||||
const command = String(commandData.command || "").trim();
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = commandData.requestId;
|
||||
const sourceClientId = commandData.sourceClientId;
|
||||
const waitForPageInfo = commandData.wait_for_page_info === true;
|
||||
|
||||
if (command === "open_url") {
|
||||
await handleOpenUrl(commandData, waitForPageInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "switch_tab") {
|
||||
await handleSwitchTab(commandData, waitForPageInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = await resolveTargetTabId();
|
||||
if (!tabId) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: "没有可用的活动标签页",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendCommandToTab(tabId, {
|
||||
type: "EXECUTE_COMMAND",
|
||||
data: commandData,
|
||||
});
|
||||
|
||||
if (response?.status === "error") {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: response.error || "命令执行失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "success",
|
||||
message: response?.message || `${command} 执行成功`,
|
||||
});
|
||||
|
||||
if (waitForPageInfo || command === "get_page_info") {
|
||||
await triggerPageCapture("command_result");
|
||||
}
|
||||
} catch (error) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenUrl(commandData, waitForPageInfo) {
|
||||
const requestId = commandData.requestId;
|
||||
const sourceClientId = commandData.sourceClientId;
|
||||
let targetUrl = String(commandData.url || "").trim();
|
||||
if (!targetUrl) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: "open_url 缺少 url 参数",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(targetUrl)) {
|
||||
targetUrl = `https://${targetUrl}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await new Promise((resolve, reject) => {
|
||||
chrome.tabs.create({ url: targetUrl, active: true }, (created) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
resolve(created);
|
||||
});
|
||||
});
|
||||
|
||||
activeTabId = tab.id;
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "success",
|
||||
message: `已打开 ${targetUrl}`,
|
||||
});
|
||||
|
||||
if (waitForPageInfo) {
|
||||
await waitTabLoadComplete(tab.id, 30000);
|
||||
await triggerPageCapture("open_url_complete");
|
||||
}
|
||||
} catch (error) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchTab(commandData, waitForPageInfo) {
|
||||
const requestId = commandData.requestId;
|
||||
const sourceClientId = commandData.sourceClientId;
|
||||
const raw = String(commandData.target || "").trim();
|
||||
if (!raw) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: "switch_tab 缺少 target 参数",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTab = null;
|
||||
const byId = Number(raw);
|
||||
if (!Number.isNaN(byId) && byId > 0) {
|
||||
try {
|
||||
targetTab = await chrome.tabs.get(byId);
|
||||
} catch (_) {
|
||||
targetTab = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTab) {
|
||||
const tabs = await chrome.tabs.query({ currentWindow: true });
|
||||
const idx = Number(raw);
|
||||
if (!Number.isNaN(idx) && idx >= 0 && idx < tabs.length) {
|
||||
targetTab = tabs[idx];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTab || !targetTab.id) {
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "error",
|
||||
error: `未找到标签页: ${raw}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await chrome.tabs.update(targetTab.id, { active: true });
|
||||
activeTabId = targetTab.id;
|
||||
|
||||
sendCommandResult({
|
||||
requestId,
|
||||
sourceClientId,
|
||||
status: "success",
|
||||
message: `已切换到标签页 ${targetTab.id}`,
|
||||
});
|
||||
|
||||
if (waitForPageInfo) {
|
||||
await triggerPageCapture("switch_tab");
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommandResult(data) {
|
||||
sendObserverMessage({
|
||||
type: "command_result",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveTargetTabId() {
|
||||
if (activeTabId) {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(activeTabId);
|
||||
if (tab && !tab.discarded) {
|
||||
return tab.id;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tabs[0]?.id) {
|
||||
activeTabId = tabs[0].id;
|
||||
return tabs[0].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendCommandToTab(tabId, payload) {
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tabId, payload);
|
||||
} catch (error) {
|
||||
await injectContentScript(tabId);
|
||||
return await chrome.tabs.sendMessage(tabId, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectContentScript(tabId) {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ["content_script.js"],
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerPageCapture(reason, retry = 0) {
|
||||
if (!monitoringEnabled && reason !== "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = await resolveTargetTabId();
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendCommandToTab(tabId, {
|
||||
type: "REQUEST_PAGE_CAPTURE",
|
||||
data: { reason },
|
||||
});
|
||||
} catch (error) {
|
||||
if (retry < PAGE_CAPTURE_RETRY_LIMIT) {
|
||||
setTimeout(() => {
|
||||
triggerPageCapture(reason, retry + 1);
|
||||
}, 250 * (retry + 1));
|
||||
} else {
|
||||
logWarn("页面抓取请求失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitTabLoadComplete(tabId, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
chrome.tabs.onUpdated.removeListener(onUpdated);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
const onUpdated = (updatedTabId, changeInfo) => {
|
||||
if (updatedTabId !== tabId || changeInfo.status !== "complete") {
|
||||
return;
|
||||
}
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
chrome.tabs.onUpdated.removeListener(onUpdated);
|
||||
resolve();
|
||||
};
|
||||
|
||||
chrome.tabs.onUpdated.addListener(onUpdated);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
const type = request?.type;
|
||||
|
||||
if (type === "GET_STATUS") {
|
||||
sendResponse({
|
||||
isConnected,
|
||||
monitoringEnabled,
|
||||
activeTabId,
|
||||
latestPageInfo,
|
||||
settings: {
|
||||
...lastSettings,
|
||||
bridgeKey: lastSettings.bridgeKey ? "***" : "",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "UPDATE_SETTINGS") {
|
||||
const patch = request?.data || {};
|
||||
const next = {
|
||||
serverUrl: typeof patch.serverUrl === "string" ? patch.serverUrl : lastSettings.serverUrl,
|
||||
bridgeKey: typeof patch.bridgeKey === "string" ? patch.bridgeKey : lastSettings.bridgeKey,
|
||||
profileKey: typeof patch.profileKey === "string" ? patch.profileKey : lastSettings.profileKey,
|
||||
monitoringEnabled:
|
||||
typeof patch.monitoringEnabled === "boolean"
|
||||
? patch.monitoringEnabled
|
||||
: monitoringEnabled,
|
||||
};
|
||||
|
||||
writeSettings(next).then(async () => {
|
||||
lastSettings = { ...lastSettings, ...next };
|
||||
monitoringEnabled = Boolean(next.monitoringEnabled);
|
||||
if (request?.data?.reconnect === true) {
|
||||
await connectObserver(true);
|
||||
}
|
||||
broadcastStatus();
|
||||
sendResponse({ success: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "TOGGLE_CONNECTION") {
|
||||
if (isConnected) {
|
||||
disconnectObserver(true);
|
||||
sendResponse({ success: true, isConnected: false });
|
||||
} else {
|
||||
connectObserver().then(() => {
|
||||
sendResponse({ success: true, isConnected: isConnected });
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "TOGGLE_MONITORING") {
|
||||
monitoringEnabled = !monitoringEnabled;
|
||||
writeSettings({ monitoringEnabled }).then(() => {
|
||||
if (monitoringEnabled) {
|
||||
triggerPageCapture("manual");
|
||||
}
|
||||
broadcastStatus();
|
||||
sendResponse({ success: true, monitoringEnabled });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "REQUEST_PAGE_CAPTURE") {
|
||||
triggerPageCapture("manual").then(() => {
|
||||
sendResponse({ success: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "PAGE_INFO_UPDATE") {
|
||||
const senderTabId = sender?.tab?.id;
|
||||
if (senderTabId && activeTabId && senderTabId !== activeTabId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const markdown = request?.data?.markdown;
|
||||
if (typeof markdown !== "string" || !markdown.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
latestPageInfo = {
|
||||
title: request?.data?.title || "",
|
||||
url: request?.data?.url || "",
|
||||
timestamp: Date.now(),
|
||||
markdown,
|
||||
};
|
||||
|
||||
chrome.storage.local.set({ latestPageInfo });
|
||||
sendObserverMessage({
|
||||
type: "pageInfoUpdate",
|
||||
data: { markdown },
|
||||
});
|
||||
broadcastStatus({ latestPageInfo });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "COMMAND_RESULT") {
|
||||
if (request?.data) {
|
||||
sendCommandResult(request.data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
activeTabId = tabId;
|
||||
await triggerPageCapture("tab_activated");
|
||||
broadcastStatus();
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
if (tab.active) {
|
||||
activeTabId = tabId;
|
||||
}
|
||||
if (tab.active && changeInfo.status === "complete") {
|
||||
await triggerPageCapture("tab_updated");
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAutoConfig() {
|
||||
try {
|
||||
const configUrl = chrome.runtime.getURL("auto_config.json");
|
||||
logInfo(`尝试加载自动配置: ${configUrl}`);
|
||||
const response = await fetch(configUrl);
|
||||
logInfo(`fetch 响应状态: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
logWarn(`自动配置文件不存在或无法访问: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const config = await response.json();
|
||||
logInfo("成功读取自动配置", config);
|
||||
if (config.serverUrl && config.bridgeKey) {
|
||||
logInfo("检测到自动配置,正在应用...", config);
|
||||
await writeSettings({
|
||||
serverUrl: config.serverUrl,
|
||||
bridgeKey: config.bridgeKey,
|
||||
profileKey: config.profileKey || "default",
|
||||
monitoringEnabled: config.monitoringEnabled !== false,
|
||||
});
|
||||
logInfo("自动配置已应用");
|
||||
} else {
|
||||
logWarn("自动配置缺少必要字段", config);
|
||||
}
|
||||
} catch (error) {
|
||||
// 文件不存在或解析失败时记录错误
|
||||
logWarn("加载自动配置失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadAutoConfig();
|
||||
|
||||
const settings = await readSettings();
|
||||
lastSettings = settings;
|
||||
monitoringEnabled = Boolean(settings.monitoringEnabled);
|
||||
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
activeTabId = tabs[0]?.id || null;
|
||||
|
||||
if (settings.serverUrl && settings.bridgeKey) {
|
||||
await connectObserver();
|
||||
} else {
|
||||
setConnectionState(false);
|
||||
broadcastStatus();
|
||||
}
|
||||
|
||||
chrome.storage.local.get(["latestPageInfo"], (stored) => {
|
||||
if (stored.latestPageInfo) {
|
||||
latestPageInfo = stored.latestPageInfo;
|
||||
broadcastStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,300 @@
|
||||
// 使用 IIFE 避免重复注入时的变量冲突
|
||||
(function () {
|
||||
// 检查是否已经注入过
|
||||
if (window.__PROXYCAST_CONTENT_SCRIPT_LOADED__) {
|
||||
return;
|
||||
}
|
||||
window.__PROXYCAST_CONTENT_SCRIPT_LOADED__ = true;
|
||||
|
||||
let refCounter = 0;
|
||||
const REF_ATTR = "proxycast-id";
|
||||
|
||||
function nextRefId() {
|
||||
refCounter += 1;
|
||||
return `proxycast-${refCounter}`;
|
||||
}
|
||||
|
||||
function resetRefs() {
|
||||
refCounter = 0;
|
||||
document.querySelectorAll(`[${REF_ATTR}]`).forEach((el) => {
|
||||
el.removeAttribute(REF_ATTR);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isElementVisible(element) {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.opacity === "0"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function isInteractiveElement(element) {
|
||||
if (!element || !isElementVisible(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const role = element.getAttribute("role");
|
||||
if (["a", "button", "input", "textarea", "select", "option"].includes(tag)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
role &&
|
||||
[
|
||||
"button",
|
||||
"link",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"menuitem",
|
||||
"tab",
|
||||
"switch",
|
||||
"option",
|
||||
"searchbox",
|
||||
"textbox",
|
||||
"combobox",
|
||||
].includes(role)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.hasAttribute("onclick")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.cursor === "pointer";
|
||||
}
|
||||
|
||||
function interactiveLabel(element) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const role = element.getAttribute("role");
|
||||
const text = normalizeText(
|
||||
element.innerText ||
|
||||
element.value ||
|
||||
element.placeholder ||
|
||||
element.getAttribute("aria-label") ||
|
||||
element.title ||
|
||||
element.name ||
|
||||
element.id,
|
||||
);
|
||||
|
||||
if (tag === "a") {
|
||||
return `链接: ${text || "无标题链接"}`;
|
||||
}
|
||||
if (tag === "button" || role === "button") {
|
||||
return `按钮: ${text || "无标题按钮"}`;
|
||||
}
|
||||
if (tag === "input" || tag === "textarea") {
|
||||
return `输入框: ${text || "未命名输入框"}`;
|
||||
}
|
||||
if (tag === "select") {
|
||||
return `下拉框: ${text || "未命名下拉框"}`;
|
||||
}
|
||||
return `可交互元素: ${text || tag}`;
|
||||
}
|
||||
|
||||
function buildMarkdown() {
|
||||
resetRefs();
|
||||
|
||||
const lines = [];
|
||||
lines.push(`# ${document.title || "Untitled"}`);
|
||||
lines.push(`URL: ${window.location.href}`);
|
||||
lines.push("");
|
||||
|
||||
const bodyText = normalizeText(document.body ? document.body.innerText : "");
|
||||
if (bodyText) {
|
||||
lines.push("## 页面文本");
|
||||
lines.push(bodyText.slice(0, 6000));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## 可交互元素");
|
||||
const allElements = Array.from(document.querySelectorAll("*")).filter(isInteractiveElement);
|
||||
|
||||
for (const element of allElements.slice(0, 300)) {
|
||||
const refId = nextRefId();
|
||||
element.setAttribute(REF_ATTR, refId);
|
||||
lines.push(`- [${interactiveLabel(element)}](${refId})`);
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function findElement(target) {
|
||||
if (!target || typeof target !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let element = document.querySelector(`[${REF_ATTR}="${CSS.escape(trimmed)}"]`);
|
||||
if (element) {
|
||||
return element;
|
||||
}
|
||||
|
||||
try {
|
||||
element = document.querySelector(trimmed);
|
||||
if (element) {
|
||||
return element;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
element = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button']")).find(
|
||||
(candidate) => normalizeText(candidate.innerText || candidate.value || candidate.placeholder || "") === trimmed,
|
||||
);
|
||||
if (element) {
|
||||
return element;
|
||||
}
|
||||
|
||||
element = Array.from(document.querySelectorAll("[aria-label]")).find(
|
||||
(candidate) => normalizeText(candidate.getAttribute("aria-label")) === trimmed,
|
||||
);
|
||||
return element || null;
|
||||
}
|
||||
|
||||
function scrollPage(text) {
|
||||
const input = normalizeText(text);
|
||||
let direction = "down";
|
||||
let amount = 500;
|
||||
|
||||
if (input.includes(":")) {
|
||||
const parts = input.split(":");
|
||||
direction = normalizeText(parts[0]) || "down";
|
||||
const parsed = Number(parts[1]);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) {
|
||||
amount = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "up") {
|
||||
window.scrollBy(0, -amount);
|
||||
} else if (direction === "left") {
|
||||
window.scrollBy(-amount, 0);
|
||||
} else if (direction === "right") {
|
||||
window.scrollBy(amount, 0);
|
||||
} else {
|
||||
window.scrollBy(0, amount);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommand(commandData) {
|
||||
const command = String(commandData.command || "").trim();
|
||||
const target = commandData.target;
|
||||
const text = commandData.text;
|
||||
|
||||
switch (command) {
|
||||
case "click": {
|
||||
const element = findElement(target);
|
||||
if (!element) {
|
||||
return { status: "error", error: `未找到点击目标: ${target}` };
|
||||
}
|
||||
element.click();
|
||||
return { status: "success", message: "click 执行成功" };
|
||||
}
|
||||
case "type": {
|
||||
const element = findElement(target);
|
||||
if (!element) {
|
||||
return { status: "error", error: `未找到输入目标: ${target}` };
|
||||
}
|
||||
const value = text == null ? "" : String(text);
|
||||
if ("value" in element) {
|
||||
element.focus();
|
||||
element.value = value;
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
} else {
|
||||
element.textContent = value;
|
||||
}
|
||||
return { status: "success", message: "type 执行成功" };
|
||||
}
|
||||
case "scroll":
|
||||
case "scroll_page": {
|
||||
scrollPage(text);
|
||||
return { status: "success", message: "scroll 执行成功" };
|
||||
}
|
||||
case "get_page_info": {
|
||||
await sendPageInfo("get_page_info");
|
||||
return { status: "success", message: "页面信息已回传" };
|
||||
}
|
||||
case "refresh_page": {
|
||||
window.location.reload();
|
||||
return { status: "success", message: "页面刷新中" };
|
||||
}
|
||||
case "go_back": {
|
||||
window.history.back();
|
||||
return { status: "success", message: "执行后退" };
|
||||
}
|
||||
case "go_forward": {
|
||||
window.history.forward();
|
||||
return { status: "success", message: "执行前进" };
|
||||
}
|
||||
default:
|
||||
return { status: "error", error: `不支持的命令: ${command}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPageInfo(reason) {
|
||||
const markdown = buildMarkdown();
|
||||
const payload = {
|
||||
type: "PAGE_INFO_UPDATE",
|
||||
data: {
|
||||
reason,
|
||||
title: document.title || "",
|
||||
url: window.location.href,
|
||||
markdown,
|
||||
},
|
||||
};
|
||||
await chrome.runtime.sendMessage(payload);
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
const type = request?.type;
|
||||
|
||||
if (type === "REQUEST_PAGE_CAPTURE") {
|
||||
sendPageInfo(request?.data?.reason || "manual")
|
||||
.then(() => sendResponse({ status: "success" }))
|
||||
.catch((error) =>
|
||||
sendResponse({ status: "error", error: error?.message || String(error) }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === "EXECUTE_COMMAND") {
|
||||
executeCommand(request?.data || {})
|
||||
.then(async (result) => {
|
||||
if (request?.data?.wait_for_page_info === true) {
|
||||
setTimeout(() => {
|
||||
sendPageInfo("wait_for_page_info");
|
||||
}, 400);
|
||||
}
|
||||
sendResponse(result);
|
||||
})
|
||||
.catch((error) =>
|
||||
sendResponse({ status: "error", error: error?.message || String(error) }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
sendPageInfo("content_script_ready").catch(() => {});
|
||||
}, 800);
|
||||
|
||||
})(); // 结束 IIFE
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Proxycast Browser Bridge",
|
||||
"description": "Connect Chrome tabs with Proxycast browser runtime for AI agents.",
|
||||
"version": "0.1.0",
|
||||
"permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Proxycast Browser Bridge",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content_script.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Proxycast Browser Bridge</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
width: 420px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
background: #f9fafb;
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-on {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.badge-off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
.quick-config {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.quick-config-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #1e40af;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-config-hint {
|
||||
font-size: 11px;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-config-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.endpoint-preview {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: white;
|
||||
color: #374151;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.page-info {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.page-info-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #111;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.page-info-url {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Proxycast Browser Bridge</h1>
|
||||
<p>连接 Chrome 与 Proxycast 服务</p>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span class="status-label">Observer 连接:</span>
|
||||
<span id="bridgeStatus" class="badge badge-off">未连接</span>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span class="status-label">页面监控:</span>
|
||||
<span id="monitorStatus" class="badge badge-off">关闭</span>
|
||||
</div>
|
||||
|
||||
<div class="quick-config">
|
||||
<div class="quick-config-title">🚀 一键配置</div>
|
||||
<div class="quick-config-hint">从剪贴板粘贴配置(在 ProxyCast 页面点击"复制配置")</div>
|
||||
<div class="quick-config-buttons">
|
||||
<button id="pasteConfigBtn" class="btn btn-small">粘贴配置</button>
|
||||
<button id="clearConfigBtn" class="btn btn-small">清除配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="serverUrl">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
class="form-input"
|
||||
placeholder="ws://127.0.0.1:8999"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bridgeKey">Bridge Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bridgeKey"
|
||||
class="form-input"
|
||||
placeholder="输入 Proxycast API Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="profileKey">Profile Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="profileKey"
|
||||
class="form-input"
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="endpoint-preview" id="endpointPreview">
|
||||
Observer URL: 未配置
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="saveBtn" class="btn btn-primary">保存并重连</button>
|
||||
<button id="toggleConnBtn" class="btn">断开</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="toggleMonitorBtn" class="btn">切换监控</button>
|
||||
<button id="captureBtn" class="btn">立即抓取</button>
|
||||
</div>
|
||||
|
||||
<div class="page-info">
|
||||
<div class="page-info-title" id="pageTitle">无页面信息</div>
|
||||
<div class="page-info-url" id="pageUrl"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,249 @@
|
||||
const DEFAULT_SETTINGS = {
|
||||
serverUrl: "ws://127.0.0.1:8999",
|
||||
bridgeKey: "",
|
||||
profileKey: "default",
|
||||
monitoringEnabled: true,
|
||||
};
|
||||
|
||||
const bridgeStatusEl = document.getElementById("bridgeStatus");
|
||||
const monitorStatusEl = document.getElementById("monitorStatus");
|
||||
const endpointPreviewEl = document.getElementById("endpointPreview");
|
||||
|
||||
const serverUrlEl = document.getElementById("serverUrl");
|
||||
const bridgeKeyEl = document.getElementById("bridgeKey");
|
||||
const profileKeyEl = document.getElementById("profileKey");
|
||||
|
||||
const saveBtnEl = document.getElementById("saveBtn");
|
||||
const toggleConnBtnEl = document.getElementById("toggleConnBtn");
|
||||
const toggleMonitorBtnEl = document.getElementById("toggleMonitorBtn");
|
||||
const captureBtnEl = document.getElementById("captureBtn");
|
||||
|
||||
const pageTitleEl = document.getElementById("pageTitle");
|
||||
const pageUrlEl = document.getElementById("pageUrl");
|
||||
|
||||
function setBadge(el, isOn, onText, offText) {
|
||||
el.textContent = isOn ? onText : offText;
|
||||
el.className = `badge ${isOn ? "badge-on" : "badge-off"}`;
|
||||
}
|
||||
|
||||
function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
|
||||
const base = String(serverUrl || "").trim().replace(/\/$/, "");
|
||||
const key = String(bridgeKey || "").trim();
|
||||
const profile = encodeURIComponent(String(profileKey || "default").trim() || "default");
|
||||
if (!base || !key) {
|
||||
return "Observer URL: 未配置";
|
||||
}
|
||||
return `Observer URL: ${base}/proxycast-chrome-observer/Proxycast_Key=${encodeURIComponent(key)}?profileKey=${profile}`;
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
const connected = Boolean(status?.isConnected);
|
||||
const monitoring = Boolean(status?.monitoringEnabled);
|
||||
setBadge(bridgeStatusEl, connected, "已连接", "未连接");
|
||||
setBadge(monitorStatusEl, monitoring, "开启", "关闭");
|
||||
|
||||
const latestPageInfo = status?.latestPageInfo;
|
||||
if (latestPageInfo?.title || latestPageInfo?.url) {
|
||||
pageTitleEl.textContent = latestPageInfo.title || "无标题";
|
||||
pageUrlEl.textContent = latestPageInfo.url || "";
|
||||
}
|
||||
|
||||
const settings = status?.settings;
|
||||
if (settings) {
|
||||
if (typeof settings.serverUrl === "string" && settings.serverUrl) {
|
||||
serverUrlEl.value = settings.serverUrl;
|
||||
}
|
||||
if (typeof settings.profileKey === "string" && settings.profileKey) {
|
||||
profileKeyEl.value = settings.profileKey;
|
||||
}
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
settings.serverUrl,
|
||||
bridgeKeyEl.value,
|
||||
settings.profileKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readStoredSettings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(Object.keys(DEFAULT_SETTINGS), (stored) => {
|
||||
resolve({ ...DEFAULT_SETTINGS, ...stored });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInitialState() {
|
||||
const settings = await readStoredSettings();
|
||||
serverUrlEl.value = settings.serverUrl;
|
||||
bridgeKeyEl.value = settings.bridgeKey;
|
||||
profileKeyEl.value = settings.profileKey;
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
settings.serverUrl,
|
||||
settings.bridgeKey,
|
||||
settings.profileKey,
|
||||
);
|
||||
|
||||
try {
|
||||
const status = await sendMessage({ type: "GET_STATUS" });
|
||||
applyStatus(status || {});
|
||||
} catch (error) {
|
||||
console.warn("[ProxycastBridgePopup] 获取状态失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndReconnect() {
|
||||
const payload = {
|
||||
serverUrl: serverUrlEl.value.trim(),
|
||||
bridgeKey: bridgeKeyEl.value.trim(),
|
||||
profileKey: profileKeyEl.value.trim() || "default",
|
||||
reconnect: true,
|
||||
};
|
||||
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
payload.serverUrl,
|
||||
payload.bridgeKey,
|
||||
payload.profileKey,
|
||||
);
|
||||
|
||||
saveBtnEl.disabled = true;
|
||||
const originalText = saveBtnEl.textContent;
|
||||
saveBtnEl.textContent = "保存中...";
|
||||
|
||||
try {
|
||||
await sendMessage({ type: "UPDATE_SETTINGS", data: payload });
|
||||
saveBtnEl.textContent = "已保存";
|
||||
setTimeout(() => {
|
||||
saveBtnEl.textContent = originalText;
|
||||
saveBtnEl.disabled = false;
|
||||
}, 900);
|
||||
} catch (error) {
|
||||
saveBtnEl.textContent = "保存失败";
|
||||
setTimeout(() => {
|
||||
saveBtnEl.textContent = originalText;
|
||||
saveBtnEl.disabled = false;
|
||||
}, 1200);
|
||||
console.warn("[ProxycastBridgePopup] 保存设置失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleConnection() {
|
||||
try {
|
||||
await sendMessage({ type: "TOGGLE_CONNECTION" });
|
||||
} catch (error) {
|
||||
console.warn("[ProxycastBridgePopup] 切换连接失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMonitoring() {
|
||||
try {
|
||||
await sendMessage({ type: "TOGGLE_MONITORING" });
|
||||
} catch (error) {
|
||||
console.warn("[ProxycastBridgePopup] 切换监控失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function capturePageNow() {
|
||||
captureBtnEl.disabled = true;
|
||||
const originalText = captureBtnEl.textContent;
|
||||
captureBtnEl.textContent = "抓取中...";
|
||||
|
||||
try {
|
||||
await sendMessage({ type: "REQUEST_PAGE_CAPTURE" });
|
||||
} catch (error) {
|
||||
console.warn("[ProxycastBridgePopup] 请求抓取失败", error?.message || String(error));
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
captureBtnEl.textContent = originalText;
|
||||
captureBtnEl.disabled = false;
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteConfigFromClipboard() {
|
||||
const pasteBtn = document.getElementById("pasteConfigBtn");
|
||||
pasteBtn.disabled = true;
|
||||
const originalText = pasteBtn.textContent;
|
||||
pasteBtn.textContent = "粘贴中...";
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const config = JSON.parse(text);
|
||||
|
||||
if (config.serverUrl) {
|
||||
serverUrlEl.value = config.serverUrl;
|
||||
}
|
||||
if (config.bridgeKey) {
|
||||
bridgeKeyEl.value = config.bridgeKey;
|
||||
}
|
||||
if (config.profileKey) {
|
||||
profileKeyEl.value = config.profileKey;
|
||||
}
|
||||
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
serverUrlEl.value,
|
||||
bridgeKeyEl.value,
|
||||
profileKeyEl.value,
|
||||
);
|
||||
|
||||
pasteBtn.textContent = "已粘贴";
|
||||
setTimeout(() => {
|
||||
pasteBtn.textContent = originalText;
|
||||
pasteBtn.disabled = false;
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
pasteBtn.textContent = "粘贴失败";
|
||||
setTimeout(() => {
|
||||
pasteBtn.textContent = originalText;
|
||||
pasteBtn.disabled = false;
|
||||
}, 1500);
|
||||
console.warn("[ProxycastBridgePopup] 粘贴配置失败", error?.message || String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function clearConfig() {
|
||||
serverUrlEl.value = DEFAULT_SETTINGS.serverUrl;
|
||||
bridgeKeyEl.value = "";
|
||||
profileKeyEl.value = DEFAULT_SETTINGS.profileKey;
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
serverUrlEl.value,
|
||||
bridgeKeyEl.value,
|
||||
profileKeyEl.value,
|
||||
);
|
||||
}
|
||||
|
||||
saveBtnEl.addEventListener("click", saveAndReconnect);
|
||||
toggleConnBtnEl.addEventListener("click", toggleConnection);
|
||||
toggleMonitorBtnEl.addEventListener("click", toggleMonitoring);
|
||||
captureBtnEl.addEventListener("click", capturePageNow);
|
||||
document.getElementById("pasteConfigBtn").addEventListener("click", pasteConfigFromClipboard);
|
||||
document.getElementById("clearConfigBtn").addEventListener("click", clearConfig);
|
||||
|
||||
for (const input of [serverUrlEl, bridgeKeyEl, profileKeyEl]) {
|
||||
input.addEventListener("input", () => {
|
||||
endpointPreviewEl.textContent = buildObserverEndpoint(
|
||||
serverUrlEl.value,
|
||||
bridgeKeyEl.value,
|
||||
profileKeyEl.value,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((request) => {
|
||||
if (request?.type === "STATUS_UPDATE") {
|
||||
applyStatus(request?.data || {});
|
||||
}
|
||||
});
|
||||
|
||||
loadInitialState();
|
||||
Reference in New Issue
Block a user