mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-28 03:43:32 +08:00
chore: release v0.99.0
This commit is contained in:
+17
-19
@@ -1,41 +1,39 @@
|
||||
## Lime v0.98.0
|
||||
## Lime v0.99.0
|
||||
|
||||
### ✨ 主要更新
|
||||
|
||||
- **Chrome Relay / Browser Connector 进入 current 主线**:新增浏览器连接器命令与后端服务,设置页补齐 Chrome Relay 安装、连接状态和目录展示,扩展弹窗与清单同步更新
|
||||
- **Agent Chat 工作台与时间线继续收口**:`ArtifactWorkbenchShell`、`AgentThreadTimeline`、`CanvasWorkbenchLayout`、`MessageList` 等核心区域完成进一步瘦身,移除了旧的 `ProjectSelector`、`TaskFiles`、`TimelineInlineItem` 等遗留表面
|
||||
- **A2UI、Service Skill 与工作台运行时稳定性继续加固**:修正 legacy 问卷场景下 `useWorkspaceA2UIRuntime` 的自循环更新,补齐 Action Request / A2UI 预览与画布布局回归,并继续收敛 `site adapter`、`Browser Assist`、`current content` 写回主链
|
||||
- **站点适配器与运行时网关进一步收口**:`site_*` 能力统一回挂到现役 `agentRuntime` 网关,减少业务层散落命令名;配合 `Service Skill` 启动、运行与保存链路,前端命令边界和契约口径保持一致
|
||||
- **测试执行与发布流程继续稳定化**:Vitest 默认切到智能分批 + 单 fork 模式,收敛 mock/info 日志风暴;macOS release workflow 会在构建前显式探测 Tauri CLI native binding,缺失时自动清理 `node_modules` 并重装,规避 `@tauri-apps/cli-darwin-*` 丢失导致的发布失败;本轮也修复了 DevBridge SSE 路由的 axum handler 编译断点,避免发布前 Rust 校验被阻塞
|
||||
- **共享网关控制面继续收口**:移除了遗留 `api-server` / `route` 相关前端页面、命令与后端路由表残面,server 侧进一步收敛到当前主路径,托盘与运行时状态同步逻辑也同步减面
|
||||
- **Browser Connector / Chrome Relay 主链继续稳定化**:浏览器连接器、扩展安装状态、目录展示、调试面板与设置页联动进一步补齐,Chrome 扩展端清单、弹窗与后台脚本同步更新
|
||||
- **Agent Chat 与 Service Skill 工作台继续加固**:`AgentChatHomeShell`、`AgentChatWorkspace`、`useWorkspaceSendActions`、`siteCapabilityBinding`、`Browser Assist` 等链路继续收敛,Service Skill 启动、自动匹配、浏览器预检与制品预览主链一致性更高
|
||||
- **设置与导航表面继续收口**:系统设置分组、渠道配置工作台、外观入口、侧边栏能力分组与创作主题默认显示策略完成新一轮整理,减少未开放或遗留入口的默认暴露
|
||||
- **工程验证与文档同步**:命令边界、Playwright / GUI smoke、质量工作流与 Chrome 扩展说明同步更新,为 `v0.99.0` 发布提供统一事实源
|
||||
|
||||
### ⚠️ 兼容性说明
|
||||
|
||||
- 正式发布由 `v0.98.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文
|
||||
- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;目标环境如果缺少对应条件,Browser Runtime/站点适配器相关能力会被降级
|
||||
- 正式发布由 `v0.99.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文
|
||||
- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;若目标环境缺少对应条件,Browser Runtime / 站点适配器相关能力会降级或无法完成验证
|
||||
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
|
||||
|
||||
### 🔗 依赖同步
|
||||
|
||||
- 应用版本已同步提升到 `v0.98.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
|
||||
- 当前仓库声明的 `aster-rust` 依赖已同步到 `v0.23.0`;本地覆盖仓库也应保持在干净 `v0.23.0` 状态
|
||||
- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.98.0` 对齐
|
||||
- 应用版本已同步提升到 `v0.99.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
|
||||
- 当前仓库声明的 `aster-rust` 依赖保持在 `v0.23.0`
|
||||
- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.99.0` 对齐
|
||||
|
||||
### 🧪 测试
|
||||
|
||||
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all`
|
||||
- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo test --manifest-path src-tauri/Cargo.toml`
|
||||
- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo clippy --manifest-path src-tauri/Cargo.toml`
|
||||
- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml`
|
||||
- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml`
|
||||
- 发布前执行:`npm run lint`
|
||||
- 发布前执行:`npm run verify:app-version`
|
||||
- 发布前执行:`npm run test:contracts`
|
||||
- 发布前执行:`npm run verify:gui-smoke`
|
||||
- 验证结果:上述命令已在当前工作区通过;Rust 校验使用独立 `CARGO_TARGET_DIR` 完成,以避免与本机其他 Cargo 进程争抢默认构建目录;GUI smoke 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径
|
||||
- 验证结果:以本次实际执行结果为准;如果当前工作区包含未收口的并行改动,需要在发布前重新确认 Rust / 前端校验与 GUI 冒烟结论
|
||||
|
||||
### 📝 文档
|
||||
|
||||
- 发布说明已切换到当前这次 `v0.98.0` 正式发布内容,供 GitHub Release 直接读取
|
||||
- 工程质量、命令边界与 Playwright / GUI 冒烟文档已同步更新到最新实现
|
||||
- 发布说明已切换到当前这次 `v0.99.0` 正式发布内容,供 GitHub Release 直接读取
|
||||
- 工程质量、命令边界、Playwright / GUI 冒烟与扩展说明已围绕当前实现持续同步
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: `v0.97.0` -> `v0.98.0`
|
||||
**完整变更**: `v0.98.0` -> `v0.99.0`
|
||||
|
||||
@@ -30,9 +30,11 @@
|
||||
- `set_browser_connector_install_root_cmd`
|
||||
- `set_browser_connector_enabled_cmd`
|
||||
- `set_system_connector_enabled_cmd`
|
||||
- `set_browser_action_capability_enabled_cmd`
|
||||
- `get_browser_connector_install_status_cmd`
|
||||
- `install_browser_connector_extension_cmd`
|
||||
- `open_browser_extensions_page_cmd`
|
||||
- `open_browser_remote_debugging_page_cmd`
|
||||
- `disconnect_browser_connector_session`
|
||||
|
||||
这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。
|
||||
@@ -99,19 +101,21 @@
|
||||
// src/lib/api/serverRuntime.ts
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export async function getServerStatus() {
|
||||
return safeInvoke<ServerStatus>("get_server_status");
|
||||
export async function getServerDiagnostics() {
|
||||
return safeInvoke<ServerDiagnostics>("get_server_diagnostics");
|
||||
}
|
||||
```
|
||||
|
||||
业务层只消费网关:
|
||||
|
||||
```typescript
|
||||
import { getServerStatus } from "@/lib/api/serverRuntime";
|
||||
import { getServerDiagnostics } from "@/lib/api/serverRuntime";
|
||||
|
||||
const status = await getServerStatus();
|
||||
const diagnostics = await getServerDiagnostics();
|
||||
```
|
||||
|
||||
共享网关控制面已下线后,`start_server`、`stop_server`、`get_server_status`、`get_available_routes`、`get_route_curl_examples`、`test_api`、`get_network_info`,以及托盘残留 `sync_tray_state`、`update_tray_server_status`、`update_tray_credential_status`、`get_tray_state`、`refresh_tray_menu`、`refresh_tray_with_stats` 都应视为 `dead` 候选,不应重新接回前端主路径;server 兼容面 `/v1/routes`、`/{selector}/v1/messages`、`/{selector}/v1/chat/completions` 也应视为 `dead` 候选,不应重新接回本地共享网关主链;开发者诊断统一继续走 `get_server_diagnostics`,托盘只保留 `sync_tray_model_shortcuts`,server 只保留标准 `/v1/messages` 与 `/v1/chat/completions`。
|
||||
|
||||
### 3. Rust 命令与注册表同步
|
||||
|
||||
- 在 `src-tauri/src/commands/` 下落到对应模块
|
||||
@@ -227,10 +231,13 @@ npm run verify:local
|
||||
- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_get_adapter_launch_readiness / site_get_adapter_catalog_status / site_import_adapter_yaml_bundle / site_run_adapter`
|
||||
- **站点适配器导入主链**:`site_import_adapter_yaml_bundle` 只负责把外部 YAML 来源编译为 Lime 标准并写入 `imported` 目录,不允许带入第二套 runtime、daemon 或自动唤醒浏览器链路
|
||||
- **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run`
|
||||
- **站点技能首页入口主链**:首页 / 工作区弹窗只负责补参数、组装 `initialUserPrompt + harness.service_skill_launch` 上下文并进入 `Claw`;真正执行统一收口到 `Claw` 首回合,不再由首页弹窗或工作区挂载副作用直接调用 `site_run_adapter`
|
||||
- **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档
|
||||
- **Claw 站点直跑门禁主链**:`site_get_adapter_launch_readiness` 只负责检测“是否存在已附着的真实浏览器会话 + 目标站点上下文”;`site_run_adapter.require_attached_session = true` 时,后端必须拒绝 managed/default fallback,不能后台偷偷起 Chrome
|
||||
- **attached-session 执行主链**:真实浏览器附着场景下,Bridge `run_adapter` 只允许下发 `adapter_name + args`,禁止继续透传原始脚本文本到扩展 content script,以免触发站点 CSP 的 `unsafe-eval`
|
||||
- **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint`
|
||||
- **浏览器资料 / 环境预设主链**:`list/save/archive/restore_browser_profile_cmd` 与 `list/save/archive/restore_browser_environment_preset_cmd` 已进入真实 DevBridge 主路径;浏览器模式下不应再默认放进 `mockPriorityCommands`,仅在 DevBridge 不可用时才允许回落 `defaultMocks`
|
||||
- **浏览器运行时启动主链**:`launch_browser_session` / `launch_browser_runtime_assist` 支持显式 `headless` 启动参数;仅用于 `verify:gui-smoke` 一类自动化校验避免弹出空白 Chrome,正常用户态调用默认仍保持有界面浏览器
|
||||
|
||||
## 相关检查脚本
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
- 优先使用 **Playwright MCP** 做交互验证,不优先编写新的本地 Playwright 测试文件
|
||||
- 浏览器模式默认首页从 `http://127.0.0.1:1420/` 进入
|
||||
- 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock
|
||||
- `verify:gui-smoke` 内部的 browser runtime 校验默认走无界面浏览器会话;它只证明主链可启动,不替代后续真实页面交互验证
|
||||
- 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板
|
||||
- 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local`
|
||||
|
||||
## 进入前的最低准备
|
||||
@@ -145,11 +147,13 @@ npm run test:contracts
|
||||
### Claw 站点技能直跑门禁验证
|
||||
|
||||
1. 在 `Claw` 首页打开一个站点型技能弹窗
|
||||
2. 如果当前没有附着真实浏览器会话,确认主按钮保持禁用,并出现“需要浏览器工作台 / 重新检测会话”的门禁提示
|
||||
2. 如果当前没有附着真实浏览器会话,确认弹窗继续展示“需要先准备浏览器 / 重新检测会话”的门禁提示,且 `在 Claw 中执行` 主按钮处于禁用状态
|
||||
3. 点击 `去浏览器工作台`,确认只发生页面跳转,不会后台偷偷拉起 Chrome
|
||||
4. 在浏览器工作台附着到真实浏览器并打开目标站点后,回到 `Claw` 再次打开同一技能
|
||||
5. 确认此时主按钮变为可执行,点击后进入 `Claw` 工作区
|
||||
6. 确认消息流顶部出现独立的站点技能执行卡,状态依次体现 `执行中 / 已完成` 或明确阻断原因,而不是伪装成普通聊天消息
|
||||
6. 确认进入 `Claw` 后会自动发送一条首回合技能任务消息,消息文本包含站点技能启动上下文,而不是由前端挂载副作用偷偷直跑
|
||||
7. 如果已有附着会话,确认 `Claw` 会通过 `lime_site_run` 执行并把结果写回当前主稿或项目资源
|
||||
8. 如果没有附着会话,确认不会再向 `Claw` 对话流注入“我已完成登录,继续执行”之类的确认卡;阻断必须停留在技能入口层
|
||||
|
||||
### 开发者页站点来源导入验证
|
||||
|
||||
@@ -167,8 +171,10 @@ npm run test:contracts
|
||||
3. 点击“展开高级控制”,确认 `总览 / Profile / 桥接 / 后端 / 调试` 页签可切换
|
||||
4. 如当前环境允许目录选择,点击“选择目录并安装”或“同步更新扩展”,确认安装目录最终落到固定子目录 `Lime Browser Connector`
|
||||
5. 点击“复制配置”,确认剪贴板内容包含 `serverUrl / bridgeKey / profileKey`
|
||||
6. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接
|
||||
7. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页
|
||||
6. 确认“连接方式”区块同时展示“浏览器扩展 / CDP 直连”两种说明,并包含 `chrome://extensions` 与 `chrome://inspect/#remote-debugging` 的打开或复制入口
|
||||
7. 在“动作配置”区块临时关闭一个能力,例如“页面内查找”,确认开关状态立即更新,且后端能力列表同步隐藏对应动作
|
||||
8. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接
|
||||
9. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”与“打开远程调试页”可成功唤起对应 Chrome 页面
|
||||
|
||||
### 话题模型恢复验证
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
|
||||
只改其中一侧,不算完成。
|
||||
|
||||
如果本轮是在下线共享网关控制面,`start_server`、`stop_server`、`get_server_status`、`get_available_routes`、`get_route_curl_examples`、`test_api`、`get_network_info`,以及托盘残留 `sync_tray_state`、`update_tray_server_status`、`update_tray_credential_status`、`get_tray_state`、`refresh_tray_menu`、`refresh_tray_with_stats` 必须同步从前端网关、Rust 注册、DevBridge 和 mock 中撤掉;server 兼容面 `/v1/routes`、`/{selector}/v1/messages`、`/{selector}/v1/chat/completions` 也必须同步从 server 路由表与 services/core 模型中撤掉;开发者诊断只保留 `get_server_diagnostics`,托盘只保留 `sync_tray_model_shortcuts`,server 只保留标准 `/v1/messages` 与 `/v1/chat/completions`。
|
||||
|
||||
### 3. 用户可见 UI 改动必须补稳定回归
|
||||
|
||||
- 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言
|
||||
@@ -134,6 +136,7 @@ npm run verify:gui-smoke
|
||||
- 等待 `DevBridge` 健康检查通过
|
||||
- 验证默认 workspace 的准备态可用
|
||||
- 验证 `browser runtime` 的启动、状态读取与审计主链可用
|
||||
- 其中 `browser runtime smoke` 默认以无界面浏览器会话执行,避免额外弹出仅用于校验的空白 Chrome
|
||||
- 验证 `site adapter catalog` 的状态、列表与推荐主链可读
|
||||
|
||||
它解决的是 GUI 产品特有风险:
|
||||
@@ -167,9 +170,10 @@ npm run bridge:health -- --timeout-ms 120000
|
||||
- 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy`
|
||||
- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义
|
||||
- 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑
|
||||
- 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch`
|
||||
- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter`
|
||||
- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级
|
||||
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、扩展安装状态或主动断开扩展连接
|
||||
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接
|
||||
- 修改 `src/lib/dev-bridge/`
|
||||
- 修改 `src/lib/tauri-mock/`
|
||||
- 修改 `src-tauri/src/app/runner.rs`
|
||||
@@ -207,6 +211,7 @@ npm run bridge:health -- --timeout-ms 120000
|
||||
补充说明:
|
||||
|
||||
- 如果这次改动把 `ServiceSkill -> automation_job -> agent_turn` 接到 Artifact 主线,除了常规 `verify:local` / `test:contracts` 之外,还应至少补一条稳定回归,证明 `content_id + request_metadata.artifact` 没在表单编辑或执行链路里丢失。
|
||||
- 如果这次改动影响 `Claw` 与站点技能的直跑门禁,还应补回归证明:阻断停留在技能入口层,不再把浏览器准备态注入成对话里的继续执行确认。
|
||||
- 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
|
||||
- session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id`
|
||||
- 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id`
|
||||
|
||||
@@ -53,9 +53,9 @@ navigation:
|
||||
|
||||
可以。上传参考图后,若所选模型支持编辑接口,会自动走编辑链路。
|
||||
|
||||
### 我还需要 API 接入能力怎么办?
|
||||
### 我还需要排查底层协议怎么办?
|
||||
|
||||
可以继续阅读 [API Server](/user-guide/api-server) 和 [API 参考](/api-reference/overview)。
|
||||
可以继续阅读 [API 参考](/api-reference/overview)。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -55,24 +55,7 @@ resource:
|
||||
|
||||
适用:品牌运营、活动策划、小团队协作。
|
||||
|
||||
## 示例 3:进阶接入
|
||||
|
||||
目标:在保留创作工作台的同时开放 API 给外部工具。
|
||||
|
||||
```yaml
|
||||
profile: "creator-plus-api"
|
||||
api_server:
|
||||
enabled: true
|
||||
host: "127.0.0.1"
|
||||
port: 8999
|
||||
auth: "api-key"
|
||||
routing:
|
||||
fallback: true
|
||||
```
|
||||
|
||||
适用:有脚本联动、自动化流程需求的用户。
|
||||
|
||||
## 示例 4:API 缓存策略(高级)
|
||||
## 示例 3:API 缓存策略(高级)
|
||||
|
||||
目标:精确控制哪些响应状态码参与短时缓存(非流式)。
|
||||
|
||||
@@ -91,7 +74,7 @@ server:
|
||||
|
||||
适用:对缓存命中与语义一致性有要求的自动化/API 调用场景。
|
||||
|
||||
## 示例 5:运行时诊断接口(高级)
|
||||
## 示例 4:运行时诊断接口(高级)
|
||||
|
||||
目标:像 ClawRouter 一样快速查看网关健康、缓存与统计信息。
|
||||
|
||||
@@ -124,10 +107,8 @@ curl -i "http://127.0.0.1:8999/v1/chat/completions" \
|
||||
- `x-lime-idempotency`(`replay/in-progress/new/removed-on-error`)
|
||||
- `x-lime-requested-provider` / `x-lime-effective-provider` / `x-lime-model`
|
||||
|
||||
补充:在「团队共享网关(内网)」页面的「网关 API 测试」结果展开区域,也会直接显示这些 `x-lime-*` 调试头。
|
||||
|
||||
## 调整顺序建议
|
||||
|
||||
1. 先确认导航与主题
|
||||
2. 再确认连接与稳定性
|
||||
3. 最后再做 API 与自动化扩展
|
||||
3. 最后再做自动化与诊断扩展
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
title: 本地 API 接入
|
||||
description: 将 Lime 能力暴露给外部工具(进阶)
|
||||
navigation:
|
||||
icon: i-heroicons-server
|
||||
---
|
||||
|
||||
# 本地 API 接入
|
||||
|
||||
::alert{type="info"}
|
||||
这是进阶能力。普通创作者可以不配置,直接在应用内完成创作。
|
||||
::
|
||||
|
||||
当你希望把 Lime 接入脚本、自动化工具或第三方客户端时,可启用本地 API。
|
||||
|
||||
## 核心说明
|
||||
|
||||
- 服务默认在本地地址运行
|
||||
- 通过 API Key 控制访问
|
||||
- 支持常见兼容接口形态
|
||||
|
||||
## 快速配置
|
||||
|
||||
1. 进入设置中的 API Server
|
||||
2. 开启服务并确认端口
|
||||
3. 设置或生成 API Key
|
||||
4. 使用一条测试请求验证连通性
|
||||
|
||||
## 最小测试示例
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8999/v1/chat/completions \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"your-model","messages":[{"role":"user","content":"你好"}]}'
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. 只在本机环境使用
|
||||
2. 不要把 API Key 提交到代码仓库
|
||||
3. 定期更换密钥并清理无效连接
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 启动后无法访问
|
||||
|
||||
先确认端口未被占用,再检查本地防火墙策略。
|
||||
|
||||
### 请求总是 401
|
||||
|
||||
通常是 API Key 填写错误或请求头格式不正确。
|
||||
@@ -5,6 +5,9 @@
|
||||
## 功能
|
||||
|
||||
- Observer 通道自动连接:`/lime-chrome-observer/<bridge_key>?profileKey=...`
|
||||
- 启停状态持久化:手动停用或桌面端主动断开后,不会在后台偷偷重连
|
||||
- MV3 保活与自动重连:使用 `chrome.alarms` 保持 relay 存活,并在浏览器恢复后自动重连
|
||||
- 按需注入 content script:不再对所有站点常驻注入,只在抓取或执行命令时动态注入
|
||||
- 页面信息上报:标题、URL、Markdown
|
||||
- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等
|
||||
- 弹窗入口:直接打开 Lime 的“连接器”页,并保留高级手动配置
|
||||
@@ -91,5 +94,6 @@ npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile de
|
||||
## 兼容说明
|
||||
|
||||
- 扩展只负责浏览器侧采集与动作执行。
|
||||
- 当前主链仍然是 `API -> observer socket -> 扩展 -> 当前已登录 Chrome 标签页`,不会额外拉起新的托管 Chrome。
|
||||
- Agent 侧通过 `aster_agent_cmd` 暴露的现役浏览器 MCP 工具访问。
|
||||
- 若你同时使用独立 Chrome Profile(Tauri `open_chrome_profile_window`),请在对应 Profile 内安装该扩展,并使用不同 `profileKey` 做隔离。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -214,6 +214,721 @@
|
||||
}
|
||||
}
|
||||
|
||||
const siteAdapterHelpers = {
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
text(value) {
|
||||
return String(value?.textContent || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
},
|
||||
absoluteUrl(value) {
|
||||
try {
|
||||
return new URL(value, window.location.href).toString();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
uniqueBy(items, getKey) {
|
||||
const seen = new Set();
|
||||
return items.filter((item) => {
|
||||
const key = getKey(item);
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
async waitFor(test, timeoutMs = 12000, intervalMs = 250) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const value = await test();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
take(items, limit) {
|
||||
return items.slice(0, Math.max(1, limit));
|
||||
},
|
||||
number(value, fallbackValue) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackValue;
|
||||
},
|
||||
looksLikeLoginWall() {
|
||||
const text = (document.body?.textContent || "").slice(0, 4000);
|
||||
return /(登录|登入|sign in|log in|继续访问|验证你是人类|扫码登录)/i.test(
|
||||
text,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const siteAdapterRunners = {
|
||||
"github/search": async (args, helpers) => {
|
||||
const query = String(args.query || "").trim();
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
await helpers.waitFor(
|
||||
() => document.querySelectorAll("a.v-align-middle").length > 0,
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
const links = Array.from(document.querySelectorAll("a.v-align-middle"));
|
||||
const items = helpers.take(
|
||||
helpers.uniqueBy(
|
||||
links
|
||||
.map((anchor) => {
|
||||
const card = anchor.closest("li, div.search-title, div.Box-row");
|
||||
return {
|
||||
title: helpers.text(anchor),
|
||||
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
||||
summary: helpers.text(card?.querySelector("p")),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
(item) => item.url,
|
||||
),
|
||||
limit,
|
||||
);
|
||||
|
||||
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: "GitHub 搜索结果为空,可能需要先登录后再访问。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
query,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"github/issues": async (args, helpers) => {
|
||||
const repo = String(args.repo || "").trim();
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
await helpers.waitFor(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
'a[data-hovercard-type="issue"], a[id^="issue_"]',
|
||||
).length > 0,
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
const links = Array.from(
|
||||
document.querySelectorAll(
|
||||
'a[data-hovercard-type="issue"], a[id^="issue_"]',
|
||||
),
|
||||
);
|
||||
const items = helpers.take(
|
||||
helpers.uniqueBy(
|
||||
links
|
||||
.map((anchor) => {
|
||||
const row = anchor.closest("div, li");
|
||||
return {
|
||||
title: helpers.text(anchor),
|
||||
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
||||
status: helpers.text(
|
||||
row?.querySelector(
|
||||
'[aria-label*="Open"], [aria-label*="Closed"], .State',
|
||||
),
|
||||
),
|
||||
summary: helpers.text(
|
||||
row?.querySelector(
|
||||
"span.color-fg-muted, .markdown-title + div",
|
||||
),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
(item) => item.url,
|
||||
),
|
||||
limit,
|
||||
);
|
||||
|
||||
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: `GitHub 仓库 ${repo} 的 issue 列表为空,可能需要登录后访问。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
repo,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"zhihu/search": async (args, helpers) => {
|
||||
const query = String(args.query || "").trim();
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
const hrefTokens = ["/question/", "/answer/", "/zvideo/"];
|
||||
const maxCandidates = Math.max(limit * 8, 40);
|
||||
const main = document.querySelector("main") || document.body;
|
||||
const collectCandidates = (maxCount) => {
|
||||
if (!main) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
for (const anchor of main.getElementsByTagName("a")) {
|
||||
const href = anchor.getAttribute("href") || "";
|
||||
if (!hrefTokens.some((token) => href.includes(token))) {
|
||||
continue;
|
||||
}
|
||||
const title = helpers.text(anchor);
|
||||
const url = helpers.absoluteUrl(href);
|
||||
if (!title || !url || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
results.push({ anchor, title, url });
|
||||
if (results.length >= maxCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300);
|
||||
const items = helpers.take(
|
||||
collectCandidates(maxCandidates)
|
||||
.map(({ anchor, title, url }) => {
|
||||
const card = anchor.closest("div");
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
summary: helpers.text(card?.querySelector("p")),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
limit,
|
||||
);
|
||||
|
||||
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: `知乎没有返回 "${query}" 的搜索结果,可能需要先登录。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
query,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"zhihu/hot": async (args, helpers) => {
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
const hrefTokens = ["/question/"];
|
||||
const maxCandidates = Math.max(limit * 6, 30);
|
||||
const main = document.querySelector("main") || document.body;
|
||||
const collectCandidates = (maxCount) => {
|
||||
if (!main) {
|
||||
return [];
|
||||
}
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
for (const anchor of main.getElementsByTagName("a")) {
|
||||
const href = anchor.getAttribute("href") || "";
|
||||
if (!hrefTokens.some((token) => href.includes(token))) {
|
||||
continue;
|
||||
}
|
||||
const title = helpers.text(anchor);
|
||||
const url = helpers.absoluteUrl(href);
|
||||
if (!title || !url || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
results.push({ anchor, title, url });
|
||||
if (results.length >= maxCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300);
|
||||
const items = helpers.take(
|
||||
collectCandidates(maxCandidates)
|
||||
.map(({ anchor, title, url }, index) => {
|
||||
const card = anchor.closest("section, div");
|
||||
return {
|
||||
rank: index + 1,
|
||||
title,
|
||||
url,
|
||||
summary: helpers.text(card?.querySelector("p")),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
limit,
|
||||
);
|
||||
|
||||
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: "当前页面没有返回知乎热榜内容,可能需要先登录知乎。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"bilibili/search": async (args, helpers) => {
|
||||
const query = String(args.query || "").trim();
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
await helpers.waitFor(
|
||||
() =>
|
||||
document.querySelectorAll(
|
||||
'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]',
|
||||
).length > 0,
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
const links = Array.from(
|
||||
document.querySelectorAll(
|
||||
'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]',
|
||||
),
|
||||
);
|
||||
const items = helpers.take(
|
||||
helpers.uniqueBy(
|
||||
links
|
||||
.map((anchor) => {
|
||||
const card = anchor.closest("div");
|
||||
return {
|
||||
title: helpers.text(anchor),
|
||||
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
||||
author: helpers.text(
|
||||
card?.querySelector(
|
||||
".bili-video-card__info--author, .up-name, .bili-video-card__info--bottom .bili-video-card__info--author",
|
||||
),
|
||||
),
|
||||
summary: helpers.text(
|
||||
card?.querySelector(
|
||||
".bili-video-card__info--desc, .des, .bili-video-card__info--title",
|
||||
),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
(item) => item.url,
|
||||
),
|
||||
limit,
|
||||
);
|
||||
|
||||
if (items.length === 0 && helpers.looksLikeLoginWall()) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
query,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"36kr/newsflash": async (args, helpers) => {
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
await helpers.waitFor(
|
||||
() => document.querySelectorAll('a[href*="/p/"]').length > 0,
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
const links = Array.from(document.querySelectorAll('a[href*="/p/"]'));
|
||||
const items = helpers.take(
|
||||
helpers.uniqueBy(
|
||||
links
|
||||
.map((anchor) => {
|
||||
const card = anchor.closest("div, article");
|
||||
return {
|
||||
title: helpers.text(anchor),
|
||||
url: helpers.absoluteUrl(anchor.getAttribute("href") || ""),
|
||||
summary: helpers.text(card?.querySelector("p")),
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
(item) => item.url,
|
||||
),
|
||||
limit,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"linux-do/hot": async (args, helpers) => {
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
const allowedPeriods = new Set([
|
||||
"all",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
]);
|
||||
const period = String(args.period || "weekly").trim().toLowerCase();
|
||||
const normalizedPeriod = allowedPeriods.has(period) ? period : "weekly";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/top.json?period=" + encodeURIComponent(normalizedPeriod),
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: "linux.do 热门话题暂不可用,可能需要先登录。",
|
||||
};
|
||||
}
|
||||
const payload = await response.json();
|
||||
const topics = Array.isArray(payload?.topic_list?.topics)
|
||||
? payload.topic_list.topics
|
||||
: [];
|
||||
const categories = Array.isArray(payload?.topic_list?.categories)
|
||||
? payload.topic_list.categories
|
||||
: Array.isArray(payload?.categories)
|
||||
? payload.categories
|
||||
: [];
|
||||
const categoryMap = new Map(
|
||||
categories.map((category) => [
|
||||
category?.id,
|
||||
String(category?.name || "").trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
const items = helpers
|
||||
.take(
|
||||
topics.map((topic, index) => ({
|
||||
rank: index + 1,
|
||||
title: String(topic?.title || "").trim(),
|
||||
replies: Math.max(0, Number(topic?.posts_count || 1) - 1),
|
||||
views: Number(topic?.views || 0),
|
||||
likes: Number(topic?.like_count || 0),
|
||||
category:
|
||||
categoryMap.get(topic?.category_id) ||
|
||||
String(topic?.category_id || "").trim(),
|
||||
})),
|
||||
limit,
|
||||
)
|
||||
.filter((item) => item.title);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
period: normalizedPeriod,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "runtime_error",
|
||||
error_message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "读取 linux.do 热门话题失败。",
|
||||
};
|
||||
}
|
||||
},
|
||||
"linux-do/categories": async (args, helpers) => {
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
|
||||
try {
|
||||
const response = await fetch("/categories.json", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "auth_required",
|
||||
error_message: "linux.do 分类列表暂不可用,可能需要先登录。",
|
||||
};
|
||||
}
|
||||
const payload = await response.json();
|
||||
const categories = Array.isArray(payload?.category_list?.categories)
|
||||
? payload.category_list.categories
|
||||
: [];
|
||||
|
||||
const items = helpers
|
||||
.take(
|
||||
categories.map((category) => ({
|
||||
name: String(category?.name || "").trim(),
|
||||
slug: String(category?.slug || "").trim(),
|
||||
id: category?.id ?? null,
|
||||
topics: category?.topic_count ?? 0,
|
||||
description: String(category?.description_text || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80),
|
||||
})),
|
||||
limit,
|
||||
)
|
||||
.filter((item) => item.name);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "runtime_error",
|
||||
error_message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "读取 linux.do 分类列表失败。",
|
||||
};
|
||||
}
|
||||
},
|
||||
"smzdm/search": async (args, helpers) => {
|
||||
const query = String(args.query || "").trim();
|
||||
const limit = helpers.number(args.limit, 10);
|
||||
const rowSelector = "li.feed-row-wide";
|
||||
|
||||
await helpers.waitFor(
|
||||
() =>
|
||||
document.querySelectorAll(rowSelector).length > 0 ||
|
||||
/搜索结果|相关好价|什么值得买/i.test(
|
||||
document.body?.textContent || "",
|
||||
),
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
|
||||
const rows = Array.from(document.querySelectorAll(rowSelector));
|
||||
const items = helpers.take(
|
||||
helpers.uniqueBy(
|
||||
rows
|
||||
.map((row, index) => {
|
||||
const titleAnchor =
|
||||
row.querySelector("h5.feed-block-title > a") ||
|
||||
row.querySelector("h5 > a");
|
||||
const rawHref =
|
||||
titleAnchor?.getAttribute("href") || titleAnchor?.href || "";
|
||||
const url = helpers.absoluteUrl(rawHref);
|
||||
const title = (
|
||||
titleAnchor?.getAttribute("title") || helpers.text(titleAnchor)
|
||||
).trim();
|
||||
const price = helpers.text(row.querySelector(".z-highlight"));
|
||||
const mall = helpers.text(
|
||||
row.querySelector(".z-feed-foot-r .feed-block-extras span") ||
|
||||
row.querySelector(".z-feed-foot-r span"),
|
||||
);
|
||||
const commentsText = helpers
|
||||
.text(row.querySelector(".feed-btn-comment"))
|
||||
.replace(/[^\d]/g, "");
|
||||
return {
|
||||
rank: index + 1,
|
||||
title,
|
||||
url,
|
||||
price,
|
||||
mall,
|
||||
comments: commentsText ? Number(commentsText) : 0,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.title && item.url),
|
||||
(item) => item.url,
|
||||
),
|
||||
limit,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
query,
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
"yahoo-finance/quote": async (args, helpers) => {
|
||||
const symbol = String(args.symbol || "").trim().toUpperCase();
|
||||
if (!symbol) {
|
||||
return {
|
||||
ok: false,
|
||||
error_code: "invalid_args",
|
||||
error_message: "symbol 不能为空。",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeTextValue = (value) => {
|
||||
const text = String(value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text || null;
|
||||
};
|
||||
const normalizeNumberValue = (value) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
const text = String(value)
|
||||
.replace(/,/g, "")
|
||||
.replace(/%/g, "")
|
||||
.trim();
|
||||
const number = Number(text);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
const buildResult = (item) => ({
|
||||
ok: true,
|
||||
data: {
|
||||
symbol,
|
||||
items: [item],
|
||||
count: 1,
|
||||
},
|
||||
source_url: location.href,
|
||||
});
|
||||
|
||||
try {
|
||||
const chartUrl =
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/" +
|
||||
encodeURIComponent(symbol) +
|
||||
"?interval=1d&range=1d";
|
||||
const response = await fetch(chartUrl, { credentials: "include" });
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
const chart = payload?.chart?.result?.[0];
|
||||
if (chart) {
|
||||
const meta = chart.meta || {};
|
||||
const previousClose =
|
||||
meta.previousClose ?? meta.chartPreviousClose ?? null;
|
||||
const price = meta.regularMarketPrice ?? null;
|
||||
const change =
|
||||
price != null && previousClose != null
|
||||
? price - previousClose
|
||||
: null;
|
||||
const changePercent =
|
||||
change != null && previousClose
|
||||
? Number(((change / previousClose) * 100).toFixed(2))
|
||||
: null;
|
||||
return buildResult({
|
||||
symbol: meta.symbol || symbol,
|
||||
name: meta.shortName || meta.longName || symbol,
|
||||
price: price != null ? Number(price.toFixed(2)) : null,
|
||||
change: change != null ? Number(change.toFixed(2)) : null,
|
||||
changePercent,
|
||||
open: chart.indicators?.quote?.[0]?.open?.[0] ?? null,
|
||||
high: meta.regularMarketDayHigh ?? null,
|
||||
low: meta.regularMarketDayLow ?? null,
|
||||
volume: meta.regularMarketVolume ?? null,
|
||||
marketCap: meta.marketCap ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await helpers.waitFor(
|
||||
() =>
|
||||
document.querySelector('[data-testid="qsp-price"]') ||
|
||||
document.querySelector(
|
||||
'fin-streamer[data-field="regularMarketPrice"]',
|
||||
) ||
|
||||
document.querySelector("h1"),
|
||||
12000,
|
||||
300,
|
||||
);
|
||||
|
||||
const titleText = normalizeTextValue(document.querySelector("h1")?.textContent);
|
||||
const daysRangeText = document.querySelector('[data-test="DAYS_RANGE-value"]')
|
||||
?.textContent;
|
||||
const daysRangeParts = typeof daysRangeText === "string"
|
||||
? daysRangeText.split(" - ")
|
||||
: [];
|
||||
const item = {
|
||||
symbol,
|
||||
name: titleText
|
||||
? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol
|
||||
: symbol,
|
||||
price: normalizeNumberValue(
|
||||
document.querySelector('[data-testid="qsp-price"]')?.textContent ||
|
||||
document.querySelector(
|
||||
'fin-streamer[data-field="regularMarketPrice"]',
|
||||
)?.textContent,
|
||||
),
|
||||
change: normalizeNumberValue(
|
||||
document.querySelector('[data-testid="qsp-price-change"]')
|
||||
?.textContent ||
|
||||
document.querySelector(
|
||||
'fin-streamer[data-field="regularMarketChange"]',
|
||||
)?.textContent,
|
||||
),
|
||||
changePercent: normalizeNumberValue(
|
||||
document.querySelector('[data-testid="qsp-price-change-percent"]')
|
||||
?.textContent ||
|
||||
document.querySelector(
|
||||
'fin-streamer[data-field="regularMarketChangePercent"]',
|
||||
)?.textContent,
|
||||
),
|
||||
open: normalizeNumberValue(
|
||||
document.querySelector('[data-test="OPEN-value"]')?.textContent,
|
||||
),
|
||||
high: normalizeNumberValue(daysRangeParts[1]),
|
||||
low: normalizeNumberValue(daysRangeParts[0]),
|
||||
volume: normalizeNumberValue(
|
||||
document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent,
|
||||
),
|
||||
marketCap: normalizeTextValue(
|
||||
document.querySelector('[data-test="MARKET_CAP-value"]')?.textContent,
|
||||
),
|
||||
};
|
||||
|
||||
if (item.price != null || item.name !== symbol) {
|
||||
return buildResult(item);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
symbol,
|
||||
items: [],
|
||||
count: 0,
|
||||
},
|
||||
source_url: location.href,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function executeCommand(commandData) {
|
||||
const command = String(commandData.command || "").trim();
|
||||
const target = commandData.target;
|
||||
@@ -267,13 +982,23 @@
|
||||
return { status: "success", message: "执行前进" };
|
||||
}
|
||||
case "run_adapter": {
|
||||
const script = String(payload?.script || "").trim();
|
||||
if (!script) {
|
||||
return { status: "error", error: "run_adapter 缺少 script" };
|
||||
const adapterName = String(payload?.adapter_name || "").trim();
|
||||
if (!adapterName) {
|
||||
return { status: "error", error: "run_adapter 缺少 adapter_name" };
|
||||
}
|
||||
const runner = siteAdapterRunners[adapterName];
|
||||
if (typeof runner !== "function") {
|
||||
return {
|
||||
status: "error",
|
||||
error: `当前扩展内未注册站点适配器: ${adapterName}`,
|
||||
};
|
||||
}
|
||||
const adapterArgs =
|
||||
payload && typeof payload.args === "object" && !Array.isArray(payload.args)
|
||||
? payload.args
|
||||
: {};
|
||||
try {
|
||||
const executor = new Function(`return ${script};`);
|
||||
const result = await executor();
|
||||
const result = await runner(adapterArgs, siteAdapterHelpers);
|
||||
return {
|
||||
status: "success",
|
||||
message: "run_adapter 执行成功",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* CDP command dispatch — main routing logic.
|
||||
*
|
||||
* Routes incoming forwardCDPCommand messages to the appropriate handler:
|
||||
* Target.* → target-ops.js (tab creation, closing, activation)
|
||||
* Extension.* → content_script/extension-ops.js (viewport, content, elements, actions)
|
||||
* Other CDP → chrome.debugger.sendCommand (transparent forwarding)
|
||||
*
|
||||
* @param {import('../tabs/manager.js').TabManager} mgr
|
||||
* @returns {(msg: any) => Promise<any>}
|
||||
*/
|
||||
|
||||
import { createTargetOps } from './target-ops.js'
|
||||
import {
|
||||
extGetViewportInfo, extEnsureZoom, extCaptureViewport,
|
||||
extExtractContent, extMarkElements, extClick, extInput,
|
||||
} from '../../content_script/extension-ops.js'
|
||||
import { RUNTIME_ENABLE_DELAY, CDP_COMMAND_TIMEOUT, withTimeout } from './utils.js'
|
||||
|
||||
const MAX_QUEUE_DEPTH = 100
|
||||
const _tabQueues = new Map()
|
||||
|
||||
function getTabQueue(tabId) {
|
||||
if (!tabId) return null
|
||||
let q = _tabQueues.get(tabId)
|
||||
if (!q) {
|
||||
q = { running: false, queue: [] }
|
||||
_tabQueues.set(tabId, q)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
async function processQueue(q) {
|
||||
if (q.running) return
|
||||
q.running = true
|
||||
try {
|
||||
while (q.queue.length > 0) {
|
||||
const { task, resolve, reject } = q.queue.shift()
|
||||
try {
|
||||
resolve(await task())
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
q.running = false
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueForTab(tabId, task) {
|
||||
const q = getTabQueue(tabId)
|
||||
if (!q) return task()
|
||||
if (q.queue.length >= MAX_QUEUE_DEPTH) {
|
||||
return Promise.reject(new Error(`Tab ${tabId} command queue full (${MAX_QUEUE_DEPTH})`))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
q.queue.push({ task, resolve, reject })
|
||||
processQueue(q)
|
||||
})
|
||||
}
|
||||
|
||||
export function cleanupTabQueue(tabId) {
|
||||
_tabQueues.delete(tabId)
|
||||
}
|
||||
|
||||
export function cleanupAllTabQueues() {
|
||||
_tabQueues.clear()
|
||||
}
|
||||
|
||||
export function createDispatcher(mgr) {
|
||||
|
||||
const { cdpCreateTarget, cdpCloseTarget, cdpCloseAllAgentTabs, cdpActivateTarget } = createTargetOps(mgr)
|
||||
|
||||
async function cdpRuntimeEnable(debuggee, params) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
|
||||
await new Promise((r) => setTimeout(r, RUNTIME_ENABLE_DELAY))
|
||||
} catch (err) {
|
||||
console.debug('[accio-relay] Runtime.disable pre-step failed:', err)
|
||||
}
|
||||
return withTimeout(
|
||||
chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params),
|
||||
CDP_COMMAND_TIMEOUT,
|
||||
'Runtime.enable',
|
||||
)
|
||||
}
|
||||
|
||||
return async function handleForwardCdpCommand(msg) {
|
||||
const method = String(msg?.params?.method || '').trim()
|
||||
const params = msg?.params?.params || undefined
|
||||
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
||||
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
||||
const tabId = mgr.resolveTabId(sessionId, targetId)
|
||||
|
||||
return enqueueForTab(tabId, async () => {
|
||||
// ── Target.* commands (no tabId required for createTarget) ──
|
||||
if (method === 'Target.createTarget') return cdpCreateTarget(params)
|
||||
if (method === 'Target.closeAllAgentTabs') return cdpCloseAllAgentTabs()
|
||||
|
||||
if (!tabId) throw new Error(`No attached tab for method ${method}`)
|
||||
|
||||
mgr.onCdpCommand?.(tabId)
|
||||
|
||||
if (method === 'Target.closeTarget') return cdpCloseTarget(params, tabId)
|
||||
if (method === 'Target.activateTarget') return cdpActivateTarget(params, tabId)
|
||||
|
||||
// ── Extension.* virtual commands ──
|
||||
if (method === 'Extension.getViewportInfo') {
|
||||
await mgr.ensureAttached(tabId)
|
||||
return extGetViewportInfo(tabId)
|
||||
}
|
||||
if (method === 'Extension.ensureZoom') return extEnsureZoom(tabId, params)
|
||||
if (method === 'Extension.captureViewport') {
|
||||
await mgr.ensureAttached(tabId)
|
||||
return extCaptureViewport(tabId, params)
|
||||
}
|
||||
if (method === 'Extension.extractContent') return extExtractContent(tabId)
|
||||
if (method === 'Extension.markElements') return extMarkElements(tabId, params)
|
||||
if (method === 'Extension.click') return extClick(tabId, params)
|
||||
if (method === 'Extension.input') return extInput(tabId, params)
|
||||
|
||||
// ── Standard CDP forwarding (requires debugger attach) ──
|
||||
const ok = await mgr.ensureAttached(tabId)
|
||||
if (!ok) throw new Error(`Failed to attach debugger to tab ${tabId} for ${method}`)
|
||||
|
||||
/** @type {chrome.debugger.DebuggerSession} */
|
||||
const debuggee = { tabId }
|
||||
|
||||
if (method === 'Runtime.enable') return cdpRuntimeEnable(debuggee, params)
|
||||
|
||||
const tabState = mgr.get(tabId)
|
||||
const mainSessionId = tabState?.sessionId
|
||||
const debuggerSession =
|
||||
sessionId && mainSessionId && sessionId !== mainSessionId
|
||||
? { ...debuggee, sessionId }
|
||||
: debuggee
|
||||
|
||||
return withTimeout(
|
||||
chrome.debugger.sendCommand(debuggerSession, method, params),
|
||||
CDP_COMMAND_TIMEOUT,
|
||||
method,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Target.* CDP command handlers.
|
||||
*
|
||||
* Handles tab creation, closing, activation, and agent tab lifecycle
|
||||
* through Chrome extension APIs rather than raw CDP.
|
||||
*
|
||||
* @param {import('../tabs/manager.js').TabManager} mgr
|
||||
*/
|
||||
|
||||
import { TARGET_CREATE_DELAY, DEFAULT_MAX_RETAINED_TABS } from './utils.js'
|
||||
|
||||
export function createTargetOps(mgr) {
|
||||
|
||||
async function cdpCreateTarget(params) {
|
||||
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
|
||||
const createInWindow = params?.createInWindow === true || params?.type === 'window'
|
||||
const retain = params?.retain === true
|
||||
const maxRetained = typeof params?.maxRetainedTabs === 'number' ? params.maxRetainedTabs : DEFAULT_MAX_RETAINED_TABS
|
||||
|
||||
if (retain && mgr.retainedTabCount >= maxRetained) {
|
||||
throw new Error(`Cannot retain: already at max (${maxRetained}). Close retained tabs or set retain=false.`)
|
||||
}
|
||||
|
||||
let tab
|
||||
if (createInWindow) {
|
||||
const win = await chrome.windows.create({ url, focused: false })
|
||||
tab = win.tabs?.[0]
|
||||
if (!tab?.id) throw new Error('Failed to create window')
|
||||
} else {
|
||||
tab = await chrome.tabs.create({ url, active: false })
|
||||
if (!tab.id) throw new Error('Failed to create tab')
|
||||
}
|
||||
mgr.markAgent(tab.id, retain)
|
||||
if (!createInWindow) {
|
||||
await mgr.addToAgentGroup(tab.id)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, TARGET_CREATE_DELAY))
|
||||
const attached = await mgr.attach(tab.id)
|
||||
if (!attached) {
|
||||
mgr.deleteAgent(tab.id)
|
||||
await chrome.tabs.remove(tab.id).catch(() => {})
|
||||
throw new Error('Failed to attach debugger to new tab')
|
||||
}
|
||||
return { targetId: attached.targetId, retained: retain }
|
||||
}
|
||||
|
||||
async function cdpCloseTarget(params, fallbackTabId) {
|
||||
const target = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const toClose = target ? mgr.getByTargetId(target) : fallbackTabId
|
||||
if (!toClose) {
|
||||
return {
|
||||
success: false,
|
||||
error: target
|
||||
? `Tab with targetId "${target}" not found. It may have already been closed or detached.`
|
||||
: 'No target specified and no fallback tab available.',
|
||||
}
|
||||
}
|
||||
if (!mgr.isAgent(toClose)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot close this tab: it was not created by the agent. Only tabs opened via action=open can be closed.',
|
||||
}
|
||||
}
|
||||
if (mgr.isRetained(toClose)) {
|
||||
return { success: true, skipped: true, reason: 'Tab is retained — close skipped.' }
|
||||
}
|
||||
mgr.deleteAgent(toClose)
|
||||
try {
|
||||
await chrome.tabs.remove(toClose)
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: `Failed to remove tab ${toClose}: ${err?.message || err}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function cdpCloseAllAgentTabs() {
|
||||
const toClose = []
|
||||
let retained = 0
|
||||
for (const [id] of mgr.agentTabs.entries()) {
|
||||
if (mgr.isRetained(id)) { retained++; continue }
|
||||
toClose.push(id)
|
||||
}
|
||||
const results = await Promise.allSettled(toClose.map((id) => chrome.tabs.remove(id)))
|
||||
for (let i = 0; i < toClose.length; i++) {
|
||||
if (results[i].status === 'fulfilled') mgr.deleteAgent(toClose[i])
|
||||
}
|
||||
const closed = results.filter((r) => r.status === 'fulfilled').length
|
||||
return { success: true, closed, retained }
|
||||
}
|
||||
|
||||
async function cdpActivateTarget(params, fallbackTabId) {
|
||||
const target = typeof params?.targetId === 'string' ? params.targetId : ''
|
||||
const toActivate = target ? mgr.getByTargetId(target) : fallbackTabId
|
||||
if (!toActivate) return {}
|
||||
const tab = await chrome.tabs.get(toActivate).catch(() => null)
|
||||
if (!tab) return {}
|
||||
if (tab.windowId) {
|
||||
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
|
||||
}
|
||||
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
|
||||
return {}
|
||||
}
|
||||
|
||||
return { cdpCreateTarget, cdpCloseTarget, cdpCloseAllAgentTabs, cdpActivateTarget }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared CDP dispatch utilities and constants.
|
||||
*/
|
||||
|
||||
export const RUNTIME_ENABLE_DELAY = 10
|
||||
export const TARGET_CREATE_DELAY = 100
|
||||
export const CDP_COMMAND_TIMEOUT = 30000
|
||||
export const DEFAULT_MAX_RETAINED_TABS = 10
|
||||
|
||||
export function withTimeout(promise, ms, label) {
|
||||
let timer
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`CDP command timed out after ${ms}ms: ${label}`)), ms)
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Debugger (JS) domain event handlers.
|
||||
*
|
||||
* Handles breakpoint pauses, script parsing, and debugging state.
|
||||
*
|
||||
* Requires: `Debugger.enable` — only fires when relay enables Debugger domain.
|
||||
*
|
||||
* CRITICAL: Debugger.paused BLOCKS script execution until Debugger.resume()
|
||||
* (or step commands) is sent. If no response is sent, the page freezes.
|
||||
*
|
||||
* Strategy:
|
||||
* - If the relay enabled the Debugger domain, it should handle pauses.
|
||||
* - We auto-resume as a safety measure to prevent indefinite page freeze,
|
||||
* since our extension does NOT use the Debugger domain for breakpoints.
|
||||
* - Forward events to relay for awareness.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:debugger')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleDebuggerDomainEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
case 'Debugger.paused':
|
||||
log.warn(
|
||||
'script paused:', tabId,
|
||||
'reason:', params?.reason,
|
||||
'frames:', params?.callFrames?.length || 0,
|
||||
'hitBreakpoints:', params?.hitBreakpoints?.length || 0,
|
||||
)
|
||||
chrome.debugger
|
||||
.sendCommand({ tabId }, 'Debugger.resume')
|
||||
.then(() => log.debug('auto-resumed paused script:', tabId))
|
||||
.catch((err) => log.warn('auto-resume failed:', tabId, err?.message || err))
|
||||
break
|
||||
|
||||
case 'Debugger.resumed':
|
||||
log.debug('script resumed:', tabId)
|
||||
break
|
||||
|
||||
case 'Debugger.scriptParsed':
|
||||
if (params?.url) {
|
||||
log.debug(
|
||||
'script parsed:', tabId,
|
||||
'id:', params?.scriptId,
|
||||
'url:', truncate(params?.url, 100),
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Debugger.scriptFailedToParse':
|
||||
log.warn(
|
||||
'script parse failed:', tabId,
|
||||
'id:', params?.scriptId,
|
||||
'url:', truncate(params?.url || '', 100),
|
||||
)
|
||||
break
|
||||
|
||||
case 'Debugger.breakpointResolved':
|
||||
log.debug(
|
||||
'breakpoint resolved:', tabId,
|
||||
'id:', params?.breakpointId,
|
||||
'line:', params?.location?.lineNumber,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Page dialog event handlers.
|
||||
*
|
||||
* Handles `Page.javascriptDialogOpening` and `Page.javascriptDialogClosed`.
|
||||
*
|
||||
* Requires: `Page.enable` (called in debugger-attach.js on attach).
|
||||
*
|
||||
* Dialog types: alert, confirm, prompt, beforeunload.
|
||||
*
|
||||
* CRITICAL: When `hasBrowserHandler` is false, failing to call
|
||||
* `Page.handleJavaScriptDialog` will STALL page execution indefinitely.
|
||||
*
|
||||
* Strategy:
|
||||
* - beforeunload → always auto-accept (agent-initiated navigation/close)
|
||||
* - alert → auto-accept (dismiss)
|
||||
* - confirm → auto-accept (true)
|
||||
* - prompt → auto-accept with defaultPrompt value
|
||||
*
|
||||
* All dialog events are forwarded to the relay so the agent side
|
||||
* can observe them. The relay can also send Page.handleJavaScriptDialog
|
||||
* explicitly via normal CDP command path if it needs different behavior.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:dialog')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleDialogEvent(method, tabId, params, _ctx) {
|
||||
if (method === 'Page.javascriptDialogOpening') {
|
||||
return handleDialogOpening(tabId, params)
|
||||
}
|
||||
|
||||
if (method === 'Page.javascriptDialogClosed') {
|
||||
log.debug(
|
||||
'dialog closed:', tabId,
|
||||
'result:', params?.result,
|
||||
'userInput:', params?.userInput ? `"${truncate(params.userInput, 40)}"` : '(none)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogOpening(tabId, params) {
|
||||
const type = params?.type
|
||||
const message = params?.message || ''
|
||||
const defaultPrompt = params?.defaultPrompt || ''
|
||||
const hasBrowserHandler = params?.hasBrowserHandler
|
||||
|
||||
log.info(
|
||||
'dialog opening:', tabId,
|
||||
`type=${type}`,
|
||||
`hasBrowserHandler=${hasBrowserHandler}`,
|
||||
`message="${truncate(message, 100)}"`,
|
||||
)
|
||||
|
||||
switch (type) {
|
||||
case 'beforeunload':
|
||||
case 'alert':
|
||||
case 'confirm':
|
||||
acceptDialog(tabId, true)
|
||||
break
|
||||
case 'prompt':
|
||||
acceptDialog(tabId, true, defaultPrompt)
|
||||
break
|
||||
default:
|
||||
log.warn('unknown dialog type, auto-accepting:', tabId, type)
|
||||
acceptDialog(tabId, true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept/dismiss a dialog immediately.
|
||||
*
|
||||
* No setTimeout — in MV3 the SW can suspend before the timer fires,
|
||||
* leaving the page permanently stalled. The dialog event has already
|
||||
* been forwarded to the relay by the time we reach here, so the relay
|
||||
* can still override by sending its own Page.handleJavaScriptDialog
|
||||
* before observing the dialogClosed event.
|
||||
*/
|
||||
function acceptDialog(tabId, accept, promptText) {
|
||||
const cmdParams = { accept }
|
||||
if (promptText !== undefined) cmdParams.promptText = promptText
|
||||
|
||||
chrome.debugger
|
||||
.sendCommand({ tabId }, 'Page.handleJavaScriptDialog', cmdParams)
|
||||
.then(() => log.debug('dialog handled:', tabId, accept ? 'accepted' : 'dismissed'))
|
||||
.catch((err) => log.warn('dialog handle failed:', tabId, err?.message || err))
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Fetch domain event handlers.
|
||||
*
|
||||
* Handles `Fetch.requestPaused` and `Fetch.authRequired`.
|
||||
*
|
||||
* Requires: `Fetch.enable({ patterns, handleAuthRequests })` — only fires
|
||||
* when explicitly enabled by the relay/agent. This is an opt-in interception
|
||||
* mechanism.
|
||||
*
|
||||
* CRITICAL: Both events BLOCK the request until a response command is sent.
|
||||
* If no response is sent, the request hangs indefinitely.
|
||||
*
|
||||
* Strategy:
|
||||
* - These events are agent-controlled (relay enables Fetch domain for
|
||||
* request interception, mocking, etc.). The relay is expected to
|
||||
* respond with Fetch.continueRequest / failRequest / fulfillRequest.
|
||||
* - We log and forward to relay. No auto-response — the relay owns this.
|
||||
* - A safety timeout could be added in future to auto-continue stale requests.
|
||||
*
|
||||
* Response commands:
|
||||
* Fetch.requestPaused → Fetch.continueRequest / Fetch.failRequest / Fetch.fulfillRequest
|
||||
* Fetch.authRequired → Fetch.continueWithAuth
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:fetch')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleFetchEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
case 'Fetch.requestPaused':
|
||||
log.info(
|
||||
'request paused:', tabId,
|
||||
'requestId:', params?.requestId,
|
||||
'url:', truncate(params?.request?.url || '', 120),
|
||||
'method:', params?.request?.method,
|
||||
'resourceType:', params?.resourceType,
|
||||
'responseStatus:', params?.responseStatusCode || '(none)',
|
||||
)
|
||||
break
|
||||
|
||||
case 'Fetch.authRequired':
|
||||
log.warn(
|
||||
'auth required:', tabId,
|
||||
'requestId:', params?.requestId,
|
||||
'url:', truncate(params?.request?.url || '', 120),
|
||||
'scheme:', params?.authChallenge?.scheme,
|
||||
'realm:', params?.authChallenge?.realm,
|
||||
'source:', params?.authChallenge?.source,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* CDP event interceptor — routes debugger events to domain-specific handlers
|
||||
* before they are forwarded to the relay.
|
||||
*
|
||||
* Architecture:
|
||||
* chrome.debugger.onEvent → TabManager.onDebuggerEvent → interceptEvent()
|
||||
* → domain handler (side effects: auto-accept, logging, session tracking)
|
||||
* → forward to relay (unless handler returns { suppress: true })
|
||||
*
|
||||
* Event handler modules:
|
||||
* dialog.js — Page.javascriptDialogOpening/Closed (auto-accept)
|
||||
* page-lifecycle.js — Page load, navigation, frame, download, interstitial, file chooser
|
||||
* target.js — Target session/lifecycle management
|
||||
* security.js — Certificate errors (auto-continue), security state
|
||||
* fetch.js — Fetch.requestPaused/authRequired (relay-controlled)
|
||||
* network.js — Network request lifecycle, WebSocket
|
||||
* runtime.js — JS context, console, exceptions
|
||||
* inspector.js — Debugger detach, target crash
|
||||
* debugger-domain.js — Debugger.paused (auto-resume), script parsing
|
||||
* input.js — Input.dragIntercepted (relay-controlled)
|
||||
* log.js — Log.entryAdded (browser-level logs)
|
||||
*
|
||||
* CDP domain enable requirements:
|
||||
* Page → Page.enable (called on attach)
|
||||
* Network → Network.enable (relay must enable)
|
||||
* Runtime → Runtime.enable (relay must enable)
|
||||
* Security → Security.enable (relay must enable)
|
||||
* Fetch → Fetch.enable (relay must enable, with patterns)
|
||||
* Log → Log.enable (relay must enable)
|
||||
* Debugger → Debugger.enable (relay must enable)
|
||||
* Target → auto after attach (no explicit enable needed)
|
||||
* Inspector → auto after attach (no explicit enable needed)
|
||||
* Input → Input.setInterceptDrags (relay must enable)
|
||||
*
|
||||
* @module lib/cdp/events
|
||||
*/
|
||||
|
||||
import { handleDialogEvent } from './dialog.js'
|
||||
import { handleTargetEvent } from './target.js'
|
||||
import { handlePageLifecycleEvent } from './page-lifecycle.js'
|
||||
import { handleSecurityEvent } from './security.js'
|
||||
import { handleFetchEvent } from './fetch.js'
|
||||
import { handleNetworkEvent } from './network.js'
|
||||
import { handleRuntimeEvent } from './runtime.js'
|
||||
import { handleInspectorEvent } from './inspector.js'
|
||||
import { handleDebuggerDomainEvent } from './debugger-domain.js'
|
||||
import { handleInputEvent } from './input.js'
|
||||
import { handleLogEvent } from './log.js'
|
||||
|
||||
/**
|
||||
* @typedef {Object} EventContext
|
||||
* @property {Map<string, number>} childSession — child sessionId → parent tabId
|
||||
* @property {Map<number, Set<string>>} childSets — parent tabId → child sessionIds
|
||||
* @property {{ info: Function, warn: Function, debug: Function, error: Function }} log
|
||||
*/
|
||||
|
||||
const _handlers = new Map([
|
||||
// ── Page domain (requires Page.enable) ──
|
||||
['Page.javascriptDialogOpening', handleDialogEvent],
|
||||
['Page.javascriptDialogClosed', handleDialogEvent],
|
||||
['Page.loadEventFired', handlePageLifecycleEvent],
|
||||
['Page.domContentEventFired', handlePageLifecycleEvent],
|
||||
['Page.lifecycleEvent', handlePageLifecycleEvent],
|
||||
['Page.frameNavigated', handlePageLifecycleEvent],
|
||||
['Page.frameAttached', handlePageLifecycleEvent],
|
||||
['Page.frameDetached', handlePageLifecycleEvent],
|
||||
['Page.frameStartedLoading', handlePageLifecycleEvent],
|
||||
['Page.frameStoppedLoading', handlePageLifecycleEvent],
|
||||
['Page.frameStartedNavigating', handlePageLifecycleEvent],
|
||||
['Page.navigatedWithinDocument', handlePageLifecycleEvent],
|
||||
['Page.windowOpen', handlePageLifecycleEvent],
|
||||
['Page.downloadWillBegin', handlePageLifecycleEvent],
|
||||
['Page.downloadProgress', handlePageLifecycleEvent],
|
||||
['Page.fileChooserOpened', handlePageLifecycleEvent],
|
||||
['Page.interstitialShown', handlePageLifecycleEvent],
|
||||
['Page.interstitialHidden', handlePageLifecycleEvent],
|
||||
|
||||
// ── Target domain (auto after attach) ──
|
||||
['Target.attachedToTarget', handleTargetEvent],
|
||||
['Target.detachedFromTarget', handleTargetEvent],
|
||||
['Target.targetCreated', handleTargetEvent],
|
||||
['Target.targetDestroyed', handleTargetEvent],
|
||||
['Target.targetCrashed', handleTargetEvent],
|
||||
['Target.targetInfoChanged', handleTargetEvent],
|
||||
|
||||
// ── Security domain (requires Security.enable) ──
|
||||
['Security.certificateError', handleSecurityEvent],
|
||||
['Security.visibleSecurityStateChanged', handleSecurityEvent],
|
||||
['Security.securityStateChanged', handleSecurityEvent],
|
||||
|
||||
// ── Fetch domain (requires Fetch.enable) ──
|
||||
['Fetch.requestPaused', handleFetchEvent],
|
||||
['Fetch.authRequired', handleFetchEvent],
|
||||
|
||||
// ── Network domain (requires Network.enable) ──
|
||||
['Network.requestIntercepted', handleNetworkEvent],
|
||||
['Network.requestWillBeSent', handleNetworkEvent],
|
||||
['Network.responseReceived', handleNetworkEvent],
|
||||
['Network.loadingFinished', handleNetworkEvent],
|
||||
['Network.loadingFailed', handleNetworkEvent],
|
||||
['Network.webSocketCreated', handleNetworkEvent],
|
||||
['Network.webSocketClosed', handleNetworkEvent],
|
||||
['Network.webSocketFrameError', handleNetworkEvent],
|
||||
|
||||
// ── Runtime domain (requires Runtime.enable) ──
|
||||
['Runtime.exceptionThrown', handleRuntimeEvent],
|
||||
['Runtime.consoleAPICalled', handleRuntimeEvent],
|
||||
['Runtime.executionContextCreated', handleRuntimeEvent],
|
||||
['Runtime.executionContextDestroyed', handleRuntimeEvent],
|
||||
['Runtime.executionContextsCleared', handleRuntimeEvent],
|
||||
['Runtime.bindingCalled', handleRuntimeEvent],
|
||||
['Runtime.inspectRequested', handleRuntimeEvent],
|
||||
|
||||
// ── Inspector domain (auto after attach) ──
|
||||
['Inspector.detached', handleInspectorEvent],
|
||||
['Inspector.targetCrashed', handleInspectorEvent],
|
||||
['Inspector.targetReloadedAfterCrash', handleInspectorEvent],
|
||||
|
||||
// ── Debugger domain (requires Debugger.enable) ──
|
||||
['Debugger.paused', handleDebuggerDomainEvent],
|
||||
['Debugger.resumed', handleDebuggerDomainEvent],
|
||||
['Debugger.scriptParsed', handleDebuggerDomainEvent],
|
||||
['Debugger.scriptFailedToParse', handleDebuggerDomainEvent],
|
||||
['Debugger.breakpointResolved', handleDebuggerDomainEvent],
|
||||
|
||||
// ── Input domain (requires Input.setInterceptDrags) ──
|
||||
['Input.dragIntercepted', handleInputEvent],
|
||||
|
||||
// ── Log domain (requires Log.enable) ──
|
||||
['Log.entryAdded', handleLogEvent],
|
||||
])
|
||||
|
||||
/**
|
||||
* Process a CDP debugger event through registered handlers.
|
||||
*
|
||||
* @param {string} method — CDP event method (e.g. 'Page.javascriptDialogOpening')
|
||||
* @param {number} tabId
|
||||
* @param {object} params — CDP event params
|
||||
* @param {EventContext} ctx
|
||||
* @returns {{ suppress?: boolean }} — if suppress is true, caller should not forward to relay
|
||||
*/
|
||||
export function interceptEvent(method, tabId, params, ctx) {
|
||||
const handler = _handlers.get(method)
|
||||
if (!handler) return {}
|
||||
try {
|
||||
return handler(method, tabId, params, ctx) || {}
|
||||
} catch (err) {
|
||||
ctx.log?.error?.(`event handler threw for ${method} on tab ${tabId}:`, err)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Input domain event handlers.
|
||||
*
|
||||
* Handles `Input.dragIntercepted`.
|
||||
*
|
||||
* Requires: `Input.setInterceptDrags({ enabled: true })` — only fires
|
||||
* when explicitly enabled by the relay/agent.
|
||||
*
|
||||
* CRITICAL: Input.dragIntercepted BLOCKS the drag operation until
|
||||
* `Input.dispatchDragEvent` (type: "drop" or "dragCancel") is sent.
|
||||
*
|
||||
* Strategy:
|
||||
* - Relay/agent owns drag interception. They enable it, they handle it.
|
||||
* - We log and forward. No auto-response.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:input')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleInputEvent(method, tabId, params, _ctx) {
|
||||
if (method === 'Input.dragIntercepted') {
|
||||
log.info(
|
||||
'drag intercepted:', tabId,
|
||||
'items:', params?.data?.items?.length || 0,
|
||||
'dragOpsAllowed:', params?.data?.dragOperationsMask,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Inspector domain event handlers.
|
||||
*
|
||||
* Handles debugger detach reasons, target crashes, and reload after crash.
|
||||
*
|
||||
* No explicit enable required — Inspector events flow automatically
|
||||
* when a debugger is attached.
|
||||
*
|
||||
* IMPORTANT: After Inspector.detached, no further CDP commands can be
|
||||
* sent to this target. Extension should clean up session state.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:inspector')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleInspectorEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
case 'Inspector.detached':
|
||||
log.warn('inspector detached:', tabId, 'reason:', params?.reason)
|
||||
break
|
||||
|
||||
case 'Inspector.targetCrashed':
|
||||
log.error('INSPECTOR: target crashed:', tabId)
|
||||
break
|
||||
|
||||
case 'Inspector.targetReloadedAfterCrash':
|
||||
log.info('target reloaded after crash:', tabId)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Log domain event handlers.
|
||||
*
|
||||
* Handles `Log.entryAdded` — browser-level log entries (distinct from
|
||||
* Runtime.consoleAPICalled which handles page-level console calls).
|
||||
*
|
||||
* Requires: `Log.enable`
|
||||
*
|
||||
* All events are passive (log + forward).
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:log')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleLogEvent(method, tabId, params, _ctx) {
|
||||
if (method === 'Log.entryAdded') {
|
||||
const entry = params?.entry
|
||||
const level = entry?.level || 'info'
|
||||
const text = entry?.text || ''
|
||||
const url = entry?.url || ''
|
||||
|
||||
if (level === 'error') {
|
||||
log.warn('browser log [error]:', tabId, truncate(text, 150), url ? `(${truncate(url, 80)})` : '')
|
||||
} else if (level === 'warning') {
|
||||
log.info('browser log [warn]:', tabId, truncate(text, 150))
|
||||
} else {
|
||||
log.debug('browser log:', tabId, `[${level}]`, truncate(text, 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Network domain event handlers.
|
||||
*
|
||||
* Handles request lifecycle events: sent, received, finished, failed.
|
||||
*
|
||||
* Requires: `Network.enable` — only fires when relay enables Network domain.
|
||||
*
|
||||
* Active handling:
|
||||
* - Network.requestIntercepted: LEGACY interception (prefer Fetch domain).
|
||||
* Blocks request until Network.continueInterceptedRequest is sent.
|
||||
* Forwarded to relay for handling — relay owns the response.
|
||||
*
|
||||
* Passive events: request/response lifecycle, WebSocket, data transfer.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:network')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleNetworkEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
// ── BLOCKING: Legacy interception (relay must respond) ──
|
||||
|
||||
case 'Network.requestIntercepted':
|
||||
log.warn(
|
||||
'request intercepted (legacy):', tabId,
|
||||
'interceptionId:', params?.interceptionId,
|
||||
'url:', truncate(params?.request?.url || '', 120),
|
||||
'isNav:', params?.isNavigationRequest,
|
||||
'hasAuth:', !!params?.authChallenge,
|
||||
)
|
||||
break
|
||||
|
||||
// ── Request lifecycle ──
|
||||
|
||||
case 'Network.requestWillBeSent':
|
||||
log.debug(
|
||||
'request →', tabId,
|
||||
params?.request?.method, truncate(params?.request?.url || '', 100),
|
||||
'type:', params?.type,
|
||||
'requestId:', params?.requestId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Network.responseReceived':
|
||||
log.debug(
|
||||
'response ←', tabId,
|
||||
params?.response?.status, truncate(params?.response?.url || '', 100),
|
||||
'type:', params?.type,
|
||||
'requestId:', params?.requestId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Network.loadingFinished':
|
||||
log.debug(
|
||||
'loading finished:', tabId,
|
||||
'requestId:', params?.requestId,
|
||||
'bytes:', params?.encodedDataLength,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Network.loadingFailed':
|
||||
log.info(
|
||||
'loading failed:', tabId,
|
||||
'requestId:', params?.requestId,
|
||||
'error:', params?.errorText,
|
||||
'type:', params?.type,
|
||||
'canceled:', params?.canceled,
|
||||
'blocked:', params?.blockedReason,
|
||||
)
|
||||
break
|
||||
|
||||
// ── WebSocket events ──
|
||||
|
||||
case 'Network.webSocketCreated':
|
||||
log.debug('WebSocket created:', tabId, 'url:', truncate(params?.url || '', 100))
|
||||
break
|
||||
|
||||
case 'Network.webSocketClosed':
|
||||
log.debug('WebSocket closed:', tabId, 'requestId:', params?.requestId)
|
||||
break
|
||||
|
||||
case 'Network.webSocketFrameError':
|
||||
log.warn('WebSocket error:', tabId, 'error:', params?.errorMessage)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Page lifecycle event handlers.
|
||||
*
|
||||
* Observes page load, navigation, frame management, download, file chooser,
|
||||
* and interstitial events.
|
||||
*
|
||||
* Requires: `Page.enable` (called in debugger-attach.js on attach).
|
||||
*
|
||||
* Active handling:
|
||||
* - Page.fileChooserOpened: logged + forwarded to relay for agent to handle
|
||||
* (requires prior Page.setInterceptFileChooserDialog({ enabled: true }))
|
||||
*
|
||||
* All other events are passive (log + forward to relay).
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:page')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handlePageLifecycleEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
// ── Load events ──
|
||||
|
||||
case 'Page.loadEventFired':
|
||||
log.debug('page loaded:', tabId, 'ts:', params?.timestamp)
|
||||
break
|
||||
|
||||
case 'Page.domContentEventFired':
|
||||
log.debug('DOM content loaded:', tabId, 'ts:', params?.timestamp)
|
||||
break
|
||||
|
||||
case 'Page.lifecycleEvent':
|
||||
log.debug(
|
||||
'lifecycle:', tabId,
|
||||
params?.name,
|
||||
'frameId:', params?.frameId,
|
||||
'loaderId:', params?.loaderId,
|
||||
)
|
||||
break
|
||||
|
||||
// ── Frame events ──
|
||||
|
||||
case 'Page.frameNavigated':
|
||||
log.debug(
|
||||
'frame navigated:', tabId,
|
||||
'url:', truncate(params?.frame?.url || '', 100),
|
||||
'id:', params?.frame?.id,
|
||||
'type:', params?.type,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Page.frameAttached':
|
||||
log.debug(
|
||||
'frame attached:', tabId,
|
||||
'frameId:', params?.frameId,
|
||||
'parentFrameId:', params?.parentFrameId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Page.frameDetached':
|
||||
log.debug(
|
||||
'frame detached:', tabId,
|
||||
'frameId:', params?.frameId,
|
||||
'reason:', params?.reason,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Page.frameStartedLoading':
|
||||
log.debug('frame loading started:', tabId, 'frameId:', params?.frameId)
|
||||
break
|
||||
|
||||
case 'Page.frameStoppedLoading':
|
||||
log.debug('frame loading stopped:', tabId, 'frameId:', params?.frameId)
|
||||
break
|
||||
|
||||
case 'Page.frameStartedNavigating':
|
||||
log.debug(
|
||||
'frame navigating:', tabId,
|
||||
'frameId:', params?.frameId,
|
||||
'url:', truncate(params?.url || '', 100),
|
||||
'type:', params?.navigationType,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Page.navigatedWithinDocument':
|
||||
log.debug(
|
||||
'SPA navigation:', tabId,
|
||||
'frameId:', params?.frameId,
|
||||
'url:', truncate(params?.url || '', 100),
|
||||
'type:', params?.navigationType,
|
||||
)
|
||||
break
|
||||
|
||||
// ── Window / popup events ──
|
||||
|
||||
case 'Page.windowOpen':
|
||||
log.info(
|
||||
'window.open:', tabId,
|
||||
'url:', truncate(params?.url || '', 100),
|
||||
'name:', params?.windowName || '(none)',
|
||||
'features:', params?.windowFeatures?.join(',') || '(none)',
|
||||
'userGesture:', params?.userGesture,
|
||||
)
|
||||
break
|
||||
|
||||
// ── Download events ──
|
||||
|
||||
case 'Page.downloadWillBegin':
|
||||
log.info(
|
||||
'download starting:', tabId,
|
||||
'url:', truncate(params?.url || '', 100),
|
||||
'filename:', params?.suggestedFilename,
|
||||
'guid:', params?.guid,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Page.downloadProgress':
|
||||
if (params?.state === 'completed') {
|
||||
log.info('download completed:', tabId, 'guid:', params?.guid, 'bytes:', params?.totalBytes)
|
||||
} else if (params?.state === 'canceled') {
|
||||
log.info('download canceled:', tabId, 'guid:', params?.guid)
|
||||
}
|
||||
break
|
||||
|
||||
// ── File chooser (requires Page.setInterceptFileChooserDialog) ──
|
||||
|
||||
case 'Page.fileChooserOpened':
|
||||
log.info(
|
||||
'file chooser opened:', tabId,
|
||||
'mode:', params?.mode,
|
||||
'frameId:', params?.frameId,
|
||||
'backendNodeId:', params?.backendNodeId,
|
||||
)
|
||||
break
|
||||
|
||||
// ── Interstitial (SSL/security pages) ──
|
||||
|
||||
case 'Page.interstitialShown':
|
||||
log.warn('interstitial shown (security/SSL page):', tabId)
|
||||
break
|
||||
|
||||
case 'Page.interstitialHidden':
|
||||
log.info('interstitial hidden:', tabId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Runtime domain event handlers.
|
||||
*
|
||||
* Handles JavaScript execution context lifecycle, console API calls,
|
||||
* uncaught exceptions, and binding calls.
|
||||
*
|
||||
* Requires: `Runtime.enable` — the extension enables this via dispatch.js
|
||||
* when the relay sends Runtime.enable command.
|
||||
*
|
||||
* All events are passive (log + forward). No active response needed.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:runtime')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleRuntimeEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
case 'Runtime.exceptionThrown': {
|
||||
const detail = params?.exceptionDetails
|
||||
log.warn(
|
||||
'JS exception:', tabId,
|
||||
'text:', truncate(detail?.text || '', 120),
|
||||
'line:', detail?.lineNumber,
|
||||
'col:', detail?.columnNumber,
|
||||
'url:', truncate(detail?.url || '', 80),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'Runtime.consoleAPICalled':
|
||||
log.debug(
|
||||
'console.' + (params?.type || 'log') + ':', tabId,
|
||||
'args:', params?.args?.length || 0,
|
||||
'contextId:', params?.executionContextId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Runtime.executionContextCreated':
|
||||
log.debug(
|
||||
'context created:', tabId,
|
||||
'id:', params?.context?.id,
|
||||
'name:', params?.context?.name,
|
||||
'origin:', truncate(params?.context?.origin || '', 60),
|
||||
)
|
||||
break
|
||||
|
||||
case 'Runtime.executionContextDestroyed':
|
||||
log.debug(
|
||||
'context destroyed:', tabId,
|
||||
'id:', params?.executionContextId || params?.executionContextUniqueId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Runtime.executionContextsCleared':
|
||||
log.debug('all contexts cleared:', tabId)
|
||||
break
|
||||
|
||||
case 'Runtime.bindingCalled':
|
||||
log.debug(
|
||||
'binding called:', tabId,
|
||||
'name:', params?.name,
|
||||
'payload:', truncate(params?.payload || '', 60),
|
||||
'contextId:', params?.executionContextId,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Runtime.inspectRequested':
|
||||
log.debug('inspect requested:', tabId, 'hints:', JSON.stringify(params?.hints))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Security domain event handlers.
|
||||
*
|
||||
* Handles certificate errors and security state changes.
|
||||
*
|
||||
* Requires: `Security.enable` for visibleSecurityStateChanged.
|
||||
* Certificate error handling additionally requires:
|
||||
* `Security.setOverrideCertificateErrors({ override: true })`
|
||||
*
|
||||
* NOTE: Security.certificateError / Security.handleCertificateError
|
||||
* is DEPRECATED. For new automation setups, prefer using Chrome's
|
||||
* --ignore-certificate-errors launch flag instead.
|
||||
*
|
||||
* Strategy:
|
||||
* - Security.certificateError → auto-continue (don't block automation)
|
||||
* - Security.visibleSecurityStateChanged → log security state
|
||||
* - Security.securityStateChanged → log overall security state
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:security')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} _ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleSecurityEvent(method, tabId, params, _ctx) {
|
||||
switch (method) {
|
||||
case 'Security.certificateError':
|
||||
log.warn(
|
||||
'certificate error:', tabId,
|
||||
'type:', params?.errorType,
|
||||
'url:', params?.requestURL,
|
||||
'eventId:', params?.eventId,
|
||||
)
|
||||
if (params?.eventId !== undefined) {
|
||||
chrome.debugger
|
||||
.sendCommand({ tabId }, 'Security.handleCertificateError', {
|
||||
eventId: params.eventId,
|
||||
action: 'continue',
|
||||
})
|
||||
.then(() => log.debug('cert error auto-continued:', tabId))
|
||||
.catch((err) => log.warn('cert error handle failed:', tabId, err?.message || err))
|
||||
}
|
||||
break
|
||||
|
||||
case 'Security.visibleSecurityStateChanged': {
|
||||
const state = params?.visibleSecurityState
|
||||
log.debug(
|
||||
'visible security state:', tabId,
|
||||
'securityState:', state?.securityState,
|
||||
'hasCertError:', !!state?.certificateSecurityState?.certificateHasWeakSignature,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'Security.securityStateChanged':
|
||||
log.debug(
|
||||
'security state changed:', tabId,
|
||||
'state:', params?.securityState,
|
||||
'schemeIsCrypto:', params?.schemeIsCryptographic,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Target domain event handlers.
|
||||
*
|
||||
* Tracks child sessions (iframes, workers) attached/detached under a parent
|
||||
* tab's debugger session, and monitors target lifecycle (crash, create, destroy).
|
||||
*
|
||||
* No explicit enable required — Target events flow after debugger attach.
|
||||
* `Target.setDiscoverTargets` can be used for additional discovery events.
|
||||
*
|
||||
* Migrated from inline logic in TabManager.onDebuggerEvent + expanded.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('evt:target')
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {number} tabId
|
||||
* @param {object} params
|
||||
* @param {import('./index.js').EventContext} ctx
|
||||
* @returns {{ suppress?: boolean } | void}
|
||||
*/
|
||||
export function handleTargetEvent(method, tabId, params, ctx) {
|
||||
switch (method) {
|
||||
case 'Target.attachedToTarget':
|
||||
if (params?.sessionId) {
|
||||
const sid = String(params.sessionId)
|
||||
ctx.childSession.set(sid, tabId)
|
||||
let children = ctx.childSets.get(tabId)
|
||||
if (!children) {
|
||||
children = new Set()
|
||||
ctx.childSets.set(tabId, children)
|
||||
}
|
||||
children.add(sid)
|
||||
log.debug(
|
||||
'child session attached:', tabId, sid,
|
||||
'type:', params.targetInfo?.type,
|
||||
'url:', truncate(params.targetInfo?.url || '', 80),
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Target.detachedFromTarget':
|
||||
if (params?.sessionId) {
|
||||
const sid = String(params.sessionId)
|
||||
ctx.childSession.delete(sid)
|
||||
ctx.childSets.get(tabId)?.delete(sid)
|
||||
log.debug('child session detached:', tabId, sid)
|
||||
}
|
||||
break
|
||||
|
||||
case 'Target.targetCreated':
|
||||
log.info(
|
||||
'target created:', tabId,
|
||||
'targetId:', params?.targetInfo?.targetId,
|
||||
'type:', params?.targetInfo?.type,
|
||||
'url:', truncate(params?.targetInfo?.url || '', 80),
|
||||
)
|
||||
break
|
||||
|
||||
case 'Target.targetDestroyed':
|
||||
log.info('target destroyed:', tabId, 'targetId:', params?.targetId)
|
||||
break
|
||||
|
||||
case 'Target.targetCrashed':
|
||||
log.warn(
|
||||
'TARGET CRASHED:', tabId,
|
||||
'targetId:', params?.targetId,
|
||||
'status:', params?.status,
|
||||
'errorCode:', params?.errorCode,
|
||||
)
|
||||
break
|
||||
|
||||
case 'Target.targetInfoChanged':
|
||||
log.debug(
|
||||
'target info changed:', tabId,
|
||||
'targetId:', params?.targetInfo?.targetId,
|
||||
'title:', truncate(params?.targetInfo?.title || '', 60),
|
||||
'url:', truncate(params?.targetInfo?.url || '', 80),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
return str.length > max ? str.slice(0, max) + '…' : str
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* CDP channel — Chrome DevTools Protocol relay infrastructure.
|
||||
*
|
||||
* relay/ — WebSocket connection & state machine
|
||||
* tabs/ — Tab lifecycle, discovery, idle detection, tab groups
|
||||
* commands/ — CDP command routing & Target.* handlers
|
||||
* events/ — CDP event interception (dialogs, security, page lifecycle)
|
||||
*/
|
||||
|
||||
export { TabManager } from './tabs/manager.js'
|
||||
export { createDispatcher } from './commands/dispatch.js'
|
||||
export {
|
||||
initRelay,
|
||||
trySendToRelay,
|
||||
isRelayConnected,
|
||||
isRelayActive,
|
||||
isRelayEnabled,
|
||||
isReconnecting,
|
||||
getRelayState,
|
||||
toggle,
|
||||
disconnect,
|
||||
connectAndAttach,
|
||||
initFromStorage,
|
||||
setRelayEnabled,
|
||||
getLogBuffer,
|
||||
ensureKeepAliveAlarm,
|
||||
handleConnectionAlarm,
|
||||
} from './relay/connection.js'
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Relay connection management.
|
||||
*
|
||||
* 3-state model:
|
||||
* disabled — relay off, badge empty
|
||||
* disconnected — relay enabled but WS not open, badge …, auto-reconnecting
|
||||
* connected — relay enabled + WS open, badge ON
|
||||
*
|
||||
* Timer strategy (MV3 compatible):
|
||||
* All deferred work uses chrome.alarms instead of setTimeout/setInterval
|
||||
* so that timers survive Service Worker suspension.
|
||||
*
|
||||
* Alarm names:
|
||||
* relayKeepAlive — 1-min heartbeat, created when relay is enabled
|
||||
* relayReconnect — exponential-backoff reconnect delay
|
||||
* relayDisconnectNotify — delayed disconnect notification (5 s)
|
||||
*/
|
||||
|
||||
import { RelayState, STATE_UI, DEFAULT_CONTROL_PORT, RELAY_PORT_OFFSET, clampPort, computeRelayPort } from '../../constants.js'
|
||||
import { setIconWithDot } from '../../icon-badge.js'
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('relay')
|
||||
|
||||
const WS_CONNECT_TIMEOUT = 5000
|
||||
const PREFLIGHT_TIMEOUT = 2000
|
||||
const DISCONNECT_NOTIFY_DELAY_MIN = 5 / 60 // 5 seconds in minutes (chrome.alarms minimum granularity is ~1 s via delayInMinutes)
|
||||
|
||||
// ── Alarm names (centralized) ──
|
||||
|
||||
const ALARM_KEEP_ALIVE = 'relayKeepAlive'
|
||||
const ALARM_RECONNECT = 'relayReconnect'
|
||||
const ALARM_DISCONNECT_NOTIFY = 'relayDisconnectNotify'
|
||||
|
||||
// ── Operation log ring buffer (O(1) push, O(n) read) ──
|
||||
const LOG_BUFFER_MAX = 200
|
||||
const _logRing = new Array(LOG_BUFFER_MAX)
|
||||
let _logHead = 0 // next write position
|
||||
let _logCount = 0 // total entries written (clamped to max)
|
||||
|
||||
function pushLog(direction, method, detail) {
|
||||
_logRing[_logHead] = { ts: Date.now(), dir: direction, method, detail }
|
||||
_logHead = (_logHead + 1) % LOG_BUFFER_MAX
|
||||
if (_logCount < LOG_BUFFER_MAX) _logCount++
|
||||
}
|
||||
|
||||
export function getLogBuffer(limit = 100) {
|
||||
const n = Math.min(limit, _logCount)
|
||||
const result = new Array(n)
|
||||
// read oldest-first: start = (head - count) mod max, then advance
|
||||
let idx = (_logHead - _logCount + LOG_BUFFER_MAX) % LOG_BUFFER_MAX
|
||||
const skip = _logCount - n
|
||||
idx = (idx + skip) % LOG_BUFFER_MAX
|
||||
for (let i = 0; i < n; i++) {
|
||||
result[i] = _logRing[idx]
|
||||
idx = (idx + 1) % LOG_BUFFER_MAX
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── State model ──
|
||||
|
||||
let _state = RelayState.DISABLED
|
||||
|
||||
function setState(newState) {
|
||||
_state = newState
|
||||
const ui = STATE_UI[newState]
|
||||
void setIconWithDot(ui.dotColor).catch(() => {})
|
||||
void chrome.action.setTitle({ title: ui.title })
|
||||
void chrome.storage.local.set({ _relayState: newState })
|
||||
}
|
||||
|
||||
export function getRelayState() {
|
||||
return _state
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
|
||||
function notifyError(title, message) {
|
||||
chrome.notifications.create('accio-relay-error', {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: `Lime Bridge: ${title}`,
|
||||
message,
|
||||
priority: 2,
|
||||
}, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.warn('[lime-relay] notification create failed:', chrome.runtime.lastError.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Port helpers ──
|
||||
|
||||
async function getControlPort() {
|
||||
const stored = await chrome.storage.local.get(['controlPort', 'relayPort'])
|
||||
const raw = Number.parseInt(String(stored.controlPort || ''), 10)
|
||||
if (Number.isFinite(raw) && raw > 0 && raw <= 65535) return raw
|
||||
const relayRaw = Number.parseInt(String(stored.relayPort || ''), 10)
|
||||
if (Number.isFinite(relayRaw) && relayRaw > 0 && relayRaw <= 65535) {
|
||||
const inferred = clampPort(relayRaw - RELAY_PORT_OFFSET)
|
||||
void chrome.storage.local.set({ controlPort: inferred })
|
||||
return inferred
|
||||
}
|
||||
return DEFAULT_CONTROL_PORT
|
||||
}
|
||||
|
||||
async function getRelayPort() {
|
||||
return computeRelayPort(await getControlPort())
|
||||
}
|
||||
|
||||
// ── Relay enabled persistence ──
|
||||
|
||||
export async function setRelayEnabled(enabled) {
|
||||
await chrome.storage.local.set({ relayEnabled: enabled })
|
||||
}
|
||||
|
||||
export async function isRelayEnabled() {
|
||||
const stored = await chrome.storage.local.get(['relayEnabled'])
|
||||
return stored.relayEnabled === true
|
||||
}
|
||||
|
||||
// ── Connection state ──
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
/** @type {AbortController|null} */
|
||||
let connectAbortCtrl = null
|
||||
|
||||
let reconnectAttempt = 0
|
||||
|
||||
/**
|
||||
* Callbacks injected by background.js at init time.
|
||||
*
|
||||
* onShutdown(reason) merges the old onClosed + onDisabled into a single
|
||||
* atomic callback so callers can handle group dissolution and tab cleanup
|
||||
* in one place, avoiding ordering bugs.
|
||||
*
|
||||
* @type {{ onMessage: (msg: any) => Promise<any>, onShutdown: (reason: 'connectionLost'|'disabled') => Promise<any>|void, onConnected: () => void, installDebuggerListeners: () => void }}
|
||||
*/
|
||||
let callbacks = {
|
||||
onMessage: async () => null,
|
||||
onShutdown: async (_reason) => {},
|
||||
onConnected: () => {},
|
||||
installDebuggerListeners: () => {},
|
||||
}
|
||||
|
||||
export function initRelay(cbs) {
|
||||
callbacks = { ...callbacks, ...cbs }
|
||||
}
|
||||
|
||||
// ── Derived state queries ──
|
||||
|
||||
export function isRelayConnected() {
|
||||
return relayWs !== null && relayWs.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
export function isRelayActive() {
|
||||
return isRelayConnected() || relayConnectPromise !== null ||
|
||||
(relayWs !== null && relayWs.readyState === WebSocket.CONNECTING)
|
||||
}
|
||||
|
||||
export function isReconnecting() {
|
||||
return _reconnectPending
|
||||
}
|
||||
|
||||
// ── Messaging ──
|
||||
|
||||
export function sendToRelay(payload) {
|
||||
const ws = relayWs
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Relay not connected')
|
||||
}
|
||||
pushLog('↑', payload.method || `id:${payload.id}`, payload.params?.method || payload.error || '')
|
||||
ws.send(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function trySendToRelay(payload) {
|
||||
try {
|
||||
sendToRelay(payload)
|
||||
} catch (err) {
|
||||
console.debug('[lime-relay] trySendToRelay failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alarm-based timer management ──
|
||||
|
||||
/**
|
||||
* Ensure the keep-alive alarm exists. Safe to call multiple times —
|
||||
* chrome.alarms.create with the same name replaces the previous alarm.
|
||||
*/
|
||||
export function ensureKeepAliveAlarm() {
|
||||
chrome.alarms.create(ALARM_KEEP_ALIVE, { periodInMinutes: 1 })
|
||||
}
|
||||
|
||||
function clearKeepAliveAlarm() {
|
||||
chrome.alarms.clear(ALARM_KEEP_ALIVE)
|
||||
}
|
||||
|
||||
let _reconnectPending = false
|
||||
|
||||
function cancelReconnect() {
|
||||
_reconnectPending = false
|
||||
chrome.alarms.clear(ALARM_RECONNECT)
|
||||
}
|
||||
|
||||
function cancelDisconnectNotify() {
|
||||
chrome.alarms.clear(ALARM_DISCONNECT_NOTIFY)
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (_reconnectPending) return
|
||||
const baseMs = 500
|
||||
const maxMs = 30_000
|
||||
const baseDelay = Math.min(maxMs, baseMs * Math.pow(2, reconnectAttempt))
|
||||
let delay = baseDelay + Math.floor(Math.random() * Math.min(baseDelay, 1000))
|
||||
if (!Number.isFinite(delay) || delay < baseMs) delay = baseMs
|
||||
reconnectAttempt++
|
||||
_reconnectPending = true
|
||||
// chrome.alarms minimum delay is ~1 second; convert ms → minutes
|
||||
const delayInMinutes = Math.max(delay / 60_000, 1 / 60)
|
||||
chrome.alarms.create(ALARM_RECONNECT, { delayInMinutes })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle alarm events relevant to relay connection.
|
||||
* Called from background.js onAlarm listener.
|
||||
* Returns true if the alarm was handled.
|
||||
*/
|
||||
export function handleConnectionAlarm(alarmName) {
|
||||
if (alarmName === ALARM_RECONNECT) {
|
||||
_reconnectPending = false
|
||||
// connectAndAttach auto-schedules reconnect on failure
|
||||
void connectAndAttach()
|
||||
return true
|
||||
}
|
||||
|
||||
if (alarmName === ALARM_DISCONNECT_NOTIFY) {
|
||||
if (_state === RelayState.DISCONNECTED) {
|
||||
notifyError('Disconnected', 'Relay connection lost. Auto-reconnecting…')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ── WebSocket connection ──
|
||||
|
||||
let debuggerListenersInstalled = false
|
||||
|
||||
async function ensureConnection() {
|
||||
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
||||
if (relayConnectPromise) return await relayConnectPromise
|
||||
|
||||
const currentAbortCtrl = new AbortController()
|
||||
connectAbortCtrl = currentAbortCtrl
|
||||
const { signal } = currentAbortCtrl
|
||||
|
||||
// Assign immediately so concurrent callers see the in-flight promise (avoids race)
|
||||
relayConnectPromise = (async () => {
|
||||
const port = await getRelayPort()
|
||||
const httpBase = `http://127.0.0.1:${port}`
|
||||
const wsUrl = `ws://127.0.0.1:${port}/extension`
|
||||
|
||||
const preflightCtrl = new AbortController()
|
||||
const preflightTimer = setTimeout(() => preflightCtrl.abort(), PREFLIGHT_TIMEOUT)
|
||||
if (signal) signal.addEventListener('abort', () => preflightCtrl.abort(), { once: true })
|
||||
try {
|
||||
await fetch(`${httpBase}/`, { method: 'HEAD', signal: preflightCtrl.signal })
|
||||
.catch((err) => {
|
||||
if (signal.aborted) throw new Error('Connection cancelled')
|
||||
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(preflightTimer)
|
||||
}
|
||||
|
||||
if (signal.aborted) throw new Error('Connection cancelled')
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), WS_CONNECT_TIMEOUT)
|
||||
const cleanup = () => { clearTimeout(t); signal.removeEventListener('abort', onAbort) }
|
||||
const onAbort = () => { cleanup(); reject(new Error('Connection cancelled')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
ws.onopen = () => { cleanup(); resolve() }
|
||||
ws.onerror = () => { cleanup(); reject(new Error('WebSocket connect failed')) }
|
||||
ws.onclose = (ev) => { cleanup(); reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`)) }
|
||||
})
|
||||
} catch (err) {
|
||||
try { ws.close() } catch { /* already closing */ }
|
||||
throw err
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
try { ws.close() } catch { /* noop */ }
|
||||
throw new Error('Connection cancelled')
|
||||
}
|
||||
|
||||
relayWs = ws
|
||||
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
|
||||
ws.onclose = () => { if (relayWs === ws) onRelayClosed('closed') }
|
||||
ws.onerror = () => { if (relayWs === ws) onRelayClosed('error') }
|
||||
|
||||
if (!debuggerListenersInstalled) {
|
||||
debuggerListenersInstalled = true
|
||||
callbacks.installDebuggerListeners()
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await relayConnectPromise
|
||||
} finally {
|
||||
if (connectAbortCtrl === currentAbortCtrl) connectAbortCtrl = null
|
||||
relayConnectPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function onRelayClosed(reason) {
|
||||
log.info('onRelayClosed:', reason)
|
||||
relayWs = null
|
||||
|
||||
void Promise.resolve(callbacks.onShutdown('connectionLost')).catch((err) => {
|
||||
log.warn('onRelayClosed: onShutdown error:', err)
|
||||
})
|
||||
|
||||
setState(RelayState.DISCONNECTED)
|
||||
|
||||
cancelDisconnectNotify()
|
||||
chrome.alarms.create(ALARM_DISCONNECT_NOTIFY, { delayInMinutes: DISCONNECT_NOTIFY_DELAY_MIN })
|
||||
|
||||
void (async () => {
|
||||
if (await isRelayEnabled()) scheduleReconnect()
|
||||
})()
|
||||
}
|
||||
|
||||
async function onRelayMessage(text) {
|
||||
/** @type {any} */
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(text)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (msg?.method === 'ping') {
|
||||
trySendToRelay({ method: 'pong' })
|
||||
return
|
||||
}
|
||||
|
||||
const MESSAGE_EXPIRE_MS = 130_000
|
||||
if (typeof msg?.ts === 'number') {
|
||||
const age = Date.now() - msg.ts
|
||||
if (age > MESSAGE_EXPIRE_MS) {
|
||||
log.warn('dropping expired message:', msg.method || msg.id, 'age:', age, 'ms')
|
||||
if (typeof msg.id === 'number') {
|
||||
trySendToRelay({ id: msg.id, error: `Message expired (age ${age}ms > ${MESSAGE_EXPIRE_MS}ms)` })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof msg?.id === 'number' && msg.method === 'forwardCDPCommand') {
|
||||
const cdpMethod = msg.params?.method || ''
|
||||
pushLog('↓', msg.method, cdpMethod)
|
||||
try {
|
||||
const result = await callbacks.onMessage(msg)
|
||||
trySendToRelay({ id: msg.id, result })
|
||||
} catch (err) {
|
||||
trySendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public lifecycle methods ──
|
||||
|
||||
/**
|
||||
* Attempt to connect. On failure, automatically schedules reconnect
|
||||
* if relay is still enabled — callers do NOT need to call scheduleReconnect.
|
||||
*/
|
||||
export async function connectAndAttach() {
|
||||
const t0 = performance.now()
|
||||
log.debug('connectAndAttach: begin, connected:', isRelayConnected())
|
||||
if (isRelayConnected()) return true
|
||||
if (!(await isRelayEnabled())) { log.debug('connectAndAttach: relay not enabled'); return false }
|
||||
try {
|
||||
await ensureConnection()
|
||||
log.info('connectAndAttach: connected in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
cancelDisconnectNotify()
|
||||
setState(RelayState.CONNECTED)
|
||||
reconnectAttempt = 0
|
||||
void callbacks.onConnected()
|
||||
return true
|
||||
} catch (err) {
|
||||
log.warn('connectAndAttach: failed in', (performance.now() - t0).toFixed(1), 'ms', err)
|
||||
if (await isRelayEnabled()) scheduleReconnect()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable relay: close WS, cancel timers, set state to disabled.
|
||||
*
|
||||
* The sync neutralization block (relayWs = null, cancel timers, abort connect)
|
||||
* runs BEFORE any await so that isRelayConnected() returns false immediately.
|
||||
*
|
||||
* onShutdown('disabled') handles both group dissolution and tab cleanup
|
||||
* atomically — the callback decides internally what to do based on the reason.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
const t0 = performance.now()
|
||||
log.info('disconnect: begin, current state:', _state, 'ws:', relayWs ? 'open' : 'null')
|
||||
|
||||
const wsToClose = relayWs
|
||||
relayWs = null
|
||||
|
||||
cancelReconnect()
|
||||
reconnectAttempt = 0
|
||||
cancelDisconnectNotify()
|
||||
clearKeepAliveAlarm()
|
||||
|
||||
if (connectAbortCtrl) {
|
||||
connectAbortCtrl.abort()
|
||||
connectAbortCtrl = null
|
||||
}
|
||||
relayConnectPromise = null
|
||||
|
||||
if (wsToClose) {
|
||||
log.debug('disconnect: closing WebSocket')
|
||||
try { wsToClose.close() } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
log.debug('disconnect: awaiting storage write + shutdown...')
|
||||
await Promise.all([
|
||||
setRelayEnabled(false),
|
||||
Promise.resolve(callbacks.onShutdown('disabled')).catch((err) => {
|
||||
log.warn('disconnect: onShutdown(disabled) failed:', err)
|
||||
}),
|
||||
])
|
||||
|
||||
log.info('disconnect: done in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
|
||||
setState(RelayState.DISABLED)
|
||||
}
|
||||
|
||||
export async function toggle() {
|
||||
const t0 = performance.now()
|
||||
if (_state !== RelayState.DISABLED) {
|
||||
await disconnect()
|
||||
log.info('toggle: disconnect done in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
return
|
||||
}
|
||||
|
||||
await setRelayEnabled(true)
|
||||
ensureKeepAliveAlarm()
|
||||
setState(RelayState.DISCONNECTED)
|
||||
|
||||
try {
|
||||
cancelReconnect()
|
||||
await ensureConnection()
|
||||
cancelDisconnectNotify()
|
||||
setState(RelayState.CONNECTED)
|
||||
void callbacks.onConnected()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('cancelled')) return
|
||||
console.warn('[lime-relay] initial connect failed:', message)
|
||||
notifyError('Connection Failed', `${message}\n\nRetrying automatically…`)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export async function initFromStorage() {
|
||||
if (await isRelayEnabled()) {
|
||||
ensureKeepAliveAlarm()
|
||||
setState(RelayState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Agent tab group management.
|
||||
*
|
||||
* Creates and manages a Chrome tab group labeled "Lime Agent"
|
||||
* to visually organize agent-controlled tabs.
|
||||
*
|
||||
* All addTab calls are serialized via a promise queue to prevent
|
||||
* concurrent calls from each creating separate tab groups.
|
||||
*
|
||||
* On reconnect, the manager attempts to find and reuse an existing
|
||||
* "Lime Agent" group rather than creating a duplicate.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('group')
|
||||
|
||||
export const TAB_GROUP_TITLE = 'Lime Agent'
|
||||
const TAB_GROUP_COLOR = 'blue'
|
||||
|
||||
export class AgentGroupManager {
|
||||
/** @type {number|null} */
|
||||
#groupId = null
|
||||
/** @type {Promise<void>} serialization queue */
|
||||
#queue = Promise.resolve()
|
||||
|
||||
get groupId() { return this.#groupId }
|
||||
|
||||
reset() { this.#groupId = null }
|
||||
|
||||
async addTab(tabId) {
|
||||
const done = this.#queue.then(() => this.#doAddTab(tabId))
|
||||
this.#queue = done.catch(() => {})
|
||||
return done
|
||||
}
|
||||
|
||||
async #doAddTab(tabId) {
|
||||
try {
|
||||
if (this.#groupId !== null) {
|
||||
try {
|
||||
await chrome.tabGroups.get(this.#groupId)
|
||||
} catch {
|
||||
this.#groupId = null
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#groupId === null) {
|
||||
await this.#tryRecoverExistingGroup()
|
||||
}
|
||||
|
||||
const groupId = await chrome.tabs.group({
|
||||
tabIds: [tabId],
|
||||
...(this.#groupId !== null ? { groupId: this.#groupId } : {}),
|
||||
})
|
||||
|
||||
if (this.#groupId === null || this.#groupId !== groupId) {
|
||||
this.#groupId = groupId
|
||||
await chrome.tabGroups.update(groupId, {
|
||||
title: TAB_GROUP_TITLE,
|
||||
color: TAB_GROUP_COLOR,
|
||||
collapsed: false,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('addTab: failed for tab', tabId, err)
|
||||
}
|
||||
}
|
||||
|
||||
async #tryRecoverExistingGroup() {
|
||||
try {
|
||||
const groups = await chrome.tabGroups.query({ title: TAB_GROUP_TITLE })
|
||||
if (groups.length > 0) {
|
||||
this.#groupId = groups[0].id
|
||||
log.info('recovered existing group:', this.#groupId)
|
||||
}
|
||||
} catch {
|
||||
// chrome.tabGroups.query not available or no match
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dissolve the tab group: close agent-created tabs, ungroup the rest.
|
||||
*
|
||||
* @param {Set<number>} [agentTabIds] — tab IDs created by the agent.
|
||||
* These will be closed (chrome.tabs.remove). Tabs NOT in this set
|
||||
* are assumed to be user-owned and will only be ungrouped.
|
||||
* If omitted, all tabs in the group are ungrouped without closing.
|
||||
*
|
||||
* Falls back to finding the group by title because clearAll() may have
|
||||
* already reset #groupId to null before this method is called.
|
||||
*/
|
||||
async dissolve(agentTabIds) {
|
||||
let gid = this.#groupId
|
||||
this.#groupId = null
|
||||
log.info('dissolve: start, cached groupId =', gid, 'agentTabIds count =', agentTabIds?.size ?? 0)
|
||||
|
||||
if (gid === null) {
|
||||
try {
|
||||
const groups = await chrome.tabGroups.query({ title: TAB_GROUP_TITLE })
|
||||
log.info('dissolve: query found', groups.length, 'groups')
|
||||
if (groups.length > 0) gid = groups[0].id
|
||||
} catch (err) {
|
||||
log.warn('dissolve: tabGroups.query failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (gid === null) {
|
||||
log.info('dissolve: no group found, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ groupId: gid })
|
||||
log.info('dissolve: group', gid, 'has', tabs.length, 'tabs')
|
||||
if (tabs.length === 0) {
|
||||
log.info('dissolved group:', gid, '(already empty)')
|
||||
return
|
||||
}
|
||||
|
||||
const allIds = tabs.map((t) => t.id).filter((id) => id != null)
|
||||
const toClose = agentTabIds
|
||||
? allIds.filter((id) => agentTabIds.has(id))
|
||||
: []
|
||||
const toUngroup = agentTabIds
|
||||
? allIds.filter((id) => !agentTabIds.has(id))
|
||||
: allIds
|
||||
|
||||
if (toClose.length > 0) {
|
||||
await chrome.tabs.remove(toClose)
|
||||
log.info('dissolve: closed', toClose.length, 'agent tabs')
|
||||
}
|
||||
if (toUngroup.length > 0) {
|
||||
await chrome.tabs.ungroup(toUngroup)
|
||||
log.info('dissolve: ungrouped', toUngroup.length, 'user tabs')
|
||||
}
|
||||
log.info('dissolved group:', gid, 'closed', toClose.length, 'ungrouped', toUngroup.length)
|
||||
} catch (err) {
|
||||
log.warn('dissolve: failed for group', gid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Low-level chrome.debugger API operations.
|
||||
*
|
||||
* Pure chrome.debugger interactions with no state management or relay awareness.
|
||||
* Used by TabManager for the physical attach/detach lifecycle.
|
||||
*
|
||||
* Domain enable strategy on attach:
|
||||
* Page.enable — always (dialog handling, page lifecycle events)
|
||||
*
|
||||
* Domains enabled on-demand by relay via CDP commands (through dispatch.js):
|
||||
* Network.enable — request lifecycle (high traffic, enable only when needed)
|
||||
* Runtime.enable — JS contexts, console, exceptions
|
||||
* Security.enable — security state changes, cert errors (deprecated)
|
||||
* Fetch.enable — request interception (must specify patterns)
|
||||
* Log.enable — browser-level log entries
|
||||
* Debugger.enable — breakpoints, script parsing (pauses execution)
|
||||
* DOM.enable — DOM mutation observation
|
||||
*
|
||||
* See lib/cdp/events/ for comprehensive event handling across all domains.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('dbg')
|
||||
|
||||
/**
|
||||
* Physically attach the Chrome debugger to a tab.
|
||||
*
|
||||
* @param {number} tabId
|
||||
* @returns {Promise<{ realTargetId: string }>} The real CDP targetId assigned by Chrome
|
||||
* @throws {Error} If attachment fails
|
||||
*/
|
||||
export async function attachDebugger(tabId) {
|
||||
const t0 = performance.now()
|
||||
const debuggee = { tabId }
|
||||
|
||||
await chrome.debugger.attach(debuggee, '1.3')
|
||||
log.debug('attach took', (performance.now() - t0).toFixed(1), 'ms', tabId)
|
||||
|
||||
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch((err) => {
|
||||
log.warn('Page.enable failed', tabId, err)
|
||||
})
|
||||
|
||||
const info = /** @type {any} */ (
|
||||
await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo')
|
||||
)
|
||||
const realTargetId = String(info?.targetInfo?.targetId || '').trim()
|
||||
|
||||
log.debug('attached', tabId, 'targetId:', realTargetId, 'in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
return { realTargetId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the Chrome debugger from a tab. Fails silently.
|
||||
* @param {number} tabId
|
||||
*/
|
||||
export async function detachDebugger(tabId) {
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId })
|
||||
} catch {
|
||||
// Already detached or tab closed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach multiple tabs in parallel. Returns a settled promise.
|
||||
* @param {number[]} tabIds
|
||||
*/
|
||||
export function detachAll(tabIds) {
|
||||
const promises = tabIds.map(
|
||||
(tabId) => chrome.debugger.detach({ tabId })
|
||||
.then(() => log.debug('detachAll: OK', tabId))
|
||||
.catch((err) => log.debug('detachAll: failed', tabId, err)),
|
||||
)
|
||||
return Promise.allSettled(promises)
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* TabManager — Lazy Attach architecture.
|
||||
*
|
||||
* Encapsulates all tab state: discovery, lazy physical attach, agent tracking.
|
||||
*
|
||||
* Tab lifecycle:
|
||||
* virtual → discovered by chrome.tabs.query, no chrome.debugger session
|
||||
* attaching → chrome.debugger.attach() in flight
|
||||
* connected → physically attached via chrome.debugger
|
||||
*
|
||||
* Relay notifications:
|
||||
* Extension.tabDiscovered — virtual tab discovered (relay stores, Playwright ignores)
|
||||
* Extension.tabUpdated — virtual tab metadata (title/url) changed
|
||||
* Extension.tabRemoved — virtual tab removed from cache
|
||||
* Target.attachedToTarget — physical debugger attached (Playwright sees this)
|
||||
* Target.detachedFromTarget — physical debugger detached (Playwright sees this)
|
||||
*
|
||||
* Delegates to:
|
||||
* SessionIndicators — spinner animation + idle tab detection
|
||||
* AgentGroupManager — Chrome tab group management
|
||||
*/
|
||||
|
||||
import { TabType } from '../../constants.js'
|
||||
import { createLogger } from '../../logger.js'
|
||||
import { attachDebugger, detachDebugger, detachAll } from './debugger-attach.js'
|
||||
import { SessionIndicators } from './session-indicators.js'
|
||||
import { AgentGroupManager } from './agent-group.js'
|
||||
import { cleanupTabQueue, cleanupAllTabQueues } from '../commands/dispatch.js'
|
||||
import { interceptEvent } from '../events/index.js'
|
||||
|
||||
const log = createLogger('tabs')
|
||||
|
||||
const CANCELLED_TABS_KEY = 'accio_cancelledTabs'
|
||||
|
||||
let _sessionSeq = 0
|
||||
|
||||
const DEBUGGABLE_URL_RE = /^(https?|file):\/\//
|
||||
|
||||
function isDebuggableUrl(url) {
|
||||
if (!url) return false
|
||||
return DEBUGGABLE_URL_RE.test(url) || url === 'about:blank'
|
||||
}
|
||||
|
||||
export class TabManager {
|
||||
/** @type {Map<number, {state: string, sessionId: string, targetId: string, url?: string, title?: string}>} */
|
||||
#tabs = new Map()
|
||||
/** @type {Map<string, number>} sessionId → tabId */
|
||||
#bySession = new Map()
|
||||
/** @type {Map<string, number>} targetId → tabId */
|
||||
#byTarget = new Map()
|
||||
/** @type {Map<string, number>} child sessionId → parent tabId */
|
||||
#childSession = new Map()
|
||||
/** @type {Map<number, Set<string>>} parent tabId → child sessionIds */
|
||||
#childSets = new Map()
|
||||
/** @type {Map<number, string>} tabId → TabType */
|
||||
#agentTabs = new Map()
|
||||
/** @type {Set<number>} */
|
||||
#cancelled = new Set()
|
||||
/** @type {Map<number, Promise<boolean>>} tabId → pending attach promise */
|
||||
#pending = new Map()
|
||||
#retainedCount = 0
|
||||
/** @type {boolean} */
|
||||
#shuttingDown = false
|
||||
/** @type {number|null} */
|
||||
#lastAttached = null
|
||||
/** @type {(payload: any) => void} */
|
||||
#sendToRelay
|
||||
|
||||
/** @type {SessionIndicators} */
|
||||
#indicators
|
||||
/** @type {AgentGroupManager} */
|
||||
#group
|
||||
|
||||
/**
|
||||
* @param {(payload: any) => void} sendToRelay — fire-and-forget relay send function
|
||||
*/
|
||||
constructor(sendToRelay) {
|
||||
this.#sendToRelay = sendToRelay
|
||||
this.#group = new AgentGroupManager()
|
||||
this.#indicators = new SessionIndicators({
|
||||
getGroupId: () => this.#group.groupId,
|
||||
getTabEntries: () => this.#tabs.entries(),
|
||||
detachTab: (tabId, reason) => void this.detach(tabId, reason),
|
||||
})
|
||||
}
|
||||
|
||||
// ── CDP command tracking (forwarded from dispatch) ──
|
||||
|
||||
onCdpCommand(tabId) {
|
||||
this.#indicators.trackCommand(tabId)
|
||||
}
|
||||
|
||||
// ── Session lifecycle ──
|
||||
|
||||
startSessionIndicators() {
|
||||
this.#indicators.start()
|
||||
}
|
||||
|
||||
stopSessionIndicators() {
|
||||
this.#indicators.stop()
|
||||
}
|
||||
|
||||
handleIndicatorAlarm(alarmName) {
|
||||
return this.#indicators.handleAlarm(alarmName)
|
||||
}
|
||||
|
||||
// ── Tab group ──
|
||||
|
||||
async addToAgentGroup(tabId) {
|
||||
await this.#group.addTab(tabId)
|
||||
}
|
||||
|
||||
async dissolveAgentGroup() {
|
||||
const agentIds = new Set(this.#agentTabs.keys())
|
||||
await this.#group.dissolve(agentIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic shutdown: optionally dissolve agent group, then clear all state.
|
||||
* Dissolution runs BEFORE clearAll() so that the groupId and agentTabs
|
||||
* are still available for deciding which tabs to close vs ungroup.
|
||||
*/
|
||||
async shutdown({ dissolveGroup = false } = {}) {
|
||||
if (dissolveGroup) {
|
||||
await this.dissolveAgentGroup()
|
||||
}
|
||||
return this.clearAll()
|
||||
}
|
||||
|
||||
// ── Tab state queries ──
|
||||
|
||||
get size() { return this.#tabs.size }
|
||||
has(tabId) { return this.#tabs.has(tabId) }
|
||||
get(tabId) { return this.#tabs.get(tabId) }
|
||||
entries() { return this.#tabs.entries() }
|
||||
|
||||
get agentTabCount() { return this.#agentTabs.size }
|
||||
get retainedTabCount() { return this.#retainedCount }
|
||||
get agentTabs() { return this.#agentTabs }
|
||||
|
||||
// ── Lookup ──
|
||||
|
||||
getBySessionId(sessionId) {
|
||||
const direct = this.#bySession.get(sessionId)
|
||||
if (direct !== undefined) return { tabId: direct, kind: 'main' }
|
||||
const child = this.#childSession.get(sessionId)
|
||||
if (child !== undefined) return { tabId: child, kind: 'child' }
|
||||
return null
|
||||
}
|
||||
|
||||
getByTargetId(targetId) {
|
||||
return this.#byTarget.get(targetId) ?? null
|
||||
}
|
||||
|
||||
resolveTabId(sessionId, targetId) {
|
||||
if (sessionId) {
|
||||
const found = this.getBySessionId(sessionId)
|
||||
if (found) return found.tabId
|
||||
}
|
||||
if (targetId) {
|
||||
const found = this.getByTargetId(targetId)
|
||||
if (found !== null) return found
|
||||
}
|
||||
if (this.#lastAttached !== null && this.#tabs.has(this.#lastAttached)) {
|
||||
return this.#lastAttached
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── User-cancelled tab tracking (persisted to session storage) ──
|
||||
|
||||
markCancelled(tabId) {
|
||||
this.#cancelled.add(tabId)
|
||||
void this.#persistCancelled()
|
||||
}
|
||||
|
||||
isCancelled(tabId) { return this.#cancelled.has(tabId) }
|
||||
|
||||
removeCancelled(tabId) {
|
||||
this.#cancelled.delete(tabId)
|
||||
void this.#persistCancelled()
|
||||
}
|
||||
|
||||
async loadCancelled() {
|
||||
try {
|
||||
const { [CANCELLED_TABS_KEY]: ids } = await chrome.storage.session.get(CANCELLED_TABS_KEY)
|
||||
const arr = Array.isArray(ids) ? ids : []
|
||||
this.#cancelled.clear()
|
||||
for (const id of arr) {
|
||||
if (typeof id === 'number' && Number.isInteger(id)) this.#cancelled.add(id)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('loadCancelled failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async #persistCancelled() {
|
||||
try {
|
||||
await chrome.storage.session.set({ [CANCELLED_TABS_KEY]: [...this.#cancelled] })
|
||||
} catch (err) {
|
||||
log.warn('persistCancelled failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent tab tracking ──
|
||||
|
||||
markAgent(tabId, retain = false) {
|
||||
const prev = this.#agentTabs.get(tabId)
|
||||
const next = retain ? TabType.RETAINED : TabType.AGENT
|
||||
this.#agentTabs.set(tabId, next)
|
||||
if (next === TabType.RETAINED && prev !== TabType.RETAINED) this.#retainedCount++
|
||||
else if (next !== TabType.RETAINED && prev === TabType.RETAINED) this.#retainedCount--
|
||||
}
|
||||
|
||||
deleteAgent(tabId) {
|
||||
const prev = this.#agentTabs.get(tabId)
|
||||
if (prev === undefined) return
|
||||
if (prev === TabType.RETAINED) this.#retainedCount--
|
||||
this.#agentTabs.delete(tabId)
|
||||
}
|
||||
|
||||
isAgent(tabId) { return this.#agentTabs.has(tabId) }
|
||||
isRetained(tabId) { return this.#agentTabs.get(tabId) === TabType.RETAINED }
|
||||
|
||||
// ── Discovery (virtual registration) ──
|
||||
|
||||
async discoverAll(isConnected) {
|
||||
const t0 = performance.now()
|
||||
if (!isConnected()) return
|
||||
|
||||
const allTabs = await chrome.tabs.query({ url: ['http://*/*', 'https://*/*', 'file:///*'] })
|
||||
log.debug('discoverAll: query took', (performance.now() - t0).toFixed(1), 'ms,', allTabs.length, 'tabs')
|
||||
|
||||
let count = 0
|
||||
for (const tab of allTabs) {
|
||||
if (!tab.id || this.#tabs.has(tab.id) || this.#cancelled.has(tab.id)) continue
|
||||
this.#registerVirtual(tab.id, tab.url, tab.title)
|
||||
count++
|
||||
}
|
||||
log.info('discoverAll: registered', count, 'virtual tabs in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
}
|
||||
|
||||
discover(tabId, url, title) {
|
||||
if (!this.#tabs.has(tabId) && isDebuggableUrl(url)) {
|
||||
this.#registerVirtual(tabId, url, title)
|
||||
log.debug('discover: registered virtual tab', tabId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a tracked tab's URL and/or title.
|
||||
* Sends Extension.tabUpdated to the relay so the agent side stays in sync.
|
||||
*/
|
||||
updateTab(tabId, url, title) {
|
||||
const entry = this.#tabs.get(tabId)
|
||||
if (!entry) return
|
||||
|
||||
let changed = false
|
||||
if (url !== undefined && url !== entry.url) { entry.url = url; changed = true }
|
||||
if (title !== undefined && title !== entry.title) { entry.title = title; changed = true }
|
||||
if (!changed) return
|
||||
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Extension.tabUpdated',
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
targetInfo: {
|
||||
targetId: entry.targetId, type: 'page',
|
||||
title: entry.title || '', url: entry.url || '',
|
||||
attached: entry.state === 'connected',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#registerVirtual(tabId, url, title) {
|
||||
if (this.#tabs.has(tabId) || this.#cancelled.has(tabId)) return
|
||||
|
||||
const sessionId = `cb-tab-${++_sessionSeq}-${tabId}`
|
||||
const targetId = `vtab-${tabId}`
|
||||
|
||||
this.#tabs.set(tabId, { state: 'virtual', sessionId, targetId, url, title })
|
||||
this.#bySession.set(sessionId, tabId)
|
||||
this.#byTarget.set(targetId, tabId)
|
||||
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Extension.tabDiscovered',
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: { targetId, type: 'page', title: title || '', url: url || '', attached: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Lazy Attach (on-demand physical attachment) ──
|
||||
|
||||
async ensureAttached(tabId) {
|
||||
if (this.#cancelled.has(tabId)) {
|
||||
log.warn('ensureAttached: tab was cancelled by user', tabId)
|
||||
throw new Error(`User denied debugger permission for tab ${tabId}`)
|
||||
}
|
||||
const entry = this.#tabs.get(tabId)
|
||||
if (!entry) { log.warn('ensureAttached: tab not tracked', tabId); return false }
|
||||
if (entry.state === 'connected') return true
|
||||
if (entry.state === 'attaching') {
|
||||
const p = this.#pending.get(tabId)
|
||||
if (p) return p
|
||||
}
|
||||
|
||||
const promise = this.#physicalAttach(tabId, entry)
|
||||
this.#pending.set(tabId, promise)
|
||||
try { return await promise } finally { this.#pending.delete(tabId) }
|
||||
}
|
||||
|
||||
async #physicalAttach(tabId, entry) {
|
||||
const t0 = performance.now()
|
||||
if (this.#shuttingDown) return false
|
||||
entry.state = 'attaching'
|
||||
log.info('physicalAttach: begin', tabId)
|
||||
|
||||
try {
|
||||
if (this.#shuttingDown) return false
|
||||
const { realTargetId } = await attachDebugger(tabId)
|
||||
|
||||
// Guard: tab may have been removed by clearAll/detach while we were awaiting
|
||||
if (!this.#tabs.has(tabId)) {
|
||||
log.warn('physicalAttach: tab removed during attach, cleaning up', tabId)
|
||||
void detachDebugger(tabId)
|
||||
return false
|
||||
}
|
||||
|
||||
if (realTargetId && realTargetId !== entry.targetId) {
|
||||
this.#byTarget.delete(entry.targetId)
|
||||
entry.targetId = realTargetId
|
||||
this.#byTarget.set(realTargetId, tabId)
|
||||
}
|
||||
|
||||
entry.state = 'connected'
|
||||
this.#lastAttached = tabId
|
||||
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: entry.sessionId,
|
||||
targetInfo: {
|
||||
targetId: entry.targetId, type: 'page',
|
||||
title: entry.title || '', url: entry.url || '', attached: true,
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
log.info('physicalAttach: done', tabId, 'in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
return true
|
||||
} catch (err) {
|
||||
log.warn('physicalAttach: failed', tabId, (performance.now() - t0).toFixed(1), 'ms', err)
|
||||
void detachDebugger(tabId)
|
||||
if (this.#tabs.has(tabId)) {
|
||||
this.#notifyRemoved(entry.sessionId)
|
||||
this.#removeEntry(tabId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async attach(tabId) {
|
||||
if (this.#cancelled.has(tabId)) {
|
||||
log.warn('attach: tab was cancelled by user', tabId)
|
||||
return null
|
||||
}
|
||||
const existing = this.#tabs.get(tabId)
|
||||
if (existing?.state === 'connected') {
|
||||
log.debug('attach: already connected', tabId)
|
||||
return existing
|
||||
}
|
||||
|
||||
const wasNew = !existing
|
||||
if (wasNew) {
|
||||
let url, title
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId)
|
||||
url = tab.url; title = tab.title
|
||||
} catch { /* tab may have closed */ }
|
||||
const sessionId = `cb-tab-${++_sessionSeq}-${tabId}`
|
||||
const targetId = `vtab-${tabId}`
|
||||
this.#tabs.set(tabId, { state: 'virtual', sessionId, targetId, url, title })
|
||||
this.#bySession.set(sessionId, tabId)
|
||||
this.#byTarget.set(targetId, tabId)
|
||||
}
|
||||
|
||||
const ok = await this.ensureAttached(tabId)
|
||||
if (!ok) {
|
||||
if (wasNew && this.#tabs.has(tabId)) this.#removeEntry(tabId)
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = this.#tabs.get(tabId)
|
||||
return entry ? { sessionId: entry.sessionId, targetId: entry.targetId } : null
|
||||
}
|
||||
|
||||
// ── Detach ──
|
||||
|
||||
async detach(tabId, reason) {
|
||||
const t0 = performance.now()
|
||||
const entry = this.#tabs.get(tabId)
|
||||
log.info('detach:', tabId, reason, 'state:', entry?.state)
|
||||
|
||||
const wasPhysical = entry?.state === 'connected' || entry?.state === 'attaching'
|
||||
|
||||
if (entry?.sessionId) {
|
||||
if (wasPhysical && entry.targetId) {
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: { sessionId: entry.sessionId, targetId: entry.targetId, reason },
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.#notifyRemoved(entry.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
this.#removeEntry(tabId)
|
||||
|
||||
if (wasPhysical) {
|
||||
await detachDebugger(tabId)
|
||||
log.debug('detach: chrome.debugger cleanup', tabId, (performance.now() - t0).toFixed(1), 'ms')
|
||||
}
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
const t0 = performance.now()
|
||||
this.#shuttingDown = true
|
||||
this.stopSessionIndicators()
|
||||
|
||||
const physical = []
|
||||
for (const [tabId, entry] of this.#tabs) {
|
||||
if (entry.state === 'connected' || entry.state === 'attaching') {
|
||||
physical.push(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('clearAll:', this.#tabs.size, 'total,', physical.length, 'physically attached')
|
||||
|
||||
this.#tabs.clear()
|
||||
this.#bySession.clear()
|
||||
this.#byTarget.clear()
|
||||
this.#childSession.clear()
|
||||
this.#childSets.clear()
|
||||
this.#pending.clear()
|
||||
this.#cancelled.clear()
|
||||
void chrome.storage.session.remove(CANCELLED_TABS_KEY).catch(() => {})
|
||||
this.#agentTabs.clear()
|
||||
this.#retainedCount = 0
|
||||
this.#group.reset()
|
||||
this.#lastAttached = null
|
||||
this.#indicators.clear()
|
||||
cleanupAllTabQueues()
|
||||
|
||||
const settled = detachAll(physical)
|
||||
settled.then(() => {
|
||||
this.#shuttingDown = false
|
||||
log.info('clearAll: done in', (performance.now() - t0).toFixed(1), 'ms')
|
||||
})
|
||||
return settled
|
||||
}
|
||||
|
||||
// ── Debugger event handlers ──
|
||||
|
||||
onDebuggerEvent(source, method, params) {
|
||||
const tabId = source.tabId
|
||||
if (!tabId) return
|
||||
const tab = this.#tabs.get(tabId)
|
||||
if (!tab?.sessionId) return
|
||||
|
||||
const result = interceptEvent(method, tabId, params, {
|
||||
childSession: this.#childSession,
|
||||
childSets: this.#childSets,
|
||||
log,
|
||||
})
|
||||
|
||||
if (result.suppress) return
|
||||
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: { sessionId: source.sessionId || tab.sessionId, method, params },
|
||||
})
|
||||
}
|
||||
|
||||
onDebuggerDetach(source, reason) {
|
||||
const tabId = source.tabId
|
||||
log.info('onDebuggerDetach:', tabId, reason, 'tracked:', this.#tabs.has(tabId))
|
||||
if (!tabId || !this.#tabs.has(tabId)) return
|
||||
void this.detach(tabId, reason)
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
|
||||
#removeEntry(tabId) {
|
||||
const entry = this.#tabs.get(tabId)
|
||||
if (!entry) return
|
||||
if (entry.sessionId) this.#bySession.delete(entry.sessionId)
|
||||
if (entry.targetId) this.#byTarget.delete(entry.targetId)
|
||||
this.#tabs.delete(tabId)
|
||||
this.#indicators.removeTab(tabId)
|
||||
cleanupTabQueue(tabId)
|
||||
if (this.#lastAttached === tabId) this.#lastAttached = null
|
||||
|
||||
const children = this.#childSets.get(tabId)
|
||||
if (children) {
|
||||
for (const sid of children) this.#childSession.delete(sid)
|
||||
this.#childSets.delete(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
#notifyRemoved(sessionId) {
|
||||
if (!sessionId) return
|
||||
this.#sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: { method: 'Extension.tabRemoved', params: { sessionId } },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Session activity indicators — spinner animation & idle tab detection.
|
||||
*
|
||||
* Manages the visual "Lime Agent" tab-group spinner and automatically
|
||||
* detaches tabs that haven't received CDP commands for a configurable period.
|
||||
*
|
||||
* Timer strategy (MV3 compatible):
|
||||
* - Spinner uses setInterval (200 ms). This is acceptable because the spinner
|
||||
* only runs while the relay WS is connected, which keeps the SW alive.
|
||||
* When the WS disconnects, stop() is called and the timer is cleared.
|
||||
* - Idle check uses chrome.alarms (30 s) to survive SW suspension.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../../logger.js'
|
||||
|
||||
const log = createLogger('indicators')
|
||||
|
||||
const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F']
|
||||
const IDLE_DETACH_MS = 5 * 60 * 1000
|
||||
const SPINNER_INTERVAL_MS = 200
|
||||
const GROUP_TITLE = 'Lime Agent'
|
||||
|
||||
const ALARM_IDLE_CHECK = 'relayIdleCheck'
|
||||
|
||||
export class SessionIndicators {
|
||||
/** @type {number|null} */
|
||||
#spinnerTimer = null
|
||||
#spinnerIdx = 0
|
||||
/** @type {Map<number, number>} tabId → last CDP command timestamp */
|
||||
#lastCommandTime = new Map()
|
||||
|
||||
/** @type {() => number|null} */
|
||||
#getGroupId
|
||||
/** @type {() => IterableIterator<[number, {state: string}]>} */
|
||||
#getTabEntries
|
||||
/** @type {(tabId: number, reason: string) => void} */
|
||||
#detachTab
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {() => number|null} opts.getGroupId — returns current agent group ID
|
||||
* @param {() => IterableIterator<[number, {state: string}]>} opts.getTabEntries — returns iterable of [tabId, entry]
|
||||
* @param {(tabId: number, reason: string) => void} opts.detachTab — detach callback
|
||||
*/
|
||||
constructor({ getGroupId, getTabEntries, detachTab }) {
|
||||
this.#getGroupId = getGroupId
|
||||
this.#getTabEntries = getTabEntries
|
||||
this.#detachTab = detachTab
|
||||
}
|
||||
|
||||
trackCommand(tabId) {
|
||||
this.#lastCommandTime.set(tabId, Date.now())
|
||||
}
|
||||
|
||||
removeTab(tabId) {
|
||||
this.#lastCommandTime.delete(tabId)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#lastCommandTime.clear()
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stop()
|
||||
this.#spinnerIdx = 0
|
||||
this.#spinnerTimer = setInterval(() => this.#tickSpinner(), SPINNER_INTERVAL_MS)
|
||||
// 30 s idle check via alarm (survives SW suspension)
|
||||
chrome.alarms.create(ALARM_IDLE_CHECK, { periodInMinutes: 0.5 })
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.#spinnerTimer) { clearInterval(this.#spinnerTimer); this.#spinnerTimer = null }
|
||||
chrome.alarms.clear(ALARM_IDLE_CHECK)
|
||||
this.#lastCommandTime.clear()
|
||||
this.#resetGroupTitle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle alarm events relevant to session indicators.
|
||||
* Called from background.js onAlarm listener.
|
||||
* Returns true if the alarm was handled.
|
||||
*/
|
||||
handleAlarm(alarmName) {
|
||||
if (alarmName === ALARM_IDLE_CHECK) {
|
||||
this.#checkIdleTabs()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
#tickSpinner() {
|
||||
const groupId = this.#getGroupId()
|
||||
if (groupId === null) return
|
||||
const frame = SPINNER_FRAMES[this.#spinnerIdx % SPINNER_FRAMES.length]
|
||||
this.#spinnerIdx++
|
||||
chrome.tabGroups.update(groupId, { title: `${frame} ${GROUP_TITLE}` }).catch(() => {})
|
||||
}
|
||||
|
||||
#resetGroupTitle() {
|
||||
const groupId = this.#getGroupId()
|
||||
if (groupId === null) return
|
||||
chrome.tabGroups.update(groupId, { title: GROUP_TITLE }).catch(() => {})
|
||||
}
|
||||
|
||||
#checkIdleTabs() {
|
||||
const now = Date.now()
|
||||
const toDetach = []
|
||||
for (const [tabId, entry] of this.#getTabEntries()) {
|
||||
if (entry.state !== 'connected') continue
|
||||
const lastCmd = this.#lastCommandTime.get(tabId)
|
||||
if (lastCmd === undefined) continue // never received CDP command, skip (avoid false idle detach)
|
||||
if (now - lastCmd > IDLE_DETACH_MS) toDetach.push(tabId)
|
||||
}
|
||||
for (const tabId of toDetach) {
|
||||
log.info('idle timeout: detaching tab', tabId, 'after', IDLE_DETACH_MS / 1000, 's')
|
||||
this.#detachTab(tabId, 'idle_timeout')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Shared constants for the Lime Browser Bridge extension.
|
||||
*/
|
||||
|
||||
export const DEFAULT_CONTROL_PORT = 9234
|
||||
export const RELAY_PORT_OFFSET = 2
|
||||
|
||||
const MAX_CONTROL_PORT = 65535 - RELAY_PORT_OFFSET
|
||||
|
||||
export function clampPort(value) {
|
||||
const n = Number.parseInt(String(value || ''), 10)
|
||||
if (!Number.isFinite(n) || n <= 0 || n > MAX_CONTROL_PORT) return DEFAULT_CONTROL_PORT
|
||||
return n
|
||||
}
|
||||
|
||||
export function computeRelayPort(controlPort) {
|
||||
return clampPort(controlPort) + RELAY_PORT_OFFSET
|
||||
}
|
||||
|
||||
export const RelayState = Object.freeze({
|
||||
DISABLED: 'disabled',
|
||||
DISCONNECTED: 'disconnected',
|
||||
CONNECTED: 'connected',
|
||||
})
|
||||
|
||||
export const TabType = Object.freeze({
|
||||
USER: 'user',
|
||||
AGENT: 'agent',
|
||||
RETAINED: 'retained',
|
||||
})
|
||||
|
||||
export const STATE_UI = {
|
||||
[RelayState.DISABLED]: { dotColor: null, title: 'Lime Browser Bridge — disabled' },
|
||||
[RelayState.DISCONNECTED]: { dotColor: '#F59E0B', title: 'Lime Browser Bridge — connecting…' },
|
||||
[RelayState.CONNECTED]: { dotColor: '#22C55E', title: 'Lime Browser Bridge — connected' },
|
||||
}
|
||||
|
||||
export const STATE_TEXT = {
|
||||
[RelayState.DISABLED]: { label: 'Not Enabled', detail: 'Click the toggle or toolbar icon to enable.' },
|
||||
[RelayState.DISCONNECTED]: { label: 'Connecting…', detail: 'Relay is enabled. Trying to connect…' },
|
||||
[RelayState.CONNECTED]: { label: 'Connected', detail: 'Relay is active — agent can control your browser.' },
|
||||
}
|
||||
|
||||
// ── Settings keys ──
|
||||
|
||||
export const SETTINGS_KEYS = Object.freeze({
|
||||
CLOSE_GROUP_ON_DISABLE: 'closeGroupOnDisable',
|
||||
})
|
||||
|
||||
export async function getSetting(key, defaultValue = false) {
|
||||
const stored = await chrome.storage.local.get([key])
|
||||
return stored[key] ?? defaultValue
|
||||
}
|
||||
|
||||
export async function setSetting(key, value) {
|
||||
await chrome.storage.local.set({ [key]: value })
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Extension.actionMask — Page overlay for blocking user interaction.
|
||||
*
|
||||
* Injects a Shadow DOM-isolated overlay that prevents user interaction
|
||||
* while AI is operating. Supports three states:
|
||||
* ongoing — blocks all interaction, cursor: wait
|
||||
* idle — hidden, no blocking
|
||||
* takeover — transparent overlay, allows user interaction
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extActionMask(tabId, params) {
|
||||
const state = (params?.state || 'idle').toLowerCase()
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (maskState) => {
|
||||
const MASK_ID = 'accio-action-mask'
|
||||
let host = document.getElementById(MASK_ID)
|
||||
|
||||
if (maskState === 'idle' || maskState === 'remove') {
|
||||
if (host) host.remove()
|
||||
return { success: true, state: 'idle' }
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
host = document.createElement('div')
|
||||
host.id = MASK_ID
|
||||
host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483646;'
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host { all: initial; }
|
||||
.mask-layer {
|
||||
position: fixed; inset: 0; z-index: 2147483646;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.mask-layer[data-state="ongoing"] {
|
||||
pointer-events: auto; cursor: wait;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.mask-layer[data-state="takeover"] {
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
.glow-border {
|
||||
position: fixed; inset: 0; z-index: 2147483645;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 30px rgba(99, 102, 241, 0.15);
|
||||
animation: accio-glow 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes accio-glow {
|
||||
from { opacity: 0.7; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.status-bar {
|
||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 2147483647;
|
||||
background: rgba(30, 30, 50, 0.9); color: #e0e0e0;
|
||||
padding: 8px 20px; border-radius: 24px;
|
||||
font: 13px/1.4 system-ui, -apple-system, sans-serif;
|
||||
backdrop-filter: blur(8px);
|
||||
pointer-events: none;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #6366f1;
|
||||
animation: accio-pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes accio-pulse {
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`
|
||||
|
||||
const mask = document.createElement('div')
|
||||
mask.className = 'mask-layer'
|
||||
|
||||
const glow = document.createElement('div')
|
||||
glow.className = 'glow-border'
|
||||
|
||||
const bar = document.createElement('div')
|
||||
bar.className = 'status-bar'
|
||||
bar.innerHTML = '<span class="status-dot"></span><span>Accio is working…</span>'
|
||||
|
||||
shadow.append(style, mask, glow, bar)
|
||||
document.body.appendChild(host)
|
||||
}
|
||||
|
||||
const shadow = host.shadowRoot
|
||||
const mask = shadow.querySelector('.mask-layer')
|
||||
const glow = shadow.querySelector('.glow-border')
|
||||
const bar = shadow.querySelector('.status-bar')
|
||||
|
||||
mask.dataset.state = maskState
|
||||
|
||||
if (maskState === 'ongoing') {
|
||||
glow.style.display = ''
|
||||
bar.style.display = ''
|
||||
const BLOCKED_EVENTS = [
|
||||
'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu',
|
||||
'pointerdown', 'pointerup', 'pointermove',
|
||||
'keydown', 'keyup', 'keypress', 'wheel',
|
||||
]
|
||||
if (!mask._blockers) {
|
||||
mask._blockers = []
|
||||
for (const evt of BLOCKED_EVENTS) {
|
||||
const handler = (e) => { if (e.isTrusted) { e.preventDefault(); e.stopImmediatePropagation() } }
|
||||
document.addEventListener(evt, handler, { capture: true, passive: false })
|
||||
mask._blockers.push({ evt, handler })
|
||||
}
|
||||
}
|
||||
} else if (maskState === 'takeover') {
|
||||
glow.style.display = 'none'
|
||||
bar.style.display = 'none'
|
||||
if (mask._blockers) {
|
||||
for (const { evt, handler } of mask._blockers) {
|
||||
document.removeEventListener(evt, handler, { capture: true })
|
||||
}
|
||||
mask._blockers = null
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, state: maskState }
|
||||
},
|
||||
args: [state],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.actionMask')
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Extension.* virtual CDP commands.
|
||||
*
|
||||
* These commands are NOT part of the Chrome Debugger Protocol —
|
||||
* they use chrome.scripting.executeScript to inject page-level scripts
|
||||
* and chrome.tabs / chrome.debugger for viewport/zoom/screenshot operations.
|
||||
*
|
||||
* This avoids the need for a persistent content script while providing
|
||||
* capabilities similar to Manus (content extraction, element marking, etc).
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
// ── Viewport & Zoom ──
|
||||
|
||||
function wrapDebuggerCommand(tabId, fn) {
|
||||
return fn().catch((err) => {
|
||||
const msg = err?.message || String(err)
|
||||
if (msg.includes('attach') || msg.includes('debugger') || msg.includes('Another debugger')) {
|
||||
throw new Error(`Tab ${tabId} is not attached to debugger. Ensure ensureAttached was called first. Original: ${msg}`)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
export async function extGetViewportInfo(tabId) {
|
||||
const debuggee = { tabId }
|
||||
const result = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
scrollX: Math.round(window.scrollX),
|
||||
scrollY: Math.round(window.scrollY),
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
zoom: window.innerWidth > 0 ? Math.round(window.outerWidth / window.innerWidth * 100) / 100 : 1,
|
||||
})`,
|
||||
returnByValue: true,
|
||||
}))
|
||||
return JSON.parse(result?.result?.value || '{}')
|
||||
}
|
||||
|
||||
export async function extEnsureZoom(tabId, params) {
|
||||
const targetZoom = typeof params?.zoom === 'number' ? params.zoom : 1
|
||||
const currentZoom = await chrome.tabs.getZoom(tabId)
|
||||
if (Math.abs(currentZoom - targetZoom) > 0.01) {
|
||||
await chrome.tabs.setZoom(tabId, targetZoom)
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
return { changed: true, from: currentZoom, to: targetZoom }
|
||||
}
|
||||
return { changed: false, current: currentZoom }
|
||||
}
|
||||
|
||||
// ── Screenshot ──
|
||||
|
||||
export async function extCaptureViewport(tabId, params) {
|
||||
const debuggee = { tabId }
|
||||
const format = params?.format || 'png'
|
||||
const quality = params?.quality || 80
|
||||
|
||||
await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', {
|
||||
expression: 'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))',
|
||||
awaitPromise: true,
|
||||
})).catch(() => {})
|
||||
|
||||
const metrics = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Page.getLayoutMetrics'))
|
||||
const vv = metrics?.visualViewport || {}
|
||||
|
||||
const vpResult = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({ dpr: window.devicePixelRatio || 1 })',
|
||||
returnByValue: true,
|
||||
}))
|
||||
const { dpr } = JSON.parse(vpResult?.result?.value || '{"dpr":1}')
|
||||
|
||||
const screenshot = await wrapDebuggerCommand(tabId, () => chrome.debugger.sendCommand(debuggee, 'Page.captureScreenshot', {
|
||||
format,
|
||||
quality: format === 'jpeg' ? quality : undefined,
|
||||
clip: {
|
||||
x: vv.pageX || 0,
|
||||
y: vv.pageY || 0,
|
||||
width: vv.clientWidth || 1280,
|
||||
height: vv.clientHeight || 720,
|
||||
scale: 1 / dpr,
|
||||
},
|
||||
captureBeyondViewport: false,
|
||||
}))
|
||||
|
||||
return {
|
||||
data: screenshot?.data,
|
||||
width: Math.round(vv.clientWidth || 1280),
|
||||
height: Math.round(vv.clientHeight || 720),
|
||||
dpr,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content Extraction ──
|
||||
|
||||
export async function extExtractContent(tabId) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const title = document.title
|
||||
|| document.querySelector('meta[property="og:title"]')?.content
|
||||
|| document.querySelector('h1')?.textContent?.trim() || ''
|
||||
|
||||
const main = document.querySelector('main, article, [role="main"], #content, .content')
|
||||
const root = main || document.body
|
||||
|
||||
function toMarkdown(el) {
|
||||
const lines = []
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === 3) {
|
||||
const t = node.textContent.replace(/\s+/g, ' ').trim()
|
||||
if (t) lines.push(t)
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== 1) return
|
||||
const tag = node.tagName
|
||||
try {
|
||||
const style = getComputedStyle(node)
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return
|
||||
} catch { /* skip check */ }
|
||||
|
||||
if (/^H[1-6]$/.test(tag)) {
|
||||
lines.push('\n' + '#'.repeat(+tag[1]) + ' ' + node.textContent.trim())
|
||||
} else if (tag === 'P') {
|
||||
lines.push('\n' + node.innerText.replace(/\s+/g, ' ').trim())
|
||||
} else if (tag === 'LI') {
|
||||
lines.push('- ' + node.innerText.replace(/\s+/g, ' ').trim())
|
||||
} else if (tag === 'A' && node.href) {
|
||||
lines.push(`[${node.textContent.trim()}](${node.href})`)
|
||||
} else if (tag === 'IMG' && node.alt) {
|
||||
lines.push(``)
|
||||
} else if (tag === 'PRE' || tag === 'CODE') {
|
||||
lines.push('\n```\n' + node.textContent.trim() + '\n```')
|
||||
} else if (tag === 'BR') {
|
||||
lines.push('\n')
|
||||
} else if (tag === 'TABLE') {
|
||||
lines.push('\n' + node.innerText.replace(/\t/g, ' | ').trim())
|
||||
} else {
|
||||
for (const child of node.childNodes) walk(child)
|
||||
if (node.shadowRoot) {
|
||||
for (const child of node.shadowRoot.childNodes) walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(el)
|
||||
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
return {
|
||||
title: title.trim(),
|
||||
url: location.href,
|
||||
content: toMarkdown(root).slice(0, 50000),
|
||||
}
|
||||
},
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.extractContent')
|
||||
}
|
||||
|
||||
// ── Interactive Element Marking ──
|
||||
|
||||
export async function extMarkElements(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (options) => {
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
'a[href]', 'button', 'input', 'select', 'textarea',
|
||||
'[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]',
|
||||
'[role="checkbox"]', '[role="radio"]', '[role="switch"]',
|
||||
'[contenteditable="true"]', '[onclick]', '[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const SKIP_TAGS = new Set([
|
||||
'HTML', 'HEAD', 'BODY', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG', 'PATH', 'META', 'LINK',
|
||||
])
|
||||
|
||||
function deepElementFromPoint(x, y) {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (el?.shadowRoot) {
|
||||
const inner = el.shadowRoot.elementFromPoint(x, y)
|
||||
if (inner && inner !== el) return inner
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function isDescendantOrSelf(node, target) {
|
||||
let cur = node
|
||||
while (cur) {
|
||||
if (cur === target) return true
|
||||
if (cur.parentElement) { cur = cur.parentElement; continue }
|
||||
const root = cur.getRootNode()
|
||||
cur = root instanceof ShadowRoot ? root.host : null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isVisible(el, rect, vw, vh) {
|
||||
if (rect.right < 0 || rect.bottom < 0 || rect.left > vw || rect.top > vh) return false
|
||||
const clipped = {
|
||||
left: Math.max(rect.left, 0), top: Math.max(rect.top, 0),
|
||||
right: Math.min(rect.right, vw), bottom: Math.min(rect.bottom, vh),
|
||||
}
|
||||
const cw = clipped.right - clipped.left
|
||||
const ch = clipped.bottom - clipped.top
|
||||
if (cw < 3 || ch < 3) return false
|
||||
|
||||
const cols = Math.min(4, Math.max(1, Math.round(cw / 20)))
|
||||
const rows = Math.min(4, Math.max(1, Math.round(ch / 20)))
|
||||
let hits = 0, total = 0
|
||||
for (let r = 0; r <= rows; r++) {
|
||||
for (let c = 0; c <= cols; c++) {
|
||||
const px = clipped.left + (cols > 0 ? (c / cols) * cw : cw / 2)
|
||||
const py = clipped.top + (rows > 0 ? (r / rows) * ch : ch / 2)
|
||||
const top = deepElementFromPoint(px, py)
|
||||
if (top && isDescendantOrSelf(top, el)) hits++
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total > 0 && (hits / total) >= 0.3
|
||||
}
|
||||
|
||||
function collectInteractive(root, out) {
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (SKIP_TAGS.has(el.tagName)) continue
|
||||
if (el.matches(INTERACTIVE_SELECTOR)) out.push(el)
|
||||
if (el.shadowRoot) collectInteractive(el.shadowRoot, out)
|
||||
}
|
||||
}
|
||||
|
||||
const vw = window.innerWidth || 1, vh = window.innerHeight || 1
|
||||
const candidates = []
|
||||
collectInteractive(document, candidates)
|
||||
|
||||
const elements = []
|
||||
let idx = 1
|
||||
const maxElements = options?.maxElements || 200
|
||||
|
||||
for (const el of candidates) {
|
||||
if (idx > maxElements) break
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width < 5 || rect.height < 5) continue
|
||||
|
||||
try {
|
||||
const style = getComputedStyle(el)
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue
|
||||
} catch { /* skip check */ }
|
||||
|
||||
if (!isVisible(el, rect, vw, vh)) continue
|
||||
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const text = (el.textContent || el.value || el.placeholder
|
||||
|| el.getAttribute('aria-label') || el.title || '').trim().slice(0, 100)
|
||||
|
||||
el.setAttribute('data-accio-idx', String(idx))
|
||||
|
||||
elements.push({
|
||||
idx, tag,
|
||||
type: el.type || '',
|
||||
text,
|
||||
role: el.getAttribute('role') || '',
|
||||
rect: {
|
||||
x: Math.round(rect.left), y: Math.round(rect.top),
|
||||
w: Math.round(rect.width), h: Math.round(rect.height),
|
||||
},
|
||||
center: {
|
||||
nx: +(((rect.left + rect.width / 2) / vw).toFixed(4)),
|
||||
ny: +(((rect.top + rect.height / 2) / vh).toFixed(4)),
|
||||
},
|
||||
})
|
||||
idx++
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
viewport: { width: vw, height: vh, dpr: window.devicePixelRatio || 1 },
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
}
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.markElements')
|
||||
}
|
||||
|
||||
// ── DOM Actions ──
|
||||
|
||||
export async function extClick(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
function deepQuery(root, sel) {
|
||||
const found = root.querySelector(sel)
|
||||
if (found) return found
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const inner = deepQuery(el.shadowRoot, sel)
|
||||
if (inner) return inner
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
let el
|
||||
if (p.index != null) {
|
||||
const idx = Number(p.index)
|
||||
if (!Number.isInteger(idx) || idx <= 0) return { success: false, error: 'Invalid index' }
|
||||
el = deepQuery(document, `[data-accio-idx="${idx}"]`)
|
||||
} else if (p.selector) {
|
||||
try { el = deepQuery(document, p.selector) } catch { return { success: false, error: 'Invalid selector' } }
|
||||
} else if (p.x != null && p.y != null) {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x
|
||||
const cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y
|
||||
el = document.elementFromPoint(cx, cy)
|
||||
if (el?.shadowRoot) {
|
||||
const inner = el.shadowRoot.elementFromPoint(cx, cy)
|
||||
if (inner) el = inner
|
||||
}
|
||||
}
|
||||
if (!el) return { success: false, error: 'Element not found' }
|
||||
el.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
const rect = el.getBoundingClientRect()
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
|
||||
const clickType = (p.clickType || 'single_left').toLowerCase()
|
||||
const isRight = clickType.includes('right')
|
||||
const button = isRight ? 2 : 0
|
||||
const baseOpts = { bubbles: true, cancelable: true, composed: true, view: window, clientX: cx, clientY: cy, button }
|
||||
|
||||
if (clickType === 'double_left') {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 1 }))
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 1 }))
|
||||
el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 1 }))
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 2 }))
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 2 }))
|
||||
el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 2 }))
|
||||
el.dispatchEvent(new MouseEvent('dblclick', { ...baseOpts, detail: 2 }))
|
||||
} else if (clickType === 'triple_left') {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: i }))
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: i }))
|
||||
el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: i }))
|
||||
}
|
||||
} else if (isRight) {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', baseOpts))
|
||||
el.dispatchEvent(new MouseEvent('mouseup', baseOpts))
|
||||
el.dispatchEvent(new MouseEvent('contextmenu', baseOpts))
|
||||
} else {
|
||||
el.dispatchEvent(new MouseEvent('mousedown', { ...baseOpts, detail: 1 }))
|
||||
el.dispatchEvent(new MouseEvent('mouseup', { ...baseOpts, detail: 1 }))
|
||||
el.dispatchEvent(new MouseEvent('click', { ...baseOpts, detail: 1 }))
|
||||
}
|
||||
|
||||
return { success: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 80), clickType, _cx: cx, _cy: cy }
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
const result = unwrapScriptResult(results, 'Extension.click')
|
||||
if (result?.success && result._cx != null) {
|
||||
showClickRipple(tabId, result._cx, result._cy).catch(() => {})
|
||||
delete result._cx
|
||||
delete result._cy
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function showClickRipple(tabId, x, y) {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (cx, cy) => {
|
||||
if (!document.getElementById('accio-ripple-style')) {
|
||||
const s = document.createElement('style')
|
||||
s.id = 'accio-ripple-style'
|
||||
s.textContent = '@keyframes accio-r{0%{transform:scale(0);opacity:1}100%{transform:scale(2.5);opacity:0}}'
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
const d = document.createElement('div')
|
||||
Object.assign(d.style, {
|
||||
position: 'fixed', left: `${cx - 16}px`, top: `${cy - 16}px`,
|
||||
width: '32px', height: '32px', borderRadius: '50%',
|
||||
background: 'rgba(99,102,241,0.4)', pointerEvents: 'none',
|
||||
zIndex: '2147483647', animation: 'accio-r .8s ease-out forwards',
|
||||
})
|
||||
document.body.appendChild(d)
|
||||
d.onanimationend = () => d.remove()
|
||||
},
|
||||
args: [x, y],
|
||||
})
|
||||
}
|
||||
|
||||
export async function extInput(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
function deepQuery(root, sel) {
|
||||
const found = root.querySelector(sel)
|
||||
if (found) return found
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const inner = deepQuery(el.shadowRoot, sel)
|
||||
if (inner) return inner
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
let el
|
||||
if (p.index != null) {
|
||||
const idx = Number(p.index)
|
||||
if (!Number.isInteger(idx) || idx <= 0) return { success: false, error: 'Invalid index' }
|
||||
el = deepQuery(document, `[data-accio-idx="${idx}"]`)
|
||||
} else if (p.selector) {
|
||||
try { el = deepQuery(document, p.selector) } catch { return { success: false, error: 'Invalid selector' } }
|
||||
}
|
||||
if (!el) return { success: false, error: 'Element not found' }
|
||||
|
||||
el.focus()
|
||||
const tag = el.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
const proto = tag === 'SELECT' ? HTMLSelectElement.prototype
|
||||
: tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const nativeSet = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
||||
if (nativeSet) nativeSet.call(el, p.text || '')
|
||||
else el.value = p.text || ''
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
} else if (el.getAttribute('contenteditable') === 'true') {
|
||||
el.focus()
|
||||
document.execCommand('selectAll')
|
||||
document.execCommand('insertText', false, p.text || '')
|
||||
}
|
||||
return { success: true }
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.input')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Enhanced content extraction with Readability-like fallback.
|
||||
*
|
||||
* Dual-path extraction:
|
||||
* Path 1: Custom DOM walker (same as extExtractContent)
|
||||
* Path 2: Scoring-based extraction inspired by Mozilla Readability
|
||||
*
|
||||
* Picks the longer result. Falls back to body.innerText if both are short.
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extExtractContentEnhanced(tabId) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const title = document.title
|
||||
|| document.querySelector('meta[property="og:title"]')?.content
|
||||
|| document.querySelector('h1')?.textContent?.trim() || ''
|
||||
|
||||
// Path 1: Simple walker
|
||||
function simpleExtract() {
|
||||
const main = document.querySelector('main, article, [role="main"], #content, .content')
|
||||
const root = main || document.body
|
||||
|
||||
const lines = []
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === 3) {
|
||||
const t = node.textContent.replace(/\s+/g, ' ').trim()
|
||||
if (t) lines.push(t)
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== 1) return
|
||||
const tag = node.tagName
|
||||
try {
|
||||
const style = getComputedStyle(node)
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (/^H[1-6]$/.test(tag)) lines.push('\n' + '#'.repeat(+tag[1]) + ' ' + node.textContent.trim())
|
||||
else if (tag === 'P') lines.push('\n' + node.innerText.replace(/\s+/g, ' ').trim())
|
||||
else if (tag === 'LI') lines.push('- ' + node.innerText.replace(/\s+/g, ' ').trim())
|
||||
else if (tag === 'A' && node.href) lines.push(`[${node.textContent.trim()}](${node.href})`)
|
||||
else if (tag === 'IMG' && node.alt) lines.push(``)
|
||||
else if (tag === 'PRE' || tag === 'CODE') lines.push('\n```\n' + node.textContent.trim() + '\n```')
|
||||
else if (tag === 'BR') lines.push('\n')
|
||||
else if (tag === 'TABLE') lines.push('\n' + node.innerText.replace(/\t/g, ' | ').trim())
|
||||
else {
|
||||
for (const child of node.childNodes) walk(child)
|
||||
if (node.shadowRoot) for (const child of node.shadowRoot.childNodes) walk(child)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
// Path 2: Scoring-based extraction (Readability-inspired)
|
||||
function scoringExtract() {
|
||||
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'NAV', 'FOOTER', 'HEADER', 'ASIDE', 'FORM'])
|
||||
const candidates = []
|
||||
|
||||
function score(el) {
|
||||
if (SKIP.has(el.tagName)) return 0
|
||||
const text = el.innerText || ''
|
||||
const textLen = text.length
|
||||
if (textLen < 25) return 0
|
||||
|
||||
let s = 0
|
||||
const paragraphs = el.querySelectorAll('p')
|
||||
s += paragraphs.length * 3
|
||||
s += Math.min(textLen / 100, 30)
|
||||
|
||||
const links = el.querySelectorAll('a')
|
||||
const linkText = [...links].reduce((sum, a) => sum + (a.textContent || '').length, 0)
|
||||
const linkDensity = textLen > 0 ? linkText / textLen : 1
|
||||
if (linkDensity > 0.5) s *= 0.3
|
||||
|
||||
const commas = (text.match(/[,,、]/g) || []).length
|
||||
s += commas
|
||||
|
||||
if (el.id?.match(/article|content|main|post|body/i)) s *= 1.5
|
||||
if (el.className?.match(/article|content|main|post|body/i)) s *= 1.3
|
||||
if (el.className?.match(/sidebar|comment|footer|nav|menu|ad|banner/i)) s *= 0.3
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
for (const el of document.querySelectorAll('div, section, article, main')) {
|
||||
const s = score(el)
|
||||
if (s > 0) candidates.push({ el, score: s })
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score)
|
||||
const best = candidates[0]
|
||||
if (!best || best.score < 10) return ''
|
||||
|
||||
const text = best.el.innerText || ''
|
||||
return text.replace(/\t/g, ' ').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
const simple = simpleExtract()
|
||||
const scored = scoringExtract()
|
||||
|
||||
let content
|
||||
if (simple.length >= scored.length || simple.length > 800) {
|
||||
content = simple
|
||||
} else {
|
||||
content = scored
|
||||
}
|
||||
|
||||
if (content.length < 200) {
|
||||
const fallback = (document.body?.innerText || '')
|
||||
.replace(/\t/g, ' ').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
||||
if (fallback.length > content.length) content = fallback
|
||||
}
|
||||
|
||||
return {
|
||||
title: title.trim(),
|
||||
url: location.href,
|
||||
content: content.slice(0, 50000),
|
||||
method: content === simple ? 'walker' : content.length < 200 ? 'body-fallback' : 'scoring',
|
||||
}
|
||||
},
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.extractContentEnhanced')
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Extension.findKeyword — In-page keyword search with context expansion.
|
||||
*
|
||||
* Uses sentence segmentation to find keyword occurrences and returns
|
||||
* surrounding context (~20 words). Handles CJK characters correctly.
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extFindKeyword(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
const keyword = (p.keyword || '').trim()
|
||||
if (!keyword) return { success: false, error: 'No keyword provided' }
|
||||
|
||||
const body = document.body?.innerText || ''
|
||||
if (!body) return { success: true, matches: [], count: 0 }
|
||||
|
||||
const CJK_RE = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/
|
||||
const isCJK = CJK_RE.test(keyword)
|
||||
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = isCJK ? new RegExp(escaped, 'gi') : new RegExp(`\\b${escaped}\\b`, 'gi')
|
||||
|
||||
let sentences
|
||||
try {
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'sentence' })
|
||||
sentences = [...segmenter.segment(body)].map(s => s.segment)
|
||||
} catch {
|
||||
sentences = body.split(/[.!?\n]+/).filter(Boolean)
|
||||
}
|
||||
|
||||
const matches = []
|
||||
const MAX_MATCHES = 20
|
||||
const CONTEXT_WORDS = 20
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if (matches.length >= MAX_MATCHES) break
|
||||
if (!pattern.test(sentence)) continue
|
||||
pattern.lastIndex = 0
|
||||
|
||||
const words = sentence.split(/\s+/)
|
||||
let match
|
||||
while ((match = pattern.exec(sentence)) !== null && matches.length < MAX_MATCHES) {
|
||||
const beforeText = sentence.slice(0, match.index)
|
||||
const wordsBefore = beforeText.split(/\s+/).filter(Boolean)
|
||||
const startWord = Math.max(0, wordsBefore.length - CONTEXT_WORDS)
|
||||
|
||||
const afterText = sentence.slice(match.index + match[0].length)
|
||||
const wordsAfter = afterText.split(/\s+/).filter(Boolean)
|
||||
const endWord = Math.min(wordsAfter.length, CONTEXT_WORDS)
|
||||
|
||||
const contextBefore = wordsBefore.slice(startWord).join(' ')
|
||||
const contextAfter = wordsAfter.slice(0, endWord).join(' ')
|
||||
const context = `${contextBefore} ${match[0]} ${contextAfter}`.trim()
|
||||
|
||||
matches.push({
|
||||
keyword: match[0],
|
||||
context,
|
||||
sentenceIndex: sentences.indexOf(sentence),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
keyword,
|
||||
count: matches.length,
|
||||
matches,
|
||||
url: location.href,
|
||||
}
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.findKeyword')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Shared utilities for content_script commands.
|
||||
*/
|
||||
|
||||
export function unwrapScriptResult(results, label) {
|
||||
const first = results?.[0]
|
||||
if (!first) throw new Error(`${label}: no injection result (page may be restricted)`)
|
||||
if (first.error) throw new Error(`${label}: ${typeof first.error === 'string' ? first.error : JSON.stringify(first.error)}`)
|
||||
return first.result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Extension.captureHighlightedViewport — Screenshot with element annotations.
|
||||
*
|
||||
* Takes a clean screenshot, then draws dashed rectangles and index labels
|
||||
* over interactive elements using OffscreenCanvas in the Service Worker.
|
||||
*
|
||||
* Color scheme follows Manus convention:
|
||||
* button → yellow, input → coral, select → pink, a → green,
|
||||
* textarea → blue, default → red
|
||||
*/
|
||||
|
||||
const TAG_COLORS = {
|
||||
button: '#FFFF00',
|
||||
input: '#FF7F50',
|
||||
select: '#FF4162',
|
||||
a: '#00FF00',
|
||||
textarea: '#0000FF',
|
||||
}
|
||||
const DEFAULT_COLOR = '#FF0000'
|
||||
|
||||
function colorForTag(tag) {
|
||||
return TAG_COLORS[tag] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
/**
|
||||
* Render element annotations onto a screenshot.
|
||||
*
|
||||
* @param {string} base64Png — Clean screenshot as base64
|
||||
* @param {Array<{idx: number, tag: string, rect: {x: number, y: number, w: number, h: number}}>} elements
|
||||
* @param {number} width — Viewport width
|
||||
* @param {number} height — Viewport height
|
||||
* @returns {Promise<string>} Annotated screenshot as base64
|
||||
*/
|
||||
export async function renderHighlightedScreenshot(base64Png, elements, width, height) {
|
||||
const blob = await (await fetch(`data:image/png;base64,${base64Png}`)).blob()
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(bitmap, 0, 0)
|
||||
|
||||
const scaleX = bitmap.width / width
|
||||
const scaleY = bitmap.height / height
|
||||
|
||||
const fontSize = Math.max(10, Math.min(20, Math.round(bitmap.width / 100)))
|
||||
ctx.font = `bold ${fontSize}px monospace`
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
const DASH_LENGTH = 4 * scaleX
|
||||
const GAP_LENGTH = 8 * scaleX
|
||||
const LINE_WIDTH = 2 * scaleX
|
||||
const LABEL_PAD = 3 * scaleX
|
||||
|
||||
for (const el of elements) {
|
||||
const color = colorForTag(el.tag)
|
||||
const x = el.rect.x * scaleX
|
||||
const y = el.rect.y * scaleY
|
||||
const w = el.rect.w * scaleX
|
||||
const h = el.rect.h * scaleY
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = LINE_WIDTH
|
||||
ctx.setLineDash([DASH_LENGTH, GAP_LENGTH])
|
||||
ctx.strokeRect(x, y, w, h)
|
||||
ctx.setLineDash([])
|
||||
|
||||
const label = String(el.idx)
|
||||
const metrics = ctx.measureText(label)
|
||||
const lw = metrics.width + LABEL_PAD * 2
|
||||
const lh = fontSize + LABEL_PAD * 2
|
||||
|
||||
let lx = x - lw - 2
|
||||
let ly = y - 2
|
||||
if (lx < 0) lx = x + w + 2
|
||||
if (ly < 0) ly = y + h + 2
|
||||
if (lx + lw > bitmap.width) lx = x
|
||||
if (ly + lh > bitmap.height) ly = y
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(lx, ly, lw, lh)
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillText(label, lx + LABEL_PAD, ly + LABEL_PAD)
|
||||
}
|
||||
|
||||
const outBlob = await canvas.convertToBlob({ type: 'image/png' })
|
||||
const buffer = await outBlob.arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const CHUNK = 0x8000
|
||||
const chunks = []
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)))
|
||||
}
|
||||
return btoa(chunks.join(''))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Content Script channel — Manus-style DOM interaction.
|
||||
*
|
||||
* Uses chrome.scripting.executeScript to inject page-level scripts
|
||||
* for viewport info, content extraction, element marking, click/input,
|
||||
* keyboard, scrolling, keyword search, mouse hover, and Shadow DOM traversal.
|
||||
* No debugger attachment required for DOM operations.
|
||||
*
|
||||
* Active commands (wired into CDP dispatch):
|
||||
* extension-ops.js — Core: viewport, screenshot, content, mark, click, input
|
||||
* press-key.js — Keyboard event simulation with modifiers/combos
|
||||
* scroll.js — Page/container scrolling with verification
|
||||
* find-keyword.js — In-page keyword search with context
|
||||
* move-mouse.js — Mouse hover simulation
|
||||
* highlight-screenshot.js — Screenshot annotation with element highlights
|
||||
*
|
||||
* Reserved commands (implemented but not wired — enable when needed):
|
||||
* wait-ready.js — Page readyState + MutationObserver
|
||||
* action-mask.js — Shadow DOM overlay to block user interaction
|
||||
* input-enhanced.js — DraftEditor / Slate / Rich Text Editor support
|
||||
* extract-readability.js — Dual-path content extraction with scoring fallback
|
||||
* keepalive.js — MV3 Service Worker heartbeat mechanism
|
||||
*
|
||||
* helpers.js — Shared utilities (unwrapScriptResult)
|
||||
*/
|
||||
|
||||
// ── Active exports ──
|
||||
|
||||
export {
|
||||
extGetViewportInfo,
|
||||
extEnsureZoom,
|
||||
extCaptureViewport,
|
||||
extExtractContent,
|
||||
extMarkElements,
|
||||
extClick,
|
||||
extInput,
|
||||
} from './extension-ops.js'
|
||||
|
||||
export { extPressKey } from './press-key.js'
|
||||
export { extScroll } from './scroll.js'
|
||||
export { extFindKeyword } from './find-keyword.js'
|
||||
export { extMoveMouse } from './move-mouse.js'
|
||||
export { renderHighlightedScreenshot } from './highlight-screenshot.js'
|
||||
|
||||
// ── Reserved exports (not wired into dispatch) ──
|
||||
|
||||
export { extWaitForReady } from './wait-ready.js'
|
||||
export { extActionMask } from './action-mask.js'
|
||||
export { extInputEnhanced } from './input-enhanced.js'
|
||||
export { extExtractContentEnhanced } from './extract-readability.js'
|
||||
export { startKeepAlive, stopKeepAlive, registerKeepAliveListener } from './keepalive.js'
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Enhanced input handling — DraftEditor and Rich Text Editor support.
|
||||
*
|
||||
* Detects and handles React DraftEditor (Facebook/Meta ecosystem),
|
||||
* Slate, ProseMirror, and other contenteditable-based editors.
|
||||
*
|
||||
* Detection strategy:
|
||||
* 1. Check for DraftEditor class on element or ancestors
|
||||
* 2. Check for data-contents attribute (DraftEditor signature)
|
||||
* 3. Check for Slate/ProseMirror data attributes
|
||||
*
|
||||
* Input strategy for rich text:
|
||||
* 1. Focus element
|
||||
* 2. Select all content (Selection API)
|
||||
* 3. For DraftEditor: execCommand("delete") + execCommand("insertText")
|
||||
* 4. For others: direct textContent set + InputEvent dispatch
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extInputEnhanced(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
function deepQuery(root, sel) {
|
||||
const found = root.querySelector(sel)
|
||||
if (found) return found
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const inner = deepQuery(el.shadowRoot, sel)
|
||||
if (inner) return inner
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let el
|
||||
if (p.index) el = deepQuery(document, `[data-accio-idx="${p.index}"]`)
|
||||
else if (p.selector) el = deepQuery(document, p.selector)
|
||||
if (!el) return { success: false, error: 'Element not found' }
|
||||
|
||||
const text = p.text || ''
|
||||
const tag = el.tagName
|
||||
const isContentEditable = el.getAttribute('contenteditable') === 'true' ||
|
||||
el.isContentEditable
|
||||
|
||||
function isDraftEditor(element) {
|
||||
if (element.classList?.contains('DraftEditor-content')) return true
|
||||
if (element.hasAttribute('data-contents')) return true
|
||||
let cur = element
|
||||
for (let i = 0; i < 5 && cur; i++) {
|
||||
if (cur.classList?.contains('DraftEditor-root')) return true
|
||||
cur = cur.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isSlateEditor(element) {
|
||||
return element.hasAttribute('data-slate-editor') ||
|
||||
element.closest?.('[data-slate-editor]') != null
|
||||
}
|
||||
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
el.focus()
|
||||
const proto = tag === 'SELECT' ? HTMLSelectElement.prototype
|
||||
: tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype
|
||||
const nativeSet = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
||||
if (nativeSet) nativeSet.call(el, text)
|
||||
else el.value = text
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return { success: true, method: 'native-setter' }
|
||||
}
|
||||
|
||||
if (isContentEditable) {
|
||||
el.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
if (isDraftEditor(el)) {
|
||||
document.execCommand('delete', false)
|
||||
document.execCommand('insertText', false, text)
|
||||
return { success: true, method: 'draft-editor' }
|
||||
}
|
||||
|
||||
if (isSlateEditor(el)) {
|
||||
document.execCommand('insertText', false, text)
|
||||
return { success: true, method: 'slate-editor' }
|
||||
}
|
||||
|
||||
const ok = document.execCommand('insertText', false, text)
|
||||
if (ok) return { success: true, method: 'execCommand' }
|
||||
|
||||
el.textContent = text
|
||||
el.dispatchEvent(new InputEvent('beforeinput', {
|
||||
bubbles: true, cancelable: true, inputType: 'insertText', data: text,
|
||||
}))
|
||||
el.dispatchEvent(new InputEvent('input', {
|
||||
bubbles: true, inputType: 'insertText', data: text,
|
||||
}))
|
||||
return { success: true, method: 'textContent-fallback' }
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported element: ${tag}, contentEditable=${el.isContentEditable}` }
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.inputEnhanced')
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* MV3 Service Worker keepalive mechanism.
|
||||
*
|
||||
* Two-layer keepalive strategy to prevent the Service Worker from being suspended:
|
||||
*
|
||||
* Layer 1: chrome.alarms (already implemented in background.js)
|
||||
* - "relayKeepAlive" alarm fires every 15 seconds
|
||||
* - Sends ping to relay server if connected
|
||||
*
|
||||
* Layer 2: Content Script heartbeat (this module)
|
||||
* - Injected into the active tab periodically
|
||||
* - Establishes a long-lived port connection with the background
|
||||
* - Sends ping every 20 seconds
|
||||
* - Auto-reconnects on disconnect
|
||||
*
|
||||
* Usage: Call startKeepAlive(tabId) when a session is active,
|
||||
* stopKeepAlive(tabId) when done. Not wired into dispatch — called
|
||||
* directly from background.js when needed.
|
||||
*/
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 20_000
|
||||
const PORT_NAME = 'accio-keepalive'
|
||||
|
||||
const activeKeepAlives = new Map()
|
||||
|
||||
export async function startKeepAlive(tabId) {
|
||||
if (activeKeepAlives.has(tabId)) return
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (portName, interval) => {
|
||||
if (window._accioKeepAlive) return
|
||||
|
||||
function connect() {
|
||||
try {
|
||||
const port = chrome.runtime.connect({ name: portName })
|
||||
const timer = setInterval(() => {
|
||||
try { port.postMessage({ type: 'ping' }) } catch { clearInterval(timer) }
|
||||
}, interval)
|
||||
port.onDisconnect.addListener(() => {
|
||||
clearInterval(timer)
|
||||
setTimeout(connect, 1000)
|
||||
})
|
||||
window._accioKeepAlive = { port, timer }
|
||||
} catch { /* extension context invalidated */ }
|
||||
}
|
||||
|
||||
connect()
|
||||
},
|
||||
args: [PORT_NAME, HEARTBEAT_INTERVAL_MS],
|
||||
})
|
||||
activeKeepAlives.set(tabId, true)
|
||||
} catch { /* tab may be restricted */ }
|
||||
}
|
||||
|
||||
export async function stopKeepAlive(tabId) {
|
||||
if (!activeKeepAlives.has(tabId)) return
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
if (window._accioKeepAlive) {
|
||||
clearInterval(window._accioKeepAlive.timer)
|
||||
try { window._accioKeepAlive.port.disconnect() } catch { /* noop */ }
|
||||
window._accioKeepAlive = null
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch { /* tab may have closed */ }
|
||||
|
||||
activeKeepAlives.delete(tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the keepalive port listener in the background script.
|
||||
* Call this once during initialization.
|
||||
*/
|
||||
export function registerKeepAliveListener() {
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (port.name !== PORT_NAME) return
|
||||
port.onMessage.addListener((msg) => {
|
||||
if (msg?.type === 'ping') {
|
||||
try { port.postMessage({ type: 'pong' }) } catch { /* port closed */ }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Extension.moveMouse — Mouse hover simulation.
|
||||
*
|
||||
* Dispatches mousemove and mouseover events to trigger hover states.
|
||||
* Supports coordinate-based and element-based targeting.
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extMoveMouse(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
function deepQuery(root, sel) {
|
||||
const found = root.querySelector(sel)
|
||||
if (found) return found
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const inner = deepQuery(el.shadowRoot, sel)
|
||||
if (inner) return inner
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let el, cx, cy
|
||||
|
||||
if (p.index) {
|
||||
el = deepQuery(document, `[data-accio-idx="${p.index}"]`)
|
||||
} else if (p.selector) {
|
||||
el = deepQuery(document, p.selector)
|
||||
}
|
||||
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
cx = rect.left + rect.width / 2
|
||||
cy = rect.top + rect.height / 2
|
||||
} else if (p.x != null && p.y != null) {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x
|
||||
cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y
|
||||
el = document.elementFromPoint(cx, cy)
|
||||
if (el?.shadowRoot) {
|
||||
const inner = el.shadowRoot.elementFromPoint(cx, cy)
|
||||
if (inner) el = inner
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) return { success: false, error: 'Target element not found' }
|
||||
|
||||
const eventOpts = {
|
||||
bubbles: true, cancelable: true, composed: true,
|
||||
clientX: cx, clientY: cy, view: window,
|
||||
}
|
||||
|
||||
el.dispatchEvent(new MouseEvent('mouseover', eventOpts))
|
||||
el.dispatchEvent(new MouseEvent('mouseenter', { ...eventOpts, bubbles: false }))
|
||||
el.dispatchEvent(new MouseEvent('mousemove', eventOpts))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
position: { x: Math.round(cx), y: Math.round(cy) },
|
||||
}
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.moveMouse')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Extension.pressKey — Keyboard event simulation.
|
||||
*
|
||||
* Supports single keys, modifier keys, and combo strings like "Ctrl+A", "Shift+Enter".
|
||||
* Dispatches keydown, keypress (for printable), and keyup events.
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extPressKey(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
const KEY_MAP = {
|
||||
enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
|
||||
tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
|
||||
escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||
esc: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
||||
backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
||||
delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
|
||||
space: { key: ' ', code: 'Space', keyCode: 32 },
|
||||
arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
||||
arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
||||
arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
||||
arrowright: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
||||
home: { key: 'Home', code: 'Home', keyCode: 36 },
|
||||
end: { key: 'End', code: 'End', keyCode: 35 },
|
||||
pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
||||
pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
KEY_MAP[`f${i}`] = { key: `F${i}`, code: `F${i}`, keyCode: 111 + i }
|
||||
}
|
||||
|
||||
const MODIFIER_NAMES = new Set(['ctrl', 'control', 'shift', 'alt', 'meta', 'cmd', 'command'])
|
||||
|
||||
function resolveKey(name) {
|
||||
const lower = name.toLowerCase().trim()
|
||||
if (KEY_MAP[lower]) return KEY_MAP[lower]
|
||||
if (lower.length === 1) {
|
||||
const upper = lower.toUpperCase()
|
||||
return { key: lower, code: `Key${upper}`, keyCode: upper.charCodeAt(0) }
|
||||
}
|
||||
return { key: name, code: name, keyCode: 0 }
|
||||
}
|
||||
|
||||
function parseCombo(combo) {
|
||||
const parts = String(combo).split('+').map(s => s.trim()).filter(Boolean)
|
||||
const modifiers = { ctrlKey: false, shiftKey: false, altKey: false, metaKey: false }
|
||||
let mainKey = null
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
if (lower === 'ctrl' || lower === 'control') modifiers.ctrlKey = true
|
||||
else if (lower === 'shift') modifiers.shiftKey = true
|
||||
else if (lower === 'alt') modifiers.altKey = true
|
||||
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') modifiers.metaKey = true
|
||||
else mainKey = resolveKey(part)
|
||||
}
|
||||
|
||||
if (!mainKey && parts.length === 1) mainKey = resolveKey(parts[0])
|
||||
return { modifiers, mainKey }
|
||||
}
|
||||
|
||||
const target = document.activeElement || document.body
|
||||
const combo = p.key || p.combo || ''
|
||||
const { modifiers, mainKey } = parseCombo(combo)
|
||||
|
||||
if (!mainKey) return { success: false, error: `Cannot resolve key: ${combo}` }
|
||||
|
||||
const isPrintable = mainKey.key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey
|
||||
|
||||
const baseOpts = {
|
||||
bubbles: true, cancelable: true, composed: true,
|
||||
key: mainKey.key, code: mainKey.code, keyCode: mainKey.keyCode, which: mainKey.keyCode,
|
||||
...modifiers,
|
||||
}
|
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', baseOpts))
|
||||
if (isPrintable) {
|
||||
target.dispatchEvent(new KeyboardEvent('keypress', { ...baseOpts, charCode: mainKey.key.charCodeAt(0) }))
|
||||
}
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', baseOpts))
|
||||
|
||||
return { success: true, key: mainKey.key, modifiers }
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.pressKey')
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Extension.scroll — Page and container scrolling with verification.
|
||||
*
|
||||
* Supports directional scrolling (up/down/left/right), scroll-to-end,
|
||||
* and container-specific scrolling by coordinates.
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extScroll(tabId, params) {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (p) => {
|
||||
const direction = (p.direction || 'down').toLowerCase()
|
||||
const toEnd = p.toEnd === true
|
||||
const amount = p.amount || null
|
||||
|
||||
function findScrollable(x, y) {
|
||||
let el = document.elementFromPoint(x, y)
|
||||
let depth = 0
|
||||
while (el && depth < 80) {
|
||||
const style = getComputedStyle(el)
|
||||
const overflowY = style.overflowY
|
||||
const overflowX = style.overflowX
|
||||
const isScrollableY = (overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight
|
||||
const isScrollableX = (overflowX === 'auto' || overflowX === 'scroll') && el.scrollWidth > el.clientWidth
|
||||
if ((direction === 'up' || direction === 'down') && isScrollableY) return el
|
||||
if ((direction === 'left' || direction === 'right') && isScrollableX) return el
|
||||
el = el.parentElement
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
let target = null
|
||||
if (p.x != null && p.y != null) {
|
||||
const cx = p.viewportWidth ? (p.x / p.viewportWidth) * vw : p.x
|
||||
const cy = p.viewportHeight ? (p.y / p.viewportHeight) * vh : p.y
|
||||
target = findScrollable(cx, cy)
|
||||
}
|
||||
|
||||
const scrollTarget = target || document.scrollingElement || document.documentElement
|
||||
const isWindow = scrollTarget === document.scrollingElement || scrollTarget === document.documentElement
|
||||
|
||||
const scrollAmount = amount || Math.max(120, Math.round((direction === 'left' || direction === 'right' ? vw : vh) * 0.6))
|
||||
|
||||
const beforeX = isWindow ? window.scrollX : scrollTarget.scrollLeft
|
||||
const beforeY = isWindow ? window.scrollY : scrollTarget.scrollTop
|
||||
|
||||
let targetX = beforeX
|
||||
let targetY = beforeY
|
||||
|
||||
if (toEnd) {
|
||||
if (direction === 'down') targetY = scrollTarget.scrollHeight
|
||||
else if (direction === 'up') targetY = 0
|
||||
else if (direction === 'right') targetX = scrollTarget.scrollWidth
|
||||
else if (direction === 'left') targetX = 0
|
||||
} else {
|
||||
if (direction === 'down') targetY = beforeY + scrollAmount
|
||||
else if (direction === 'up') targetY = beforeY - scrollAmount
|
||||
else if (direction === 'right') targetX = beforeX + scrollAmount
|
||||
else if (direction === 'left') targetX = beforeX - scrollAmount
|
||||
}
|
||||
|
||||
if (isWindow) {
|
||||
window.scrollTo({ left: targetX, top: targetY, behavior: 'instant' })
|
||||
} else {
|
||||
scrollTarget.scrollTo({ left: targetX, top: targetY, behavior: 'instant' })
|
||||
}
|
||||
|
||||
const afterX = isWindow ? window.scrollX : scrollTarget.scrollLeft
|
||||
const afterY = isWindow ? window.scrollY : scrollTarget.scrollTop
|
||||
|
||||
const scrolledX = Math.round(afterX - beforeX)
|
||||
const scrolledY = Math.round(afterY - beforeY)
|
||||
|
||||
const totalHeight = scrollTarget.scrollHeight
|
||||
const clientHeight = isWindow ? vh : scrollTarget.clientHeight
|
||||
const pixelsAbove = Math.round(afterY)
|
||||
const pixelsBelow = Math.max(0, Math.round(totalHeight - afterY - clientHeight))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
direction,
|
||||
scrolled: { x: scrolledX, y: scrolledY },
|
||||
container: target ? target.tagName.toLowerCase() : 'window',
|
||||
pixelsAbove,
|
||||
pixelsBelow,
|
||||
atTop: afterY <= 0,
|
||||
atBottom: pixelsBelow <= 1,
|
||||
}
|
||||
},
|
||||
args: [params || {}],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.scroll')
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Extension.waitForReady — Page readiness tracking.
|
||||
*
|
||||
* Waits for the page to reach a ready state before executing actions.
|
||||
* Uses document.readyState and optional MutationObserver for body detection.
|
||||
*
|
||||
* Ready criteria: "complete" OR ("interactive" + 1000ms elapsed)
|
||||
*/
|
||||
|
||||
import { unwrapScriptResult } from './helpers.js'
|
||||
|
||||
export async function extWaitForReady(tabId, params) {
|
||||
const timeoutMs = params?.timeout || 10000
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: (timeout) => {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = Date.now()
|
||||
|
||||
function check() {
|
||||
const state = document.readyState
|
||||
const elapsed = Date.now() - t0
|
||||
|
||||
if (state === 'complete') {
|
||||
return resolve({ ready: true, state, elapsed })
|
||||
}
|
||||
if (state === 'interactive' && elapsed >= 1000) {
|
||||
return resolve({ ready: true, state, elapsed, note: 'interactive + 1s' })
|
||||
}
|
||||
if (elapsed >= timeout) {
|
||||
return resolve({ ready: false, state, elapsed, error: 'Timeout waiting for page ready' })
|
||||
}
|
||||
setTimeout(check, 200)
|
||||
}
|
||||
|
||||
if (!document.body) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.body) {
|
||||
observer.disconnect()
|
||||
check()
|
||||
}
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true })
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
check()
|
||||
}, Math.min(timeout, 5000))
|
||||
} else {
|
||||
check()
|
||||
}
|
||||
})
|
||||
},
|
||||
args: [timeoutMs],
|
||||
})
|
||||
return unwrapScriptResult(results, 'Extension.waitForReady')
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Dynamic icon badge — draws a small colored dot on the extension icon.
|
||||
*
|
||||
* Uses OffscreenCanvas (available in MV3 service workers) to composite
|
||||
* the base icon with a status indicator dot in the bottom-right corner.
|
||||
*/
|
||||
|
||||
const ICON_SIZES = [16, 32]
|
||||
|
||||
/** @type {Map<number, ImageBitmap>} */
|
||||
const iconCache = new Map()
|
||||
|
||||
async function loadIcon(size) {
|
||||
if (iconCache.has(size)) return iconCache.get(size)
|
||||
const url = chrome.runtime.getURL(`icons/icon${size}.png`)
|
||||
const resp = await fetch(url)
|
||||
const blob = await resp.blob()
|
||||
const bmp = await createImageBitmap(blob)
|
||||
iconCache.set(size, bmp)
|
||||
return bmp
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the extension icon with an optional colored status dot.
|
||||
* @param {string|null} dotColor — CSS color for the dot, or null to remove it
|
||||
*/
|
||||
export async function setIconWithDot(dotColor) {
|
||||
if (!dotColor) {
|
||||
await chrome.action.setIcon({
|
||||
path: { '16': 'icons/icon16.png', '32': 'icons/icon32.png', '48': 'icons/icon48.png', '128': 'icons/icon128.png' },
|
||||
})
|
||||
// Clear any leftover badge text
|
||||
void chrome.action.setBadgeText({ text: '' })
|
||||
return
|
||||
}
|
||||
|
||||
/** @type {Record<string, ImageData>} */
|
||||
const imageData = {}
|
||||
|
||||
for (const size of ICON_SIZES) {
|
||||
const bmp = await loadIcon(size)
|
||||
const canvas = new OffscreenCanvas(size, size)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(bmp, 0, 0, size, size)
|
||||
|
||||
const dotRadius = Math.round(size * 0.20)
|
||||
const cx = size - dotRadius - 1
|
||||
const cy = size - dotRadius - 1
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, dotRadius + 1, 0, Math.PI * 2)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2)
|
||||
ctx.fillStyle = dotColor
|
||||
ctx.fill()
|
||||
|
||||
imageData[String(size)] = ctx.getImageData(0, 0, size, size)
|
||||
}
|
||||
|
||||
await chrome.action.setIcon({ imageData })
|
||||
void chrome.action.setBadgeText({ text: '' })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Debug logger — controlled by a module-level flag.
|
||||
*
|
||||
* When `DEBUG` is true, all log/warn/error calls forward to the console
|
||||
* with a [lime-relay] prefix and a module tag. When false, only warn/error emit.
|
||||
*
|
||||
* Usage:
|
||||
* import { createLogger, setDebug } from './logger.js'
|
||||
* const log = createLogger('relay')
|
||||
* setDebug(true)
|
||||
* log.debug('connected', { port: 9236 }) // → console.debug('[lime:relay] connected', {port:9236})
|
||||
* log.info('ok') // → console.info('[lime:relay] ok')
|
||||
* log.warn('slow') // always prints
|
||||
* log.error('fail', err) // always prints
|
||||
*/
|
||||
|
||||
let DEBUG = false
|
||||
|
||||
export function setDebug(enabled) {
|
||||
DEBUG = !!enabled
|
||||
}
|
||||
|
||||
export function isDebug() {
|
||||
return DEBUG
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tagged logger.
|
||||
* @param {string} module — short tag (e.g. 'relay', 'tabs', 'cdp', 'bg')
|
||||
*/
|
||||
export function createLogger(module) {
|
||||
const prefix = `[lime:${module}]`
|
||||
|
||||
return {
|
||||
/** Only prints when DEBUG is true */
|
||||
debug(...args) {
|
||||
if (DEBUG) console.debug(prefix, ...args)
|
||||
},
|
||||
|
||||
/** Only prints when DEBUG is true */
|
||||
info(...args) {
|
||||
if (DEBUG) console.info(prefix, ...args)
|
||||
},
|
||||
|
||||
/** Always prints */
|
||||
warn(...args) {
|
||||
console.warn(prefix, ...args)
|
||||
},
|
||||
|
||||
/** Always prints */
|
||||
error(...args) {
|
||||
console.error(prefix, ...args)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,42 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Lime Browser Bridge",
|
||||
"description": "Connect Chrome tabs with Lime browser runtime for AI agents.",
|
||||
"version": "0.2.0",
|
||||
"permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"description": "Attach Lime to your current Chrome tab via the Lime browser bridge.",
|
||||
"version": "0.3.0",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"tabs",
|
||||
"tabGroups",
|
||||
"windows",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage",
|
||||
"clipboardRead",
|
||||
"alarms",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Lime Browser Bridge",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"js": ["content_script.js"],
|
||||
"run_at": "document_idle"
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "pages/options.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Connection Methods Comparison</title>
|
||||
<link rel="stylesheet" href="styles/install-common.css" />
|
||||
<style>
|
||||
.compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.compare-card {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 24px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.compare-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.compare-card.recommended {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.compare-card-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.compare-card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.compare-card.recommended .compare-card-icon {
|
||||
background: color-mix(in oklab, var(--accent) 15%, transparent);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.compare-card:not(.recommended) .compare-card-icon {
|
||||
background: color-mix(in oklab, #6366f1 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, #6366f1 20%, transparent);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.compare-card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.compare-card-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.compare-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compare-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.compare-item-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.compare-item-icon.pro {
|
||||
background: color-mix(in oklab, var(--green) 12%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.compare-item-icon.con {
|
||||
background: color-mix(in oklab, #F59E0B 12%, transparent);
|
||||
color: #F59E0B;
|
||||
}
|
||||
|
||||
.compare-item-icon svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.compare-divider {
|
||||
margin: 40px 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.compare-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.compare-divider-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--dim);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feature-table th,
|
||||
.feature-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feature-table tr:last-child th,
|
||||
.feature-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feature-table th {
|
||||
font-weight: 600;
|
||||
color: var(--dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.feature-table td {
|
||||
color: var(--muted);
|
||||
background: canvas;
|
||||
}
|
||||
|
||||
.feature-table td:first-child {
|
||||
font-weight: 600;
|
||||
color: canvasText;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-table .check {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.feature-table .cross {
|
||||
color: var(--dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.choose-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.choose-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
-webkit-app-region: no-drag;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.choose-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.choose-btn:active { transform: scale(0.99); }
|
||||
|
||||
.choose-btn svg:first-child {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.choose-btn .arrow {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.choose-btn.primary {
|
||||
border-color: #0ea5e9;
|
||||
background: #0ea5e9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.choose-btn.primary:hover {
|
||||
background: #0284c7;
|
||||
border-color: #0284c7;
|
||||
}
|
||||
|
||||
.choose-btn.primary .arrow { opacity: 0.7; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.compare-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.compare-card { animation: step-enter 0.4s ease-out both; }
|
||||
.compare-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.compare-card:nth-child(2) { animation-delay: 0.2s; }
|
||||
.feature-table { animation: step-enter 0.4s ease-out 0.3s both; }
|
||||
.choose-section { animation: step-enter 0.4s ease-out 0.4s both; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="drag-handle"></div>
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-icon">
|
||||
<img src="../icons/icon128.png" alt="Lime Browser Bridge" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 data-i18n="title">Choose Your Connection Method</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtitle" data-i18n="subtitle">
|
||||
Two ways to connect your browser. Choose the one that works best for you.
|
||||
</p>
|
||||
|
||||
<!-- ── Side-by-side cards ── -->
|
||||
|
||||
<div class="compare-grid">
|
||||
|
||||
<!-- Extension -->
|
||||
<div class="compare-card recommended">
|
||||
<div class="compare-card-badge" data-i18n="badge_recommended">Recommended</div>
|
||||
<div class="compare-card-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.5 2.5v3h3v-3a1.5 1.5 0 00-3 0z"/>
|
||||
<path d="M14 6H10a1 1 0 00-1 1v3H5.5a1.5 1.5 0 000 3H9v4a1 1 0 001 1h4a1 1 0 001-1v-4h3.5a1.5 1.5 0 000-3H15V7a1 1 0 00-1-1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="compare-card-title" data-i18n="ext_title">Browser Extension</div>
|
||||
<p class="compare-card-subtitle" data-i18n="ext_subtitle">Install once, connect automatically. No repeated authorization needed.</p>
|
||||
<ul class="compare-list">
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon pro"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 5.5l2 2 4-4"/></svg></span>
|
||||
<span data-i18n="ext_pro1">Auto-connects on every launch</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon pro"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 5.5l2 2 4-4"/></svg></span>
|
||||
<span data-i18n="ext_pro2">No manual approval required</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon pro"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 5.5l2 2 4-4"/></svg></span>
|
||||
<span data-i18n="ext_pro3">Works with all Chrome versions</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon con"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 3l4 4M7 3l-4 4"/></svg></span>
|
||||
<span data-i18n="ext_con1">Requires installing a Chrome extension</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CDP Direct -->
|
||||
<div class="compare-card">
|
||||
<div class="compare-card-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 8l-4 4 4 4"/>
|
||||
<path d="M17 8l4 4-4 4"/>
|
||||
<path d="M14 4l-4 16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="compare-card-title" data-i18n="cdp_title">CDP Direct Connect</div>
|
||||
<p class="compare-card-subtitle" data-i18n="cdp_subtitle">Zero installation. Connect directly via Chrome DevTools Protocol.</p>
|
||||
<ul class="compare-list">
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon pro"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 5.5l2 2 4-4"/></svg></span>
|
||||
<span data-i18n="cdp_pro1">No extension installation needed</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon pro"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M2 5.5l2 2 4-4"/></svg></span>
|
||||
<span data-i18n="cdp_pro2">Works out of the box on Chrome 144+</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon con"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 3l4 4M7 3l-4 4"/></svg></span>
|
||||
<span data-i18n="cdp_con1">Requires re-authorization after every Lime or Chrome restart</span>
|
||||
</li>
|
||||
<li class="compare-item">
|
||||
<span class="compare-item-icon con"><svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 3l4 4M7 3l-4 4"/></svg></span>
|
||||
<span data-i18n="cdp_con2">Chrome 144+ only</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Feature table ── -->
|
||||
|
||||
<div class="compare-divider">
|
||||
<span class="compare-divider-line"></span>
|
||||
<span class="compare-divider-text" data-i18n="table_title">Feature Comparison</span>
|
||||
<span class="compare-divider-line"></span>
|
||||
</div>
|
||||
|
||||
<table class="feature-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="th_feature">Feature</th>
|
||||
<th data-i18n="th_extension">Extension</th>
|
||||
<th data-i18n="th_cdp">CDP Direct</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td data-i18n="f_setup">Initial Setup</td>
|
||||
<td data-i18n="f_setup_ext">Install extension</td>
|
||||
<td data-i18n="f_setup_cdp">Enable in chrome://inspect</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="f_reconnect">Reconnect after restart</td>
|
||||
<td><span class="check" data-i18n="f_auto">Automatic</span></td>
|
||||
<td data-i18n="f_manual">Manual (click "Allow")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="f_chrome_ver">Chrome version</td>
|
||||
<td data-i18n="f_all_ver">All versions</td>
|
||||
<td>144+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="f_stability">Connection stability</td>
|
||||
<td><span class="check" data-i18n="f_high">High</span></td>
|
||||
<td><span data-i18n="f_high2">Medium</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="f_install">Requires installation</td>
|
||||
<td><span class="cross">Yes</span></td>
|
||||
<td><span class="check">No</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td data-i18n="f_banner">Automation banner</td>
|
||||
<td data-i18n="f_banner_ext">Extension debugging notice</td>
|
||||
<td data-i18n="f_banner_cdp">Automated test software notice</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ── Choose buttons ── -->
|
||||
|
||||
<div class="choose-section">
|
||||
<button class="choose-btn primary" onclick="navigateToInstallPage('install-extension.html')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.5 2.5v3h3v-3a1.5 1.5 0 00-3 0z"/>
|
||||
<path d="M14 6H10a1 1 0 00-1 1v3H5.5a1.5 1.5 0 000 3H9v4a1 1 0 001 1h4a1 1 0 001-1v-4h3.5a1.5 1.5 0 000-3H15V7a1 1 0 00-1-1z"/>
|
||||
</svg>
|
||||
<span data-i18n="choose_ext">Use Extension (Recommended)</span>
|
||||
<svg class="arrow" width="12" height="12" viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
<button class="choose-btn" onclick="navigateToInstallPage('install-direct-cdp.html')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 8l-4 4 4 4"/>
|
||||
<path d="M17 8l4 4-4 4"/>
|
||||
<path d="M14 4l-4 16"/>
|
||||
</svg>
|
||||
<span data-i18n="choose_cdp">Use CDP Direct Connect</span>
|
||||
<svg class="arrow" width="12" height="12" viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="scripts/install-i18n.js"></script>
|
||||
<script src="scripts/install-helpers.js"></script>
|
||||
<script>
|
||||
InstallI18n.register('en', {
|
||||
title: 'Choose Your Connection Method',
|
||||
subtitle: 'Two ways to connect your browser. Choose the one that works best for you.',
|
||||
badge_recommended: 'Recommended',
|
||||
ext_title: 'Browser Extension',
|
||||
ext_subtitle: 'Install once, connect automatically. No repeated authorization needed.',
|
||||
ext_pro1: 'Auto-connects on every launch',
|
||||
ext_pro2: 'No manual approval required',
|
||||
ext_pro3: 'Works with all Chrome versions',
|
||||
ext_con1: 'Requires installing a Chrome extension',
|
||||
cdp_title: 'CDP Direct Connect',
|
||||
cdp_subtitle: 'Zero installation. Connect directly via Chrome DevTools Protocol.',
|
||||
cdp_pro1: 'No extension installation needed',
|
||||
cdp_pro2: 'Works out of the box on Chrome 144+',
|
||||
cdp_con1: 'Requires re-authorization after every Lime or Chrome restart',
|
||||
cdp_con2: 'Chrome 144+ only',
|
||||
table_title: 'Feature Comparison',
|
||||
th_feature: 'Feature',
|
||||
th_extension: 'Extension',
|
||||
th_cdp: 'CDP Direct',
|
||||
f_setup: 'Initial setup',
|
||||
f_setup_ext: 'Install extension',
|
||||
f_setup_cdp: 'Enable in chrome://inspect',
|
||||
f_reconnect: 'Reconnect after restart',
|
||||
f_auto: 'Automatic',
|
||||
f_manual: 'Manual (click "Allow")',
|
||||
f_chrome_ver: 'Chrome version',
|
||||
f_all_ver: 'All versions',
|
||||
f_stability: 'Connection stability',
|
||||
f_high: 'High',
|
||||
f_high2: 'Medium',
|
||||
f_install: 'Requires installation',
|
||||
f_banner: 'Automation banner',
|
||||
f_banner_ext: 'Extension debugging notice',
|
||||
f_banner_cdp: 'Automated test software notice',
|
||||
choose_ext: 'Use Extension (Recommended)',
|
||||
choose_cdp: 'Use CDP Direct Connect',
|
||||
});
|
||||
InstallI18n.register('zh', {
|
||||
title: '选择连接方式',
|
||||
subtitle: '两种方式将浏览器连接到 Lime,选择最适合你的方案。',
|
||||
badge_recommended: '推荐',
|
||||
ext_title: '浏览器扩展',
|
||||
ext_subtitle: '安装一次,自动连接。无需反复授权。',
|
||||
ext_pro1: '每次启动自动连接',
|
||||
ext_pro2: '无需手动点击授权',
|
||||
ext_pro3: '支持所有 Chrome 版本',
|
||||
ext_con1: '需要安装一个 Chrome 扩展',
|
||||
cdp_title: 'CDP 直连',
|
||||
cdp_subtitle: '零安装。通过 Chrome DevTools 协议直接连接。',
|
||||
cdp_pro1: '无需安装任何扩展',
|
||||
cdp_pro2: 'Chrome 144+ 即开即用',
|
||||
cdp_con1: '每次重启 Lime 或 Chrome 后需要重新授权',
|
||||
cdp_con2: '仅支持 Chrome 144+',
|
||||
table_title: '功能对比',
|
||||
th_feature: '功能',
|
||||
th_extension: '扩展方式',
|
||||
th_cdp: 'CDP 直连',
|
||||
f_setup: '初始设置',
|
||||
f_setup_ext: '安装扩展',
|
||||
f_setup_cdp: '在 chrome://inspect 中启用',
|
||||
f_reconnect: '重启后重连',
|
||||
f_auto: '自动',
|
||||
f_manual: '手动(点击"允许")',
|
||||
f_chrome_ver: 'Chrome 版本',
|
||||
f_all_ver: '所有版本',
|
||||
f_stability: '连接稳定性',
|
||||
f_high: '高',
|
||||
f_high2: '中',
|
||||
f_install: '需要安装',
|
||||
f_banner: '自动化提示',
|
||||
f_banner_ext: '扩展调试提示',
|
||||
f_banner_cdp: '自动化测试软件提示',
|
||||
choose_ext: '使用扩展方式(推荐)',
|
||||
choose_cdp: '使用 CDP 直连',
|
||||
});
|
||||
InstallI18n.register('de', {
|
||||
title: 'Wählen Sie Ihre Verbindungsmethode',
|
||||
subtitle: 'Zwei Möglichkeiten, Ihren Browser zu verbinden. Wählen Sie diejenige, die am besten für Sie funktioniert.',
|
||||
badge_recommended: 'Empfohlen',
|
||||
ext_title: 'Browser-Erweiterung',
|
||||
ext_subtitle: 'Einmal installieren, automatisch verbinden. Keine wiederholte Autorisierung erforderlich.',
|
||||
ext_pro1: 'Verbindet sich automatisch bei jedem Start',
|
||||
ext_pro2: 'Keine manuelle Genehmigung erforderlich',
|
||||
ext_pro3: 'Funktioniert mit allen Chrome-Versionen',
|
||||
ext_con1: 'Erfordert die Installation einer Chrome-Erweiterung',
|
||||
cdp_title: 'CDP-Direktverbindung',
|
||||
cdp_subtitle: 'Keine Installation. Direkte Verbindung über das Chrome DevTools Protocol.',
|
||||
cdp_pro1: 'Keine Installation einer Erweiterung erforderlich',
|
||||
cdp_pro2: 'Funktioniert sofort mit Chrome 144+',
|
||||
cdp_con1: 'Erfordert eine erneute Autorisierung nach jedem Neustart von Lime oder Chrome',
|
||||
cdp_con2: 'Nur Chrome 144+',
|
||||
table_title: 'Funktionsvergleich',
|
||||
th_feature: 'Funktion',
|
||||
th_extension: 'Erweiterung',
|
||||
th_cdp: 'CDP-Direkt',
|
||||
f_setup: 'Ersteinrichtung',
|
||||
f_setup_ext: 'Erweiterung installieren',
|
||||
f_setup_cdp: 'In chrome://inspect aktivieren',
|
||||
f_reconnect: 'Wiederverbindung nach Neustart',
|
||||
f_auto: 'Automatisch',
|
||||
f_manual: 'Manuell (auf "Zulassen" klicken)',
|
||||
f_chrome_ver: 'Chrome-Version',
|
||||
f_all_ver: 'Alle Versionen',
|
||||
f_stability: 'Verbindungsstabilität',
|
||||
f_high: 'Hoch',
|
||||
f_high2: 'Mittel',
|
||||
f_install: 'Erfordert Installation',
|
||||
f_banner: 'Automatisierungsbanner',
|
||||
f_banner_ext: 'Hinweis zum Debugging der Erweiterung',
|
||||
f_banner_cdp: 'Hinweis auf automatisierte Testsoftware',
|
||||
choose_ext: 'Erweiterung verwenden (Empfohlen)',
|
||||
choose_cdp: 'CDP-Direktverbindung verwenden',
|
||||
});
|
||||
InstallI18n.register('es', {
|
||||
title: 'Elija su método de conexión',
|
||||
subtitle: 'Dos formas de conectar su navegador. Elija la que mejor se adapte a sus necesidades.',
|
||||
badge_recommended: 'Recomendado',
|
||||
ext_title: 'Extensión del navegador',
|
||||
ext_subtitle: 'Instale una vez, conéctese automáticamente. No se requiere autorización repetida.',
|
||||
ext_pro1: 'Se conecta automáticamente en cada inicio',
|
||||
ext_pro2: 'No se requiere aprobación manual',
|
||||
ext_pro3: 'Funciona con todas las versiones de Chrome',
|
||||
ext_con1: 'Requiere instalar una extensión de Chrome',
|
||||
cdp_title: 'Conexión directa CDP',
|
||||
cdp_subtitle: 'Sin instalación. Conéctese directamente a través del protocolo Chrome DevTools.',
|
||||
cdp_pro1: 'No se necesita instalar ninguna extensión',
|
||||
cdp_pro2: 'Funciona de inmediato en Chrome 144+',
|
||||
cdp_con1: 'Requiere reautorización después de cada reinicio de Lime o Chrome',
|
||||
cdp_con2: 'Solo Chrome 144+',
|
||||
table_title: 'Comparación de funciones',
|
||||
th_feature: 'Función',
|
||||
th_extension: 'Extensión',
|
||||
th_cdp: 'CDP directo',
|
||||
f_setup: 'Configuración inicial',
|
||||
f_setup_ext: 'Instalar extensión',
|
||||
f_setup_cdp: 'Habilitar en chrome://inspect',
|
||||
f_reconnect: 'Reconexión después de reiniciar',
|
||||
f_auto: 'Automática',
|
||||
f_manual: 'Manual (haga clic en "Permitir")',
|
||||
f_chrome_ver: 'Versión de Chrome',
|
||||
f_all_ver: 'Todas las versiones',
|
||||
f_stability: 'Estabilidad de la conexión',
|
||||
f_high: 'Alta',
|
||||
f_high2: 'Media',
|
||||
f_install: 'Requiere instalación',
|
||||
f_banner: 'Banner de automatización',
|
||||
f_banner_ext: 'Aviso de depuración de la extensión',
|
||||
f_banner_cdp: 'Aviso de software de prueba automatizado',
|
||||
choose_ext: 'Usar extensión (Recomendado)',
|
||||
choose_cdp: 'Usar conexión directa CDP',
|
||||
});
|
||||
InstallI18n.register('fr', {
|
||||
title: 'Choisissez votre méthode de connexion',
|
||||
subtitle: 'Deux façons de connecter votre navigateur. Choisissez celle qui vous convient le mieux.',
|
||||
badge_recommended: 'Recommandé',
|
||||
ext_title: 'Extension de navigateur',
|
||||
ext_subtitle: 'Installez une seule fois, connectez-vous automatiquement. Aucune autorisation répétée n’est nécessaire.',
|
||||
ext_pro1: 'Connexion automatique à chaque lancement',
|
||||
ext_pro2: 'Aucune approbation manuelle requise',
|
||||
ext_pro3: 'Fonctionne avec toutes les versions de Chrome',
|
||||
ext_con1: 'Nécessite l’installation d’une extension Chrome',
|
||||
cdp_title: 'Connexion directe CDP',
|
||||
cdp_subtitle: 'Zéro installation. Connectez-vous directement via le protocole Chrome DevTools.',
|
||||
cdp_pro1: 'Aucune installation d’extension n’est nécessaire',
|
||||
cdp_pro2: 'Fonctionne immédiatement sur Chrome 144+',
|
||||
cdp_con1: 'Nécessite une réautorisation après chaque redémarrage d’Lime ou de Chrome',
|
||||
cdp_con2: 'Chrome 144+ uniquement',
|
||||
table_title: 'Comparaison des fonctionnalités',
|
||||
th_feature: 'Fonctionnalité',
|
||||
th_extension: 'Extension',
|
||||
th_cdp: 'CDP direct',
|
||||
f_setup: 'Configuration initiale',
|
||||
f_setup_ext: 'Installer l’extension',
|
||||
f_setup_cdp: 'Activer dans chrome://inspect',
|
||||
f_reconnect: 'Reconnexion après redémarrage',
|
||||
f_auto: 'Automatique',
|
||||
f_manual: 'Manuelle (cliquer sur "Autoriser")',
|
||||
f_chrome_ver: 'Version de Chrome',
|
||||
f_all_ver: 'Toutes les versions',
|
||||
f_stability: 'Stabilité de la connexion',
|
||||
f_high: 'Haute',
|
||||
f_high2: 'Moyenne',
|
||||
f_install: 'Nécessite une installation',
|
||||
f_banner: 'Bannière d’automatisation',
|
||||
f_banner_ext: 'Avis de débogage de l’extension',
|
||||
f_banner_cdp: 'Avis de logiciel de test automatisé',
|
||||
choose_ext: 'Utiliser l’extension (Recommandé)',
|
||||
choose_cdp: 'Utiliser la connexion directe CDP',
|
||||
});
|
||||
InstallI18n.register('pt', {
|
||||
title: 'Escolha seu método de conexão',
|
||||
subtitle: 'Duas formas de conectar seu navegador. Escolha a que melhor funciona para você.',
|
||||
badge_recommended: 'Recomendado',
|
||||
ext_title: 'Extensão do navegador',
|
||||
ext_subtitle: 'Instale uma vez, conecte-se automaticamente. Não é necessária autorização repetida.',
|
||||
ext_pro1: 'Conecta-se automaticamente em cada inicialização',
|
||||
ext_pro2: 'Nenhuma aprovação manual necessária',
|
||||
ext_pro3: 'Funciona com todas as versões do Chrome',
|
||||
ext_con1: 'Requer a instalação de uma extensão do Chrome',
|
||||
cdp_title: 'Conexão direta CDP',
|
||||
cdp_subtitle: 'Instalação zero. Conecte-se diretamente via protocolo Chrome DevTools.',
|
||||
cdp_pro1: 'Nenhuma instalação de extensão necessária',
|
||||
cdp_pro2: 'Funciona imediatamente no Chrome 144+',
|
||||
cdp_con1: 'Requer reautorização após cada reinicialização do Lime ou do Chrome',
|
||||
cdp_con2: 'Apenas Chrome 144+',
|
||||
table_title: 'Comparação de recursos',
|
||||
th_feature: 'Recurso',
|
||||
th_extension: 'Extensão',
|
||||
th_cdp: 'CDP direto',
|
||||
f_setup: 'Configuração inicial',
|
||||
f_setup_ext: 'Instalar extensão',
|
||||
f_setup_cdp: 'Ativar em chrome://inspect',
|
||||
f_reconnect: 'Reconexão após reinicialização',
|
||||
f_auto: 'Automática',
|
||||
f_manual: 'Manual (clique em "Permitir")',
|
||||
f_chrome_ver: 'Versão do Chrome',
|
||||
f_all_ver: 'Todas as versões',
|
||||
f_stability: 'Estabilidade da conexão',
|
||||
f_high: 'Alta',
|
||||
f_high2: 'Média',
|
||||
f_install: 'Requer instalação',
|
||||
f_banner: 'Banner de automação',
|
||||
f_banner_ext: 'Aviso de depuração da extensão',
|
||||
f_banner_cdp: 'Aviso de software de teste automatizado',
|
||||
choose_ext: 'Usar extensão (Recomendado)',
|
||||
choose_cdp: 'Usar conexão direta CDP',
|
||||
});
|
||||
|
||||
InstallI18n.apply();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,338 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Enable Browser Connection</title>
|
||||
<link rel="stylesheet" href="styles/install-common.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="drag-handle"></div>
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-icon">
|
||||
<img src="../icons/icon128.png" alt="Lime Browser Bridge" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 data-i18n="title">Enable Browser Connection</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtitle" data-i18n="subtitle">
|
||||
Your Chrome supports direct connection — no extension needed!
|
||||
</p>
|
||||
|
||||
<div id="browser-info" style="display:none;margin-bottom:24px;">
|
||||
<div class="note" style="margin-top:0">
|
||||
<div class="note-body" style="display:flex;align-items:center;gap:10px">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8.5l3 3 7-7"/>
|
||||
</svg>
|
||||
<span id="browser-info-text" style="color:var(--green);font-weight:600;font-size:13px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="step-list">
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step1_title">Enable Remote Debugging</div>
|
||||
<div class="step-body">
|
||||
<button class="open-btn" onclick="openChromeUrl('chrome://inspect/#remote-debugging')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8.5V12a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 012 12V5a1.5 1.5 0 011.5-1.5H7"/>
|
||||
<path d="M10 2h4v4"/>
|
||||
<path d="M7 9L14 2"/>
|
||||
</svg>
|
||||
<span data-i18n="step1_open">Open chrome://inspect/#remote-debugging</span>
|
||||
</button>
|
||||
<span data-i18n="step1_detail" style="display:block;margin-top:8px">Toggle the remote debugging switch to <strong>On</strong>.</span>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step1-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step1_more">Can't open? Copy URL instead</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step1-extras">
|
||||
<span data-i18n="step1_body">On Windows, Chrome may block this page from opening automatically. Copy and paste the URL into your address bar:</span>
|
||||
<div class="url-block" id="copy-url-block">
|
||||
chrome://inspect/#remote-debugging
|
||||
<svg class="copy-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/>
|
||||
<path d="M3.5 10.5h-1a1 1 0 01-1-1v-7a1 1 0 011-1h7a1 1 0 011 1v1"/>
|
||||
</svg>
|
||||
<span class="copy-label" style="font-size:11px;color:var(--muted)" data-i18n="click_to_copy">Click to copy</span>
|
||||
</div>
|
||||
<div class="note" style="margin-top:8px">
|
||||
<div class="note-title" data-i18n="extras_how_title">How it works</div>
|
||||
<div class="note-body" data-i18n="extras_how_body">
|
||||
Chrome M144+ includes a built-in remote debugging interface. Lime connects directly to Chrome via the DevTools Protocol (CDP) — faster and simpler than using an extension.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step2_title">Allow Connection</div>
|
||||
<div class="step-body">
|
||||
<span data-i18n="step2_body">When Lime requests a debugging session, Chrome will show a permission dialog. Click <strong>Allow</strong> to let Lime connect.</span>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step2-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step2_more">Details</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step2-extras">
|
||||
<div class="note" style="margin-top:8px">
|
||||
<div class="note-title" data-i18n="extras_banner_title">About the automation banner</div>
|
||||
<div class="note-body" data-i18n="extras_banner_body">
|
||||
While Lime is connected, Chrome displays <strong>"Chrome is being controlled by automated test software"</strong>. This is a standard security notice confirming the debugging session is active.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step3_title">You're All Set</div>
|
||||
<div class="step-body">
|
||||
<span data-i18n="step3_body">Lime can now directly control your browser tabs. No extension installation required.</span>
|
||||
<div class="note" style="margin-top:12px">
|
||||
<div class="note-title" data-i18n="extras_reconnect_title">When do I need to reconnect?</div>
|
||||
<div class="note-body" data-i18n="extras_reconnect_body">
|
||||
You need to click "Allow" again each time you restart Lime or Chrome. During normal use, the connection stays active automatically — no repeated approval needed.
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step3-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step3_more">Security details</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step3-extras">
|
||||
<div class="note" style="margin-top:8px">
|
||||
<div class="note-title" data-i18n="extras_security_title">Security</div>
|
||||
<div class="note-body" data-i18n="extras_security_body">
|
||||
Remote debugging only works on localhost (127.0.0.1). Each new session requires explicit user permission via the Chrome dialog. You can disable it at any time by toggling the switch off.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ol>
|
||||
|
||||
<div class="alt-method">
|
||||
<div class="alt-method-divider"></div>
|
||||
<button class="alt-method-btn" onclick="navigateToInstallPage('install-extension.html')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.5 2.5v3h3"/>
|
||||
<path d="M5.5 13.5v-3h-3"/>
|
||||
<path d="M13.5 5.5h-3v-3"/>
|
||||
<path d="M2.5 10.5h3v3"/>
|
||||
</svg>
|
||||
<span data-i18n="alt_use_extension">Having trouble? Use the extension method instead</span>
|
||||
<svg width="12" height="12" viewBox="0 0 8 12" fill="currentColor" style="opacity:0.5"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="alt-method" style="margin-top:10px">
|
||||
<button class="alt-method-btn" onclick="navigateToInstallPage('compare-methods.html')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1.5" y="2.5" width="5" height="11" rx="1"/>
|
||||
<rect x="9.5" y="2.5" width="5" height="11" rx="1"/>
|
||||
</svg>
|
||||
<span data-i18n="compare_methods">Compare connection methods</span>
|
||||
<svg width="12" height="12" viewBox="0 0 8 12" fill="currentColor" style="opacity:0.5"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="scripts/install-i18n.js"></script>
|
||||
<script src="scripts/install-helpers.js"></script>
|
||||
<script>
|
||||
InstallI18n.register('en', {
|
||||
title: 'Enable Browser Connection',
|
||||
subtitle: 'Your Chrome supports direct connection — no extension needed!',
|
||||
step1_title: 'Enable Remote Debugging',
|
||||
step1_open: 'Open chrome://inspect/#remote-debugging',
|
||||
step1_more: 'Can\'t open? Copy URL instead',
|
||||
step1_body: 'On Windows, Chrome may block this page from opening automatically. Copy and paste the URL into your address bar:',
|
||||
step1_detail: 'Toggle the remote debugging switch to <strong>On</strong>.',
|
||||
step2_title: 'Allow Connection',
|
||||
step2_body: 'When Lime requests a debugging session, Chrome will show a permission dialog. Click <strong>Allow</strong> to let Lime connect.',
|
||||
step2_more: 'Details',
|
||||
step3_title: 'You\'re All Set',
|
||||
step3_body: 'Lime can now directly control your browser tabs. No extension installation required.',
|
||||
step3_more: 'Security details',
|
||||
click_to_copy: 'Click to copy',
|
||||
more_info: 'More information',
|
||||
extras_how_title: 'How it works',
|
||||
extras_how_body: 'Chrome M144+ includes a built-in remote debugging interface. Lime connects directly to Chrome via the DevTools Protocol (CDP) — faster and simpler than using an extension.',
|
||||
extras_banner_title: 'About the automation banner',
|
||||
extras_banner_body: 'While Lime is connected, Chrome displays <strong>"Chrome is being controlled by automated test software"</strong>. This is a standard security notice confirming the debugging session is active.',
|
||||
extras_reconnect_title: 'When do I need to reconnect?',
|
||||
extras_reconnect_body: 'You need to click "Allow" again each time you restart Lime or Chrome. During normal use, the connection stays active automatically \u2014 no repeated approval needed.',
|
||||
extras_security_title: 'Security',
|
||||
extras_security_body: 'Remote debugging only works on localhost (127.0.0.1). Each new session requires explicit user permission via the Chrome dialog. You can disable it at any time by toggling the switch off.',
|
||||
alt_use_extension: 'Having trouble? Use the extension method instead',
|
||||
compare_methods: 'Compare connection methods',
|
||||
});
|
||||
InstallI18n.register('zh', {
|
||||
title: '启用浏览器连接',
|
||||
subtitle: '您的 Chrome 支持直接连接 — 无需安装扩展!',
|
||||
step1_title: '启用远程调试',
|
||||
step1_open: '打开 chrome://inspect/#remote-debugging',
|
||||
step1_more: '无法打开?复制链接',
|
||||
step1_body: '在 Windows 上,Chrome 可能会阻止自动打开此页面。请复制以下地址并粘贴到浏览器地址栏:',
|
||||
step1_detail: '将远程调试开关切换为 <strong>开启</strong>。',
|
||||
step2_title: '允许连接',
|
||||
step2_body: '当 Lime 请求调试会话时,Chrome 会弹出权限对话框,点击 <strong>允许</strong> 即可让 Lime 连接。',
|
||||
step2_more: '详情',
|
||||
step3_title: '设置完成',
|
||||
step3_body: 'Lime 现在可以直接控制您的浏览器标签页,无需安装任何扩展。',
|
||||
step3_more: '安全详情',
|
||||
click_to_copy: '点击复制',
|
||||
more_info: '更多信息',
|
||||
extras_how_title: '工作原理',
|
||||
extras_how_body: 'Chrome M144+ 内置了远程调试接口。Lime 通过 DevTools 协议 (CDP) 直接连接 Chrome — 比使用扩展更快速、更简单。',
|
||||
extras_banner_title: '关于自动化提示栏',
|
||||
extras_banner_body: 'Lime 连接期间,Chrome 会显示 <strong>"Chrome 正在由自动化测试软件控制"</strong>,这是标准的安全提示,确认调试会话处于活动状态。',
|
||||
extras_reconnect_title: '什么时候需要重新连接?',
|
||||
extras_reconnect_body: '每次重启 Lime 或 Chrome 后,需要重新点击"允许"。正常使用期间连接会自动保持,无需反复授权。',
|
||||
extras_security_title: '安全性',
|
||||
extras_security_body: '远程调试仅在本地回环地址 (127.0.0.1) 上工作,每次新会话都需要用户通过 Chrome 对话框明确授权。随时可以关闭开关来禁用。',
|
||||
alt_use_extension: '遇到问题?使用扩展方式连接',
|
||||
compare_methods: '对比两种连接方式',
|
||||
});
|
||||
InstallI18n.register('de', {
|
||||
title: 'Browserverbindung aktivieren',
|
||||
subtitle: 'Ihr Chrome unterstützt die direkte Verbindung — keine Erweiterung erforderlich!',
|
||||
step1_title: 'Remote-Debugging aktivieren',
|
||||
step1_open: 'Öffnen Sie chrome://inspect/#remote-debugging',
|
||||
step1_more: 'Lässt sich nicht öffnen? URL kopieren',
|
||||
step1_body: 'Unter Windows verhindert Chrome möglicherweise das automatische Öffnen dieser Seite. Kopieren Sie die URL und fügen Sie sie in die Adressleiste ein:',
|
||||
step1_detail: 'Stellen Sie den Schalter für Remote-Debugging auf <strong>Ein</strong>.',
|
||||
step2_title: 'Verbindung zulassen',
|
||||
step2_body: 'Wenn Lime eine Debugging-Sitzung anfordert, zeigt Chrome einen Berechtigungsdialog an. Klicken Sie auf <strong>Zulassen</strong>, um Lime die Verbindung zu ermöglichen.',
|
||||
step2_more: 'Details',
|
||||
step3_title: 'Alles bereit',
|
||||
step3_body: 'Lime kann jetzt Ihre Browser-Tabs direkt steuern. Keine Installation einer Erweiterung erforderlich.',
|
||||
step3_more: 'Sicherheitsdetails',
|
||||
click_to_copy: 'Zum Kopieren klicken',
|
||||
more_info: 'Weitere Informationen',
|
||||
extras_how_title: 'Wie es funktioniert',
|
||||
extras_how_body: 'Chrome M144+ enthält eine integrierte Remote-Debugging-Schnittstelle. Lime verbindet sich direkt mit Chrome über das DevTools-Protokoll (CDP) — schneller und einfacher als die Verwendung einer Erweiterung.',
|
||||
extras_banner_title: 'Über das Automatisierungsbanner',
|
||||
extras_banner_body: 'Während Lime verbunden ist, zeigt Chrome <strong>"Chrome wird von automatisierter Testsoftware gesteuert"</strong> an. Dies ist ein Standard-Sicherheitshinweis, der bestätigt, dass die Debugging-Sitzung aktiv ist.',
|
||||
extras_reconnect_title: 'Wann muss ich die Verbindung wiederherstellen?',
|
||||
extras_reconnect_body: 'Sie müssen jedes Mal erneut auf "Zulassen" klicken, wenn Sie Lime oder Chrome neu starten. Während der normalen Nutzung bleibt die Verbindung automatisch aktiv — keine wiederholte Zustimmung erforderlich.',
|
||||
extras_security_title: 'Sicherheit',
|
||||
extras_security_body: 'Remote-Debugging funktioniert nur auf localhost (127.0.0.1). Jede neue Sitzung erfordert eine ausdrückliche Benutzererlaubnis über den Chrome-Dialog. Sie können es jederzeit deaktivieren, indem Sie den Schalter ausschalten.',
|
||||
alt_use_extension: 'Probleme? Verwenden Sie stattdessen die Erweiterungsmethode',
|
||||
compare_methods: 'Verbindungsmethoden vergleichen',
|
||||
});
|
||||
InstallI18n.register('es', {
|
||||
title: 'Activar conexión del navegador',
|
||||
subtitle: '¡Tu Chrome admite conexión directa — no se necesita extensión!',
|
||||
step1_title: 'Activar depuración remota',
|
||||
step1_open: 'Abre chrome://inspect/#remote-debugging',
|
||||
step1_more: '¿No se abre? Copia la URL',
|
||||
step1_body: 'En Windows, Chrome puede bloquear la apertura automática de esta página. Copia la URL y pégala en la barra de direcciones:',
|
||||
step1_detail: 'Cambia el interruptor de depuración remota a <strong>Activado</strong>.',
|
||||
step2_title: 'Permitir conexión',
|
||||
step2_body: 'Cuando Lime solicite una sesión de depuración, Chrome mostrará un cuadro de diálogo de permiso. Haz clic en <strong>Permitir</strong> para que Lime se conecte.',
|
||||
step2_more: 'Detalles',
|
||||
step3_title: 'Todo listo',
|
||||
step3_body: 'Lime ahora puede controlar directamente las pestañas de tu navegador. No se requiere instalación de extensiones.',
|
||||
step3_more: 'Detalles de seguridad',
|
||||
click_to_copy: 'Haz clic para copiar',
|
||||
more_info: 'Más información',
|
||||
extras_how_title: 'Cómo funciona',
|
||||
extras_how_body: 'Chrome M144+ incluye una interfaz de depuración remota integrada. Lime se conecta directamente a Chrome a través del Protocolo DevTools (CDP), más rápido y sencillo que usar una extensión.',
|
||||
extras_banner_title: 'Sobre el banner de automatización',
|
||||
extras_banner_body: 'Mientras Lime está conectado, Chrome muestra <strong>"Chrome está siendo controlado por un software de prueba automatizado"</strong>. Este es un aviso de seguridad estándar que confirma que la sesión de depuración está activa.',
|
||||
extras_reconnect_title: '¿Cuándo necesito volver a conectar?',
|
||||
extras_reconnect_body: 'Debes hacer clic en "Permitir" nuevamente cada vez que reinicies Lime o Chrome. Durante el uso normal, la conexión permanece activa automáticamente; no se necesita aprobación repetida.',
|
||||
extras_security_title: 'Seguridad',
|
||||
extras_security_body: 'La depuración remota solo funciona en localhost (127.0.0.1). Cada nueva sesión requiere el permiso explícito del usuario a través del cuadro de diálogo de Chrome. Puedes desactivarla en cualquier momento apagando el interruptor.',
|
||||
alt_use_extension: '¿Tienes problemas? Usa el método de extensión en su lugar',
|
||||
compare_methods: 'Comparar métodos de conexión',
|
||||
});
|
||||
InstallI18n.register('fr', {
|
||||
title: 'Activer la connexion au navigateur',
|
||||
subtitle: 'Votre Chrome prend en charge la connexion directe — aucune extension n\'est nécessaire !',
|
||||
step1_title: 'Activer le débogage à distance',
|
||||
step1_open: 'Ouvrez chrome://inspect/#remote-debugging',
|
||||
step1_more: 'Impossible d\'ouvrir ? Copiez l\'URL',
|
||||
step1_body: 'Sous Windows, Chrome peut empêcher l\'ouverture automatique de cette page. Copiez l\'URL et collez-la dans la barre d\'adresse :',
|
||||
step1_detail: 'Basculez l\'interrupteur de débogage à distance sur <strong>Activé</strong>.',
|
||||
step2_title: 'Autoriser la connexion',
|
||||
step2_body: 'Lorsque Lime demande une session de débogage, Chrome affiche une boîte de dialogue d\'autorisation. Cliquez sur <strong>Autoriser</strong> pour permettre à Lime de se connecter.',
|
||||
step2_more: 'Détails',
|
||||
step3_title: 'Tout est prêt',
|
||||
step3_body: 'Lime peut désormais contrôler directement les onglets de votre navigateur. Aucune installation d\'extension n\'est requise.',
|
||||
step3_more: 'Détails de sécurité',
|
||||
click_to_copy: 'Cliquez pour copier',
|
||||
more_info: 'Plus d\'informations',
|
||||
extras_how_title: 'Comment ça marche',
|
||||
extras_how_body: 'Chrome M144+ inclut une interface de débogage à distance intégrée. Lime se connecte directement à Chrome via le protocole DevTools (CDP) — plus rapide et plus simple que l\'utilisation d\'une extension.',
|
||||
extras_banner_title: 'À propos de la bannière d\'automatisation',
|
||||
extras_banner_body: 'Pendant qu\'Lime est connecté, Chrome affiche <strong>"Chrome est contrôlé par un logiciel de test automatisé"</strong>. Il s\'agit d\'un avis de sécurité standard confirmant que la session de débogage est active.',
|
||||
extras_reconnect_title: 'Quand dois-je me reconnecter ?',
|
||||
extras_reconnect_body: 'Vous devez cliquer à nouveau sur "Autoriser" chaque fois que vous redémarrez Lime ou Chrome. En utilisation normale, la connexion reste active automatiquement — aucune approbation répétée n\'est nécessaire.',
|
||||
extras_security_title: 'Sécurité',
|
||||
extras_security_body: 'Le débogage à distance ne fonctionne que sur localhost (127.0.0.1). Chaque nouvelle session nécessite l\'autorisation explicite de l\'utilisateur via la boîte de dialogue Chrome. Vous pouvez le désactiver à tout moment en éteignant l\'interrupteur.',
|
||||
alt_use_extension: 'Vous rencontrez des problèmes ? Utilisez plutôt la méthode par extension',
|
||||
compare_methods: 'Comparer les méthodes de connexion',
|
||||
});
|
||||
InstallI18n.register('pt', {
|
||||
title: 'Ativar conexão do navegador',
|
||||
subtitle: 'O seu Chrome suporta ligação direta — não é necessária nenhuma extensão!',
|
||||
step1_title: 'Ativar depuração remota',
|
||||
step1_open: 'Abra chrome://inspect/#remote-debugging',
|
||||
step1_more: 'Não abre? Copie a URL',
|
||||
step1_body: 'No Windows, o Chrome pode bloquear a abertura automática desta página. Copie a URL e cole-a na barra de endereços:',
|
||||
step1_detail: 'Ative o interruptor de depuração remota para <strong>Ligado</strong>.',
|
||||
step2_title: 'Permitir ligação',
|
||||
step2_body: 'Quando o Lime solicita uma sessão de depuração, o Chrome mostra um diálogo de permissão. Clique em <strong>Permitir</strong> para deixar o Lime ligar-se.',
|
||||
step2_more: 'Detalhes',
|
||||
step3_title: 'Tudo pronto',
|
||||
step3_body: 'O Lime pode agora controlar diretamente os separadores do seu navegador. No é necessária a instalação de extensões.',
|
||||
step3_more: 'Detalhes de segurança',
|
||||
click_to_copy: 'Clique para copiar',
|
||||
more_info: 'Mais informações',
|
||||
extras_how_title: 'Como funciona',
|
||||
extras_how_body: 'O Chrome M144+ inclui uma interface de depuração remota integrada. O Lime liga-se diretamente ao Chrome através do Protocolo DevTools (CDP) — mais rápido e simples do que usar uma extensão.',
|
||||
extras_banner_title: 'Sobre o banner de automação',
|
||||
extras_banner_body: 'Enquanto o Lime está ligado, o Chrome exibe <strong>"O Chrome está a ser controlado por software de teste automatizado"</strong>. Este é um aviso de segurança padrão que confirma que a sessão de depuração está ativa.',
|
||||
extras_reconnect_title: 'Quando preciso de me voltar a ligar?',
|
||||
extras_reconnect_body: 'Precisa de clicar em "Permitir" novamente cada vez que reiniciar o Lime ou o Chrome. Durante a utilização normal, a ligação permanece ativa automaticamente — não é necessária aprovação repetida.',
|
||||
extras_security_title: 'Segurança',
|
||||
extras_security_body: 'A depuração remota só funciona em localhost (127.0.0.1). Cada nova sessão requer permissão explícita do utilizador através do diálogo do Chrome. Pode desativá-la a qualquer momento, desligando o interruptor.',
|
||||
alt_use_extension: 'Problemas? Utilize o método de extensão em vez disso',
|
||||
compare_methods: 'Comparar métodos de ligação',
|
||||
});
|
||||
|
||||
var lang = InstallI18n.apply();
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var browserName = params.get('browser');
|
||||
var browserVersion = params.get('version');
|
||||
if (browserName && browserVersion) {
|
||||
var infoEl = document.getElementById('browser-info');
|
||||
var textEl = document.getElementById('browser-info-text');
|
||||
if (infoEl && textEl) {
|
||||
infoEl.style.display = 'block';
|
||||
textEl.textContent = lang === 'zh'
|
||||
? (browserName + ' M' + browserVersion + ' — 支持直连 CDP')
|
||||
: (browserName + ' M' + browserVersion + ' — Direct CDP supported');
|
||||
}
|
||||
}
|
||||
|
||||
setupCopyBlock('copy-url-block', 'chrome://inspect/#remote-debugging');
|
||||
setupAllStepExtras();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,496 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Install Lime Browser Bridge</title>
|
||||
<link rel="stylesheet" href="styles/install-common.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="drag-handle"></div>
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-icon">
|
||||
<img src="../icons/icon128.png" alt="Lime Browser Bridge" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>
|
||||
<span data-i18n="title">Install Lime Browser Bridge</span>
|
||||
<span class="title-tag" data-i18n="title_tag">Extension</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtitle" data-i18n="subtitle">
|
||||
Install the extension to connect your browser to Lime.
|
||||
</p>
|
||||
|
||||
<!-- Chrome Web Store install (hidden until published) -->
|
||||
<div class="webstore-section" style="display:none">
|
||||
<div class="webstore-card">
|
||||
<div class="webstore-badge">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M12 2v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M20.66 7l-5.2 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M20.66 17l-5.2-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-i18n="webstore_label">Chrome Web Store</span>
|
||||
<span class="webstore-tag" data-i18n="webstore_tag">Recommended</span>
|
||||
</div>
|
||||
<p class="webstore-desc" data-i18n="webstore_desc">Install directly from the Chrome Web Store — the simplest way.</p>
|
||||
<a class="open-btn webstore-btn" href="https://chromewebstore.google.com/detail/lime-chrome/cpidmllglbedhpombjibeoalnafofipo" target="_blank" rel="noopener">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8.5V12a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 012 12V5a1.5 1.5 0 011.5-1.5H7"/>
|
||||
<path d="M10 2h4v4"/>
|
||||
<path d="M7 9L14 2"/>
|
||||
</svg>
|
||||
<span data-i18n="webstore_install">Install from Chrome Web Store</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual install steps (divider hidden while webstore is hidden) -->
|
||||
<div class="manual-section" style="display:none">
|
||||
<div class="manual-divider">
|
||||
<span class="manual-divider-line"></span>
|
||||
<span class="manual-divider-text" data-i18n="manual_divider">Or install manually from local files</span>
|
||||
<span class="manual-divider-line"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="step-list">
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step1_title">Open Chrome Extensions & Enable Developer Mode</div>
|
||||
<div class="step-body">
|
||||
<button class="open-btn" onclick="openChromeUrl('chrome://extensions')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8.5V12a1.5 1.5 0 01-1.5 1.5h-7A1.5 1.5 0 012 12V5a1.5 1.5 0 011.5-1.5H7"/>
|
||||
<path d="M10 2h4v4"/>
|
||||
<path d="M7 9L14 2"/>
|
||||
</svg>
|
||||
<span data-i18n="step1_open">Open chrome://extensions</span>
|
||||
</button>
|
||||
<div class="step-note">
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0">
|
||||
<path d="M8 5v3"/>
|
||||
<circle cx="8" cy="11" r="0.5" fill="currentColor"/>
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
</svg>
|
||||
<span data-i18n="step1_devmode">Then turn on the <strong>Developer mode</strong> toggle in the top-right corner of the page.</span>
|
||||
</div>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step1-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step1_more">Manually copy the URL to open extensions</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step1-extras">
|
||||
<span data-i18n="step1_body">Open a new Chrome tab and copy the address:</span>
|
||||
<div class="url-block" id="copy-url-block">
|
||||
chrome://extensions
|
||||
<svg class="copy-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/>
|
||||
<path d="M3.5 10.5h-1a1 1 0 01-1-1v-7a1 1 0 011-1h7a1 1 0 011 1v1"/>
|
||||
</svg>
|
||||
<span class="copy-label" style="font-size:11px;color:var(--muted)" data-i18n="click_to_copy">Click to copy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step2_title">Open the Extension Folder</div>
|
||||
<div class="step-body">
|
||||
<span data-i18n="step2_body">Open the folder that contains the browser extension files:</span>
|
||||
<code class="path-display" id="extension-path">chrome-extension/lime-chrome</code>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="open-folder-btn" class="open-btn" style="display:none" onclick="openExtensionFolder()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/>
|
||||
</svg>
|
||||
<span data-i18n="open_folder">Open Folder</span>
|
||||
</button>
|
||||
<button id="copy-path-btn" class="action-btn" style="display:none" onclick="copyExtensionPath()">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="9" height="9" rx="1.5"/>
|
||||
<path d="M4 11.5H3.5A1.5 1.5 0 012 10V3.5A1.5 1.5 0 013.5 2H10A1.5 1.5 0 0111.5 4"/>
|
||||
</svg>
|
||||
<span data-i18n="copy_path">Copy Path</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="step">
|
||||
<div class="step-title" data-i18n="step3_title">Drag the Extension Folder into Chrome</div>
|
||||
<div class="step-body">
|
||||
<span data-i18n="step3_body">
|
||||
Drag the <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> folder directly onto the <code>chrome://extensions</code> page. Chrome will install it immediately.
|
||||
</span>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step3-alt-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step3_alt"><strong style="font-weight:700">Can't drag?</strong> Try loading unpacked manually</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step3-alt-extras">
|
||||
<div class="note" style="margin-top:8px">
|
||||
<div class="note-title" data-i18n="extras_devmode_title">Load Unpacked</div>
|
||||
<div class="note-body" data-i18n="extras_devmode_body">
|
||||
Click <strong>"Load unpacked"</strong> on the <code>chrome://extensions</code> page and select the extension folder.
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
<div class="note-title" data-i18n="extras_finder_title">Navigating to a path in Finder (macOS)</div>
|
||||
<div class="note-body" data-i18n="extras_finder_body">
|
||||
In the file picker dialog, press <strong>⌘ + Shift + G</strong> to open "Go to the folder", then paste the extension folder path and press Enter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-extras">
|
||||
<button class="step-extras-toggle" data-target="step3-extras" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="step3_more">After installation</span>
|
||||
</button>
|
||||
<div class="step-extras-content" id="step3-extras">
|
||||
<div class="note" style="margin-top:8px">
|
||||
<div class="note-title" data-i18n="extras_pin_title">Pin the Extension</div>
|
||||
<div class="note-body" data-i18n="extras_pin_body">
|
||||
Click the puzzle piece icon in Chrome's toolbar and pin <strong>Lime Browser Bridge</strong> for quick access.
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
<div class="note-title" data-i18n="extras_update_title">Updating the Extension</div>
|
||||
<div class="note-body" data-i18n="extras_update_body">
|
||||
When Lime is updated, go to <code>chrome://extensions</code>, remove the old version, then repeat the steps above. Or click the <strong>reload icon</strong> (↻) on the extension card to quick-reload.
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">
|
||||
<div class="note-title" data-i18n="extras_debug_title">About the debugging banner</div>
|
||||
<div class="note-body" data-i18n="extras_debug_body">
|
||||
When the extension attaches to a tab, Chrome shows a yellow banner: <strong>"Lime Browser Bridge started debugging this browser"</strong>. This is a standard Chrome security notice. <strong>Do not click "Cancel"</strong> unless you want to disconnect that tab.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ol>
|
||||
|
||||
<div class="alt-method" id="alt-direct-cdp" style="display:none">
|
||||
<div class="alt-method-divider"></div>
|
||||
<button class="alt-method-btn" onclick="navigateToInstallPage('install-direct-cdp.html')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 8h8"/>
|
||||
<path d="M11 5l3 3-3 3"/>
|
||||
</svg>
|
||||
<span data-i18n="alt_use_direct">Your Chrome supports direct connection — try the faster method</span>
|
||||
<svg width="12" height="12" viewBox="0 0 8 12" fill="currentColor" style="opacity:0.5"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom extras for general maintenance info -->
|
||||
<div class="extras">
|
||||
<button class="extras-toggle" id="extras-toggle" aria-expanded="false">
|
||||
<svg viewBox="0 0 8 12" fill="currentColor"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
<span data-i18n="more_info">More information</span>
|
||||
</button>
|
||||
<div class="extras-content" id="extras-content">
|
||||
|
||||
<div class="note">
|
||||
<div class="note-title" data-i18n="extras_trouble_title">Troubleshooting</div>
|
||||
<div class="note-body">
|
||||
<span data-i18n="extras_trouble_badge"><strong>Badge shows <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime is not running or browser control is not enabled.</span>
|
||||
<br><br>
|
||||
<span data-i18n="extras_trouble_page"><strong>Can't control a page</strong> — Internal pages (<code>chrome://</code>) cannot be debugged. Use a regular webpage.</span>
|
||||
<br><br>
|
||||
<span data-i18n="extras_trouble_restart"><strong>Extension disappeared</strong> — Unpacked extensions may get disabled after Chrome restart. Re-enable at <code>chrome://extensions</code>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alt-method">
|
||||
<div class="alt-method-divider"></div>
|
||||
<button class="alt-method-btn" onclick="navigateToInstallPage('compare-methods.html')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1.5" y="2.5" width="5" height="11" rx="1"/>
|
||||
<rect x="9.5" y="2.5" width="5" height="11" rx="1"/>
|
||||
</svg>
|
||||
<span data-i18n="compare_methods">Compare connection methods</span>
|
||||
<svg width="12" height="12" viewBox="0 0 8 12" fill="currentColor" style="opacity:0.5"><path d="M1.5 0L7.5 6L1.5 12L0 10.5L4.5 6L0 1.5Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="scripts/install-i18n.js"></script>
|
||||
<script src="scripts/install-helpers.js"></script>
|
||||
<script>
|
||||
InstallI18n.register('en', {
|
||||
title: 'Install Lime Browser Bridge',
|
||||
title_tag: 'Extension',
|
||||
subtitle: 'Install the extension to connect your browser to Lime.',
|
||||
webstore_label: 'Chrome Web Store',
|
||||
webstore_tag: 'Recommended',
|
||||
webstore_desc: 'Install directly from the Chrome Web Store — the simplest way.',
|
||||
webstore_install: 'Install from Chrome Web Store',
|
||||
manual_divider: 'Or install manually from local files',
|
||||
step1_title: 'Open Chrome Extensions & Enable Developer Mode',
|
||||
step1_open: 'Open chrome://extensions',
|
||||
step1_devmode: 'Then turn on the <strong>Developer mode</strong> toggle in the top-right corner of the page.',
|
||||
step1_more: 'Manually copy the URL to open extensions',
|
||||
step1_body: 'Open a new Chrome tab and copy the address:',
|
||||
step2_title: 'Open the Extension Folder',
|
||||
step2_body: 'Open the folder that contains the browser extension files:',
|
||||
step3_title: 'Drag the Extension Folder into Chrome',
|
||||
step3_body: 'Drag the <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> folder directly onto the <code>chrome://extensions</code> page. Chrome will install it immediately.',
|
||||
step3_alt: '<strong style="font-weight:700">Can\'t drag?</strong> Try loading unpacked manually',
|
||||
step3_more: 'After installation',
|
||||
click_to_copy: 'Click to copy',
|
||||
open_folder: 'Open Folder',
|
||||
copy_path: 'Copy Path',
|
||||
more_info: 'More information',
|
||||
extras_devmode_title: 'Load Unpacked',
|
||||
extras_devmode_body: 'Click <strong>"Load unpacked"</strong> on the <code>chrome://extensions</code> page and select the extension folder.',
|
||||
extras_finder_title: 'Navigating to a path in Finder (macOS)',
|
||||
extras_finder_body: 'In the file picker dialog, press <strong>⌘ + Shift + G</strong> to open "Go to the folder", then paste the extension folder path and press Enter.',
|
||||
extras_pin_title: 'Pin the Extension',
|
||||
extras_pin_body: 'Click the puzzle piece icon in Chrome\'s toolbar and pin <strong>Lime Browser Bridge</strong> for quick access.',
|
||||
extras_debug_title: 'About the debugging banner',
|
||||
extras_debug_body: 'When the extension attaches to a tab, Chrome shows a yellow banner: <strong>"Lime Browser Bridge started debugging this browser"</strong>. This is a standard Chrome security notice. <strong>Do not click "Cancel"</strong> unless you want to disconnect that tab.',
|
||||
extras_update_title: 'Updating the Extension',
|
||||
extras_update_body: 'When Lime is updated, go to <code>chrome://extensions</code>, remove the old version, then repeat the steps above. Or click the <strong>reload icon</strong> (↻) on the extension card to quick-reload.',
|
||||
extras_trouble_title: 'Troubleshooting',
|
||||
extras_trouble_badge: '<strong>Badge shows <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime is not running or browser control is not enabled.',
|
||||
extras_trouble_page: '<strong>Can\'t control a page</strong> — Internal pages (<code>chrome://</code>) cannot be debugged. Use a regular webpage.',
|
||||
extras_trouble_restart: '<strong>Extension disappeared</strong> — Unpacked extensions may get disabled after Chrome restart. Re-enable at <code>chrome://extensions</code>.',
|
||||
alt_use_direct: 'Your Chrome supports direct connection — try the faster method',
|
||||
compare_methods: 'Compare connection methods',
|
||||
});
|
||||
InstallI18n.register('zh', {
|
||||
title: '安装 Lime Browser Bridge',
|
||||
title_tag: '扩展程序',
|
||||
subtitle: '安装扩展以将浏览器连接到 Lime。',
|
||||
webstore_label: 'Chrome 应用商店',
|
||||
webstore_tag: '推荐',
|
||||
webstore_desc: '从 Chrome 应用商店一键安装,最简单的方式。',
|
||||
webstore_install: '前往 Chrome 应用商店安装',
|
||||
manual_divider: '或从本地文件手动安装',
|
||||
step1_title: '打开 Chrome 扩展管理页面并启用开发者模式',
|
||||
step1_open: '打开 chrome://extensions',
|
||||
step1_devmode: '然后打开页面右上角的 <strong>开发者模式</strong> 开关。',
|
||||
step1_more: '手动复制地址并打开扩展管理页面',
|
||||
step1_body: '在 Chrome 中打开新标签页,复制以下地址:',
|
||||
step2_title: '打开扩展文件夹',
|
||||
step2_body: '打开包含浏览器扩展文件的文件夹:',
|
||||
step3_title: '将扩展文件夹拖入 Chrome',
|
||||
step3_body: '将 <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> 文件夹直接拖到 <code>chrome://extensions</code> 页面上,Chrome 会立即安装。',
|
||||
step3_alt: '<strong style="font-weight:700">不方便拖拽?</strong>尝试手动加载已解压的扩展程序',
|
||||
step3_more: '安装后须知',
|
||||
click_to_copy: '点击复制',
|
||||
open_folder: '打开文件夹',
|
||||
copy_path: '复制路径',
|
||||
more_info: '更多信息',
|
||||
extras_devmode_title: '加载已解压的扩展程序',
|
||||
extras_devmode_body: '在 <code>chrome://extensions</code> 页面点击 <strong>"加载已解压的扩展程序"</strong>,选择扩展文件夹。',
|
||||
extras_finder_title: '在访达中定位路径(macOS)',
|
||||
extras_finder_body: '在文件选择对话框中,按 <strong>⌘ + Shift + G</strong> 打开"前往文件夹",粘贴扩展文件夹路径后按回车。',
|
||||
extras_pin_title: '固定扩展图标',
|
||||
extras_pin_body: '点击 Chrome 工具栏中的拼图图标,将 <strong>Lime Browser Bridge</strong> 固定,方便快速访问。',
|
||||
extras_debug_title: '关于调试提示栏',
|
||||
extras_debug_body: '扩展附加到标签页时,Chrome 会显示黄色提示:<strong>"Lime Browser Bridge 已开始调试此浏览器"</strong>,这是正常的安全提示。<strong>请勿点击"取消"</strong>,否则该标签页将断开连接。',
|
||||
extras_update_title: '更新扩展',
|
||||
extras_update_body: 'Lime 更新后,前往 <code>chrome://extensions</code> 移除旧版本,然后重复以上步骤安装新版本。也可以点击扩展卡片上的 <strong>重新加载</strong> 图标 (↻) 快速刷新。',
|
||||
extras_trouble_title: '故障排查',
|
||||
extras_trouble_badge: '<strong>图标显示 <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime 未运行或浏览器控制未开启。',
|
||||
extras_trouble_page: '<strong>无法控制页面</strong> — 内部页面(<code>chrome://</code>)无法调试,请打开普通网页。',
|
||||
extras_trouble_restart: '<strong>扩展消失</strong> — 解压安装的扩展可能在 Chrome 重启后被禁用,请到 <code>chrome://extensions</code> 重新启用。',
|
||||
alt_use_direct: '您的 Chrome 支持直接连接 — 试试更快的方式',
|
||||
compare_methods: '对比两种连接方式',
|
||||
});
|
||||
InstallI18n.register('de', {
|
||||
title: 'Lime Browser Bridge installieren',
|
||||
title_tag: 'Erweiterung',
|
||||
subtitle: 'Installieren Sie die Erweiterung, um Ihren Browser mit Lime zu verbinden.',
|
||||
webstore_label: 'Chrome Web Store',
|
||||
webstore_tag: 'Empfohlen',
|
||||
webstore_desc: 'Direkt aus dem Chrome Web Store installieren — der einfachste Weg.',
|
||||
webstore_install: 'Aus dem Chrome Web Store installieren',
|
||||
manual_divider: 'Oder manuell aus lokalen Dateien installieren',
|
||||
step1_title: 'Chrome-Erweiterungen öffnen & Entwicklermodus aktivieren',
|
||||
step1_open: 'chrome://extensions öffnen',
|
||||
step1_devmode: 'Aktivieren Sie dann den <strong>Entwicklermodus</strong>-Schalter oben rechts.',
|
||||
step1_more: 'URL manuell kopieren, um Erweiterungen zu öffnen',
|
||||
step1_body: 'Öffnen Sie einen neuen Chrome-Tab und kopieren Sie die Adresse:',
|
||||
step2_title: 'Erweiterungsordner öffnen',
|
||||
step2_body: 'Öffnen Sie den Ordner mit den Browser-Erweiterungsdateien:',
|
||||
step3_title: 'Erweiterungsordner in Chrome ziehen',
|
||||
step3_body: 'Ziehen Sie den <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span>-Ordner direkt auf die <code>chrome://extensions</code>-Seite. Chrome installiert ihn sofort.',
|
||||
step3_alt: '<strong style="font-weight:700">Ziehen nicht möglich?</strong> Versuchen Sie, entpackt zu laden',
|
||||
step3_more: 'Nach der Installation',
|
||||
click_to_copy: 'Zum Kopieren klicken',
|
||||
open_folder: 'Ordner öffnen',
|
||||
copy_path: 'Pfad kopieren',
|
||||
more_info: 'Weitere Informationen',
|
||||
extras_devmode_title: 'Entpackte Erweiterung laden',
|
||||
extras_devmode_body: 'Klicken Sie auf <strong>„Entpackte Erweiterung laden"</strong> auf der <code>chrome://extensions</code>-Seite und wählen Sie den Erweiterungsordner.',
|
||||
extras_finder_title: 'Zu einem Pfad im Finder navigieren (macOS)',
|
||||
extras_finder_body: 'Drücken Sie im Dateiauswahldialog <strong>⌘ + Shift + G</strong>, um „Gehe zum Ordner" zu öffnen, fügen Sie den Pfad ein und drücken Sie Enter.',
|
||||
extras_pin_title: 'Erweiterung anheften',
|
||||
extras_pin_body: 'Klicken Sie auf das Puzzle-Symbol in der Chrome-Symbolleiste und heften Sie <strong>Lime Browser Bridge</strong> für schnellen Zugriff an.',
|
||||
extras_debug_title: 'Über das Debugging-Banner',
|
||||
extras_debug_body: 'Wenn die Erweiterung an einen Tab angehängt wird, zeigt Chrome ein gelbes Banner: <strong>„Lime Browser Bridge hat das Debugging dieses Browsers gestartet"</strong>. Dies ist ein normaler Sicherheitshinweis. <strong>Klicken Sie nicht auf „Abbrechen"</strong>, es sei denn, Sie möchten den Tab trennen.',
|
||||
extras_update_title: 'Erweiterung aktualisieren',
|
||||
extras_update_body: 'Nach einem Lime-Update gehen Sie zu <code>chrome://extensions</code>, entfernen Sie die alte Version und wiederholen Sie die obigen Schritte. Oder klicken Sie auf das <strong>Neuladen-Symbol</strong> (↻) auf der Erweiterungskarte.',
|
||||
extras_trouble_title: 'Fehlerbehebung',
|
||||
extras_trouble_badge: '<strong>Symbol zeigt <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime läuft nicht oder die Browsersteuerung ist nicht aktiviert.',
|
||||
extras_trouble_page: '<strong>Seite kann nicht gesteuert werden</strong> — Interne Seiten (<code>chrome://</code>) können nicht debuggt werden. Verwenden Sie eine normale Webseite.',
|
||||
extras_trouble_restart: '<strong>Erweiterung verschwunden</strong> — Entpackte Erweiterungen können nach einem Chrome-Neustart deaktiviert werden. Aktivieren Sie sie unter <code>chrome://extensions</code> erneut.',
|
||||
alt_use_direct: 'Ihr Chrome unterstützt Direktverbindung — probieren Sie die schnellere Methode',
|
||||
compare_methods: 'Verbindungsmethoden vergleichen',
|
||||
});
|
||||
InstallI18n.register('es', {
|
||||
title: 'Instalar Lime Browser Bridge',
|
||||
title_tag: 'Extensión',
|
||||
subtitle: 'Instala la extensión para conectar tu navegador a Lime.',
|
||||
webstore_label: 'Chrome Web Store',
|
||||
webstore_tag: 'Recomendado',
|
||||
webstore_desc: 'Instalar directamente desde la Chrome Web Store — la forma más sencilla.',
|
||||
webstore_install: 'Instalar desde Chrome Web Store',
|
||||
manual_divider: 'O instalar manualmente desde archivos locales',
|
||||
step1_title: 'Abrir extensiones de Chrome y activar modo desarrollador',
|
||||
step1_open: 'Abrir chrome://extensions',
|
||||
step1_devmode: 'Luego activa el interruptor de <strong>Modo desarrollador</strong> en la esquina superior derecha.',
|
||||
step1_more: 'Copiar la URL manualmente para abrir extensiones',
|
||||
step1_body: 'Abre una nueva pestaña de Chrome y copia la dirección:',
|
||||
step2_title: 'Abrir la carpeta de la extensión',
|
||||
step2_body: 'Abre la carpeta que contiene los archivos de la extensión:',
|
||||
step3_title: 'Arrastra la carpeta de la extensión a Chrome',
|
||||
step3_body: 'Arrastra la carpeta <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> directamente a la página <code>chrome://extensions</code>. Chrome la instalará inmediatamente.',
|
||||
step3_alt: '<strong style="font-weight:700">¿No puedes arrastrar?</strong> Intenta cargar desempaquetada manualmente',
|
||||
step3_more: 'Después de la instalación',
|
||||
click_to_copy: 'Clic para copiar',
|
||||
open_folder: 'Abrir carpeta',
|
||||
copy_path: 'Copiar ruta',
|
||||
more_info: 'Más información',
|
||||
extras_devmode_title: 'Cargar desempaquetada',
|
||||
extras_devmode_body: 'Haz clic en <strong>\"Cargar desempaquetada\"</strong> en la página <code>chrome://extensions</code> y selecciona la carpeta de la extensión.',
|
||||
extras_finder_title: 'Navegar a una ruta en Finder (macOS)',
|
||||
extras_finder_body: 'En el diálogo de selección de archivos, presiona <strong>⌘ + Shift + G</strong> para abrir \"Ir a la carpeta\", pega la ruta y presiona Enter.',
|
||||
extras_pin_title: 'Fijar la extensión',
|
||||
extras_pin_body: 'Haz clic en el icono del puzzle en la barra de herramientas de Chrome y fija <strong>Lime Browser Bridge</strong> para acceso rápido.',
|
||||
extras_debug_title: 'Sobre el banner de depuración',
|
||||
extras_debug_body: 'Cuando la extensión se conecta a una pestaña, Chrome muestra un banner amarillo: <strong>\"Lime Browser Bridge ha comenzado a depurar este navegador\"</strong>. Es un aviso de seguridad estándar. <strong>No hagas clic en \"Cancelar\"</strong> a menos que quieras desconectar esa pestaña.',
|
||||
extras_update_title: 'Actualizar la extensión',
|
||||
extras_update_body: 'Cuando Lime se actualice, ve a <code>chrome://extensions</code>, elimina la versión antigua y repite los pasos anteriores. O haz clic en el <strong>icono de recarga</strong> (↻) en la tarjeta de la extensión.',
|
||||
extras_trouble_title: 'Solución de problemas',
|
||||
extras_trouble_badge: '<strong>El icono muestra <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime no está ejecutándose o el control del navegador no está activado.',
|
||||
extras_trouble_page: '<strong>No se puede controlar una página</strong> — Las páginas internas (<code>chrome://</code>) no se pueden depurar. Usa una página web normal.',
|
||||
extras_trouble_restart: '<strong>La extensión desapareció</strong> — Las extensiones desempaquetadas pueden desactivarse tras reiniciar Chrome. Reactívalas en <code>chrome://extensions</code>.',
|
||||
alt_use_direct: 'Tu Chrome soporta conexión directa — prueba el método más rápido',
|
||||
compare_methods: 'Comparar métodos de conexión',
|
||||
});
|
||||
InstallI18n.register('fr', {
|
||||
title: 'Installer Lime Browser Bridge',
|
||||
title_tag: 'Extension',
|
||||
subtitle: "Installez l'extension pour connecter votre navigateur à Lime.",
|
||||
webstore_label: 'Chrome Web Store',
|
||||
webstore_tag: 'Recommandé',
|
||||
webstore_desc: 'Installer directement depuis le Chrome Web Store — la méthode la plus simple.',
|
||||
webstore_install: 'Installer depuis le Chrome Web Store',
|
||||
manual_divider: 'Ou installer manuellement depuis des fichiers locaux',
|
||||
step1_title: 'Ouvrir les extensions Chrome et activer le mode développeur',
|
||||
step1_open: 'Ouvrir chrome://extensions',
|
||||
step1_devmode: 'Puis activez le <strong>Mode développeur</strong> en haut à droite de la page.',
|
||||
step1_more: "Copier l'URL manuellement pour ouvrir les extensions",
|
||||
step1_body: "Ouvrez un nouvel onglet Chrome et copiez l'adresse :",
|
||||
step2_title: "Ouvrir le dossier de l'extension",
|
||||
step2_body: "Ouvrez le dossier contenant les fichiers de l'extension :",
|
||||
step3_title: "Glisser le dossier de l'extension dans Chrome",
|
||||
step3_body: 'Glissez le dossier <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> directement sur la page <code>chrome://extensions</code>. Chrome l\'installera immédiatement.',
|
||||
step3_alt: '<strong style="font-weight:700">Impossible de glisser ?</strong> Essayez de charger manuellement',
|
||||
step3_more: "Après l'installation",
|
||||
click_to_copy: 'Cliquer pour copier',
|
||||
open_folder: 'Ouvrir le dossier',
|
||||
copy_path: 'Copier le chemin',
|
||||
more_info: "Plus d'informations",
|
||||
extras_devmode_title: 'Charger une extension non empaquetée',
|
||||
extras_devmode_body: 'Cliquez sur <strong>\"Charger l\'extension non empaquetée\"</strong> sur la page <code>chrome://extensions</code> et sélectionnez le dossier.',
|
||||
extras_finder_title: 'Naviguer vers un chemin dans le Finder (macOS)',
|
||||
extras_finder_body: 'Dans la boîte de dialogue, appuyez sur <strong>⌘ + Shift + G</strong> pour ouvrir \"Aller au dossier\", collez le chemin et appuyez sur Entrée.',
|
||||
extras_pin_title: "Épingler l'extension",
|
||||
extras_pin_body: "Cliquez sur l'icône puzzle dans la barre d'outils Chrome et épinglez <strong>Lime Browser Bridge</strong> pour un accès rapide.",
|
||||
extras_debug_title: 'À propos de la bannière de débogage',
|
||||
extras_debug_body: "Lorsque l'extension s'attache à un onglet, Chrome affiche une bannière jaune : <strong>\"Lime Browser Bridge a commencé à déboguer ce navigateur\"</strong>. C'est un avis de sécurité standard. <strong>Ne cliquez pas sur \"Annuler\"</strong> sauf si vous souhaitez déconnecter l'onglet.",
|
||||
extras_update_title: "Mettre à jour l'extension",
|
||||
extras_update_body: 'Après une mise à jour d\'Lime, allez sur <code>chrome://extensions</code>, supprimez l\'ancienne version et répétez les étapes. Ou cliquez sur l\'<strong>icône de rechargement</strong> (↻) sur la carte de l\'extension.',
|
||||
extras_trouble_title: 'Dépannage',
|
||||
extras_trouble_badge: '<strong>L\'icône affiche <span class="badge-demo" style="background:#F59E0B">…</span></strong> — Lime ne fonctionne pas ou le contrôle du navigateur n\'est pas activé.',
|
||||
extras_trouble_page: '<strong>Impossible de contrôler une page</strong> — Les pages internes (<code>chrome://</code>) ne peuvent pas être déboguées. Utilisez une page web normale.',
|
||||
extras_trouble_restart: '<strong>Extension disparue</strong> — Les extensions non empaquetées peuvent être désactivées après le redémarrage de Chrome. Réactivez-les sur <code>chrome://extensions</code>.',
|
||||
alt_use_direct: 'Votre Chrome supporte la connexion directe — essayez la méthode plus rapide',
|
||||
compare_methods: 'Comparer les méthodes de connexion',
|
||||
});
|
||||
InstallI18n.register('pt', {
|
||||
title: 'Instalar Lime Browser Bridge',
|
||||
title_tag: 'Extensão',
|
||||
subtitle: 'Instale a extensão para conectar seu navegador ao Lime.',
|
||||
webstore_label: 'Chrome Web Store',
|
||||
webstore_tag: 'Recomendado',
|
||||
webstore_desc: 'Instale diretamente da Chrome Web Store — a maneira mais simples.',
|
||||
webstore_install: 'Instalar da Chrome Web Store',
|
||||
manual_divider: 'Ou instalar manualmente a partir de arquivos locais',
|
||||
step1_title: 'Abrir extensões do Chrome e ativar modo desenvolvedor',
|
||||
step1_open: 'Abrir chrome://extensions',
|
||||
step1_devmode: 'Em seguida, ative o <strong>Modo desenvolvedor</strong> no canto superior direito.',
|
||||
step1_more: 'Copiar a URL manualmente para abrir extensões',
|
||||
step1_body: 'Abra uma nova aba do Chrome e copie o endereço:',
|
||||
step2_title: 'Abrir a pasta da extensão',
|
||||
step2_body: 'Abra a pasta que contém os arquivos da extensão:',
|
||||
step3_title: 'Arraste a pasta da extensão para o Chrome',
|
||||
step3_body: 'Arraste a pasta <span class="ext-name"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4.5A1.5 1.5 0 013.5 3h3.172a1 1 0 01.707.293L8.5 4.414a1 1 0 00.707.293H12.5A1.5 1.5 0 0114 6.207V12a1.5 1.5 0 01-1.5 1.5h-9A1.5 1.5 0 012 12V4.5z"/></svg>lime-chrome</span> diretamente para a página <code>chrome://extensions</code>. O Chrome a instalará imediatamente.',
|
||||
step3_alt: '<strong style="font-weight:700">Não consegue arrastar?</strong> Tente carregar descompactada manualmente',
|
||||
step3_more: 'Após a instalação',
|
||||
click_to_copy: 'Clique para copiar',
|
||||
open_folder: 'Abrir pasta',
|
||||
copy_path: 'Copiar caminho',
|
||||
more_info: 'Mais informações',
|
||||
extras_devmode_title: 'Carregar extensão descompactada',
|
||||
extras_devmode_body: 'Clique em <strong>"Carregar sem compactação"</strong> na página <code>chrome://extensions</code> e selecione a pasta da extensão.',
|
||||
extras_finder_title: 'Navegar para um caminho no Finder (macOS)',
|
||||
extras_finder_body: 'Na caixa de seleção de arquivo, pressione <strong>⌘ + Shift + G</strong> para abrir "Ir para a pasta", cole o caminho e pressione Enter.',
|
||||
extras_pin_title: 'Fixar a extensão',
|
||||
extras_pin_body: 'Clique no ícone de quebra-cabeça na barra de ferramentas do Chrome e fixe <strong>Lime Browser Bridge</strong> para acesso rápido.',
|
||||
extras_debug_title: 'Sobre o banner de depuração',
|
||||
extras_debug_body: 'Quando a extensão se conecta a uma aba, o Chrome mostra um banner amarelo: <strong>"Lime Browser Bridge começou a depurar este navegador"</strong>. Este é um aviso de segurança padrão. <strong>Não clique em "Cancelar"</strong> a menos que queira desconectar a aba.',
|
||||
extras_update_title: 'Atualizar a extensão',
|
||||
extras_update_body: 'Quando o Lime for atualizado, vá para <code>chrome://extensions</code>, remova a versão antiga e repita os passos. Ou clique no <strong>ícone de recarregar</strong> (↻) no cartão da extensão.',
|
||||
extras_trouble_title: 'Solução de problemas',
|
||||
extras_trouble_badge: '<strong>O ícone mostra <span class="badge-demo" style="background:#F59E0B">…</span></strong> — O Lime não está em execução ou o controle do navegador não está ativado.',
|
||||
extras_trouble_page: '<strong>Não é possível controlar uma página</strong> — Páginas internas (<code>chrome://</code>) não podem ser depuradas. Use uma página web normal.',
|
||||
extras_trouble_restart: '<strong>Extensão desapareceu</strong> — Extensões descompactadas podem ser desativadas após reiniciar o Chrome. Reative-as em <code>chrome://extensions</code>.',
|
||||
alt_use_direct: 'Seu Chrome suporta conexão direta — experimente o método mais rápido',
|
||||
compare_methods: 'Comparar métodos de conexão',
|
||||
});
|
||||
|
||||
InstallI18n.apply();
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var browserVersion = parseInt(params.get('version') || '0', 10);
|
||||
if (browserVersion >= 144) {
|
||||
var altEl = document.getElementById('alt-direct-cdp');
|
||||
if (altEl) altEl.style.display = 'block';
|
||||
}
|
||||
|
||||
setupExtensionPath();
|
||||
setupCopyBlock('copy-url-block', 'chrome://extensions');
|
||||
setupAllStepExtras();
|
||||
setupExtrasToggle('extras-toggle', 'extras-content');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lime Browser Bridge</title>
|
||||
<link rel="stylesheet" href="styles/options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="header anim-entrance" style="--delay:0">
|
||||
<div class="header-icon" aria-hidden="true">
|
||||
<img src="../icons/icon128.png" alt="" />
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1>Lime Browser Bridge</h1>
|
||||
<p>Connect your browser to Lime.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle + status -->
|
||||
<div class="status-card anim-entrance" style="--delay:1" id="status-card" data-state="disabled">
|
||||
<div class="status-glow"></div>
|
||||
<div class="status-row">
|
||||
<span class="status-indicator"></span>
|
||||
<span class="status-label" id="status-label">Checking…</span>
|
||||
<span class="status-meta" id="status-meta"></span>
|
||||
<label class="toggle" title="Enable / disable relay">
|
||||
<input type="checkbox" id="relay-toggle" />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="status-detail" id="status-detail"></div>
|
||||
<div class="tab-stats" id="tab-stats"></div>
|
||||
</div>
|
||||
|
||||
<!-- Attached tabs -->
|
||||
<div class="section anim-entrance" style="--delay:2; display:none" id="tabs-section">
|
||||
<div class="section-label">Attached Tabs</div>
|
||||
<div class="card">
|
||||
<div id="tabs-list" class="tab-list"></div>
|
||||
<div id="tabs-empty" class="tab-list-empty">No tabs attached</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Quick Start -->
|
||||
<details class="collapsible anim-entrance" style="--delay:2">
|
||||
<summary class="collapsible-trigger">
|
||||
<svg class="collapsible-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 4 10 8 6 12"/></svg>
|
||||
<span>Quick Start</span>
|
||||
</summary>
|
||||
<div class="collapsible-body">
|
||||
<div class="card">
|
||||
<ol class="steps">
|
||||
<li>Make sure <strong>Lime</strong> is running with browser control enabled.</li>
|
||||
<li>The relay connects automatically — a green dot on the icon means ready.</li>
|
||||
<li>Ask Lime a browser-related question — it can now see and control your tabs.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Collapsible: How It Works -->
|
||||
<details class="collapsible anim-entrance" style="--delay:3">
|
||||
<summary class="collapsible-trigger">
|
||||
<svg class="collapsible-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 4 10 8 6 12"/></svg>
|
||||
<span>How It Works</span>
|
||||
</summary>
|
||||
<div class="collapsible-body">
|
||||
<div class="card">
|
||||
<div class="card-title">Always-on relay</div>
|
||||
<div class="card-body">The relay is enabled by default and connects automatically when Lime is running. Use the toggle above to pause if needed. Agent-opened tabs are grouped under an <strong>"Lime Agent"</strong> tab group.</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Auto-reconnect</div>
|
||||
<div class="card-body">If the connection drops, the extension reconnects automatically. No manual intervention needed.</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Tab safety</div>
|
||||
<div class="card-body">Your existing tabs are protected — the agent can only close tabs it opened. Personal tabs are never affected.</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Collapsible: Status Indicators -->
|
||||
<details class="collapsible anim-entrance" style="--delay:4">
|
||||
<summary class="collapsible-trigger">
|
||||
<svg class="collapsible-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 4 10 8 6 12"/></svg>
|
||||
<span>Status Indicators</span>
|
||||
</summary>
|
||||
<div class="collapsible-body">
|
||||
<div class="card">
|
||||
<div class="badge-grid">
|
||||
<span class="indicator-dot indicator-dot--green"></span>
|
||||
<span class="badge-text">Connected — agent can control your tabs</span>
|
||||
<span class="indicator-dot indicator-dot--yellow"></span>
|
||||
<span class="badge-text">Connecting / reconnecting to local relay server</span>
|
||||
<span class="indicator-dot indicator-dot--dim"></span>
|
||||
<span class="badge-text">Relay disabled — no indicator</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Collapsible: Troubleshooting -->
|
||||
<details class="collapsible anim-entrance" style="--delay:5">
|
||||
<summary class="collapsible-trigger">
|
||||
<svg class="collapsible-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 4 10 8 6 12"/></svg>
|
||||
<span>Troubleshooting</span>
|
||||
</summary>
|
||||
<div class="collapsible-body">
|
||||
<div class="card">
|
||||
<div class="card-title">Stays on yellow dot (connecting)</div>
|
||||
<div class="card-body">Relay server not reachable. Ensure Lime is running with browser control enabled.</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Pages not responding</div>
|
||||
<div class="card-body">Internal Chrome pages (<code>chrome://</code>) cannot be controlled. Navigate to a regular webpage.</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Collapsible: Settings -->
|
||||
<details class="collapsible anim-entrance" style="--delay:6">
|
||||
<summary class="collapsible-trigger">
|
||||
<svg class="collapsible-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 4 10 8 6 12"/></svg>
|
||||
<span>Settings</span>
|
||||
</summary>
|
||||
<div class="collapsible-body">
|
||||
<div class="card">
|
||||
<div class="card-title">Tab Group Behavior</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Close "Lime Agent" tab group when relay is disabled</span>
|
||||
<label class="mini-toggle">
|
||||
<input type="checkbox" id="closeGroupOnDisable" />
|
||||
<span class="mini-toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Control Port</div>
|
||||
<div class="card-body">Only change if you use a custom port in Lime config.</div>
|
||||
<div class="port-row">
|
||||
<input id="port" inputmode="numeric" pattern="[0-9]*" placeholder="9234" />
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
<div class="port-hint">Default: <code>9234</code>. Relay = control port + 2.</div>
|
||||
<div class="port-status" id="port-status"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title" style="display:flex;align-items:center;justify-content:space-between">
|
||||
CDP Logs
|
||||
<button id="logs-refresh" type="button" class="logs-refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div id="logs-container" class="logs-container">
|
||||
<div class="logs-empty">No log entries yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module" src="scripts/options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,438 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lime Browser Bridge</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f4f4f5;
|
||||
--surface-hover: #e4e4e7;
|
||||
--border: #e4e4e7;
|
||||
--text: #18181b;
|
||||
--muted: #71717a;
|
||||
--dim: #a1a1aa;
|
||||
--accent: #14b8a6;
|
||||
--accent-soft: rgba(20, 184, 166, 0.08);
|
||||
--accent-glow: rgba(20, 184, 166, 0.2);
|
||||
--green: #34d399;
|
||||
--green-soft: rgba(52, 211, 153, 0.08);
|
||||
--green-glow: rgba(52, 211, 153, 0.35);
|
||||
--yellow: #f59e0b;
|
||||
--yellow-soft: rgba(245, 158, 11, 0.08);
|
||||
--yellow-glow: rgba(245, 158, 11, 0.15);
|
||||
--red: #ef4444;
|
||||
--coral: #f43f5e;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { background: transparent; }
|
||||
body {
|
||||
width: 268px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup { padding: 16px; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.header img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.3s;
|
||||
}
|
||||
.header img:hover {
|
||||
transform: scale(1.1) rotate(-4deg);
|
||||
filter: drop-shadow(0 0 6px var(--accent-glow));
|
||||
}
|
||||
.header-text {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Status card ── */
|
||||
.status-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.4s, box-shadow 0.4s, background 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.status-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.status-card[data-state="connected"] {
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
box-shadow: 0 2px 16px -4px var(--green-glow);
|
||||
}
|
||||
.status-card[data-state="connected"]::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, var(--green-soft) 0%, transparent 60%);
|
||||
}
|
||||
.status-card[data-state="disconnected"] {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px -4px var(--yellow-glow);
|
||||
}
|
||||
.status-card[data-state="disconnected"]::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, var(--yellow-soft) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* ── Status indicator ── */
|
||||
.status-ring {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--dim);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.status-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.status-card[data-state="connected"] .status-dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green-glow);
|
||||
}
|
||||
.status-card[data-state="connected"] .status-ring::after {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
animation: ring-expand 2.5s ease-out infinite;
|
||||
}
|
||||
.status-card[data-state="disconnected"] .status-dot {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px var(--yellow-glow);
|
||||
animation: dot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.status-card[data-state="disconnected"] .status-ring::after {
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
@keyframes ring-expand {
|
||||
0% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.status-label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Toggle ── */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--dim);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background .25s, box-shadow .3s;
|
||||
}
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px; top: 2px;
|
||||
width: 16px; height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow .3s;
|
||||
}
|
||||
.toggle input:checked + .toggle-track {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
.toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Meta chips ── */
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta:empty { margin-bottom: 0; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.chip:hover { transform: translateY(-1px); }
|
||||
.chip-tabs {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: #64748b;
|
||||
}
|
||||
.chip-agent {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #f97316;
|
||||
}
|
||||
.chip-retained {
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
color: #a855f7;
|
||||
}
|
||||
.chip-num { font-weight: 700; }
|
||||
|
||||
/* ── Divider ── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* ── Tabs section ── */
|
||||
.tabs-section {
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabs-section[data-empty="true"] { display: none; }
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tabs-header svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.tabs-section[data-open="true"] .tabs-header svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 0.4s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.3s ease,
|
||||
margin 0.3s ease;
|
||||
margin-top: 0;
|
||||
}
|
||||
.tabs-section[data-open="true"] .tabs-list {
|
||||
max-height: 180px;
|
||||
opacity: 1;
|
||||
margin-top: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tabs-list::-webkit-scrollbar { width: 3px; }
|
||||
.tabs-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.tabs-list::-webkit-scrollbar-thumb {
|
||||
background: var(--dim);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.tab-item:hover {
|
||||
background: var(--surface-hover);
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.tab-item:active { transform: translateX(2px) scale(0.99); }
|
||||
|
||||
.tab-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--dim);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.tab-dot[data-state="connected"] {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green-glow);
|
||||
}
|
||||
.tab-dot[data-state="attaching"] {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px var(--yellow-glow);
|
||||
}
|
||||
.tab-dot[data-state="virtual"] { background: var(--dim); }
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tab-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-badge-agent {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #f97316;
|
||||
}
|
||||
.tab-badge-retained {
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
color: #a855f7;
|
||||
}
|
||||
.tab-go {
|
||||
width: 12px; height: 12px;
|
||||
color: var(--dim);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
}
|
||||
.tab-item:hover .tab-go {
|
||||
color: var(--accent);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* ── Settings link ── */
|
||||
.settings-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
font-family: inherit;
|
||||
border-radius: 10px;
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.settings-link:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
.settings-link svg {
|
||||
width: 14px; height: 14px;
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.settings-link:hover svg { transform: rotate(60deg); }
|
||||
|
||||
/* ── Entrance animations ── */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.anim { animation: fadeUp 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.anim-d1 { animation-delay: 40ms; }
|
||||
.anim-d2 { animation-delay: 80ms; }
|
||||
.anim-d3 { animation-delay: 120ms; }
|
||||
.anim-d4 { animation-delay: 160ms; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup">
|
||||
<div class="header anim">
|
||||
<img src="../icons/icon128.png" alt="" />
|
||||
<span class="header-text">Lime Browser Bridge</span>
|
||||
</div>
|
||||
|
||||
<div class="status-card anim anim-d1" id="status-card" data-state="disabled">
|
||||
<div class="status-ring">
|
||||
<div class="status-dot" id="dot"></div>
|
||||
</div>
|
||||
<span class="status-label" id="label">Checking…</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="toggle" />
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="meta anim anim-d2" id="meta"></div>
|
||||
|
||||
<div class="tabs-section anim anim-d2" id="tabs-section" data-empty="true" data-open="false">
|
||||
<div class="tabs-header" id="tabs-header">
|
||||
<svg viewBox="0 0 10 10" fill="currentColor"><path d="M3 1l5 4-5 4z"/></svg>
|
||||
<span>Attached Tabs</span>
|
||||
</div>
|
||||
<div class="tabs-list" id="tabs-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider anim anim-d3"></div>
|
||||
|
||||
<button class="settings-link anim anim-d4" id="open-settings">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M13.5 8a5.5 5.5 0 01-.4 2.1l1.2 1.2-1.5 1.5-1.2-1.2A5.5 5.5 0 018 13.5a5.5 5.5 0 01-2.1-.4l-1.2 1.2-1.5-1.5 1.2-1.2A5.5 5.5 0 012.5 8c0-.7.1-1.4.4-2.1L1.7 4.7l1.5-1.5 1.2 1.2A5.5 5.5 0 018 2.5c.7 0 1.4.1 2.1.4l1.2-1.2 1.5 1.5-1.2 1.2c.3.7.4 1.4.4 2.1z"/>
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script src="scripts/popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Shared helper functions for install guide pages.
|
||||
*/
|
||||
|
||||
function setupExtrasToggle(toggleId, contentId) {
|
||||
var toggle = document.getElementById(toggleId);
|
||||
var content = document.getElementById(contentId);
|
||||
if (!toggle || !content) return;
|
||||
toggle.addEventListener('click', function () {
|
||||
var expanded = toggle.getAttribute('aria-expanded') === 'true';
|
||||
toggle.setAttribute('aria-expanded', String(!expanded));
|
||||
content.classList.toggle('open', !expanded);
|
||||
});
|
||||
}
|
||||
|
||||
function setupAllStepExtras() {
|
||||
var toggles = document.querySelectorAll('.step-extras-toggle');
|
||||
toggles.forEach(function (toggle) {
|
||||
var contentId = toggle.getAttribute('data-target');
|
||||
if (!contentId) return;
|
||||
var content = document.getElementById(contentId);
|
||||
if (!content) return;
|
||||
toggle.addEventListener('click', function () {
|
||||
var expanded = toggle.getAttribute('aria-expanded') === 'true';
|
||||
toggle.setAttribute('aria-expanded', String(!expanded));
|
||||
content.classList.toggle('open', !expanded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openChromeUrl(url) {
|
||||
if (window.installGuide && window.installGuide.openChromeUrl) {
|
||||
window.installGuide.openChromeUrl(url);
|
||||
var btn = event && event.currentTarget;
|
||||
if (btn && btn.classList) {
|
||||
var origText = btn.querySelector('[data-i18n]');
|
||||
btn.classList.add('clicked');
|
||||
if (origText) {
|
||||
var prev = origText.textContent;
|
||||
origText.textContent = '✓';
|
||||
setTimeout(function () {
|
||||
origText.textContent = prev;
|
||||
btn.classList.remove('clicked');
|
||||
}, 1500);
|
||||
} else {
|
||||
setTimeout(function () { btn.classList.remove('clicked'); }, 1500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
navigator.clipboard.writeText(url).then(function () {
|
||||
var btn = event && event.currentTarget;
|
||||
if (btn) {
|
||||
var origText = btn.querySelector('[data-i18n]');
|
||||
if (origText) {
|
||||
var prev = origText.textContent;
|
||||
origText.textContent = InstallI18n.t('click_to_copy') === '点击复制' ? '已复制!请粘贴到浏览器' : 'Copied! Paste in browser';
|
||||
setTimeout(function () { origText.textContent = prev; }, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupCopyBlock(blockId, textToCopy) {
|
||||
var block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
block.addEventListener('click', function () {
|
||||
var label = block.querySelector('.copy-label');
|
||||
navigator.clipboard.writeText(textToCopy).then(function () {
|
||||
if (label) {
|
||||
var origText = label.textContent;
|
||||
label.textContent = '✓';
|
||||
setTimeout(function () { label.textContent = origText; }, 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupExtensionPath() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var extensionPath = params.get('extensionPath');
|
||||
if (!extensionPath) return;
|
||||
|
||||
var el = document.getElementById('extension-path');
|
||||
if (el) {
|
||||
var sep = extensionPath.indexOf('/') >= 0 ? '/' : '\\';
|
||||
var parts = extensionPath.split(sep);
|
||||
var folderName = parts[parts.length - 1] || parts[parts.length - 2] || extensionPath;
|
||||
var parentParts = parts.slice(0, -1);
|
||||
var shortPrefix = parentParts.length > 2
|
||||
? '…' + sep + parentParts.slice(-2).join(sep)
|
||||
: parentParts.join(sep);
|
||||
var shortPath = shortPrefix + sep;
|
||||
|
||||
el.innerHTML = '<span class="path-short">' + shortPath + '<span class="path-highlight">' + folderName + '</span></span>'
|
||||
+ '<span class="path-full">' + extensionPath.replace(new RegExp(folderName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'), '<span class="path-highlight">' + folderName + '</span>') + '</span>';
|
||||
el.title = extensionPath;
|
||||
}
|
||||
|
||||
var copyBtn = document.getElementById('copy-path-btn');
|
||||
if (copyBtn) copyBtn.style.display = 'inline-flex';
|
||||
|
||||
var openBtn = document.getElementById('open-folder-btn');
|
||||
if (openBtn) openBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function openExtensionFolder() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var parentPath = params.get('extensionParentPath') || params.get('extensionPath');
|
||||
if (!parentPath) return;
|
||||
var normalized = parentPath.replace(/\\/g, '/');
|
||||
if (/^\/\//.test(normalized)) {
|
||||
// UNC path: \\server\share → file://server/share (RFC 8089)
|
||||
window.open('file:' + normalized);
|
||||
} else if (/^[A-Za-z]:/.test(normalized)) {
|
||||
// Windows drive: C:/path → file:///C:/path
|
||||
window.open('file:///' + normalized);
|
||||
} else {
|
||||
// Unix or already normalized
|
||||
window.open('file://' + normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToInstallPage(page) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
window.location.href = page + '?' + params.toString();
|
||||
}
|
||||
|
||||
var _copyBtnOrigHTML = '';
|
||||
var _copyResetTimer = null;
|
||||
function copyExtensionPath() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var extensionPath = params.get('extensionPath');
|
||||
if (!extensionPath) return;
|
||||
var btn = document.getElementById('copy-path-btn');
|
||||
if (btn && !_copyBtnOrigHTML) _copyBtnOrigHTML = btn.innerHTML;
|
||||
navigator.clipboard.writeText(extensionPath).then(function () {
|
||||
if (!btn) return;
|
||||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3 3 7-7"/></svg> ✓';
|
||||
if (_copyResetTimer) clearTimeout(_copyResetTimer);
|
||||
_copyResetTimer = setTimeout(function () {
|
||||
btn.innerHTML = _copyBtnOrigHTML;
|
||||
_copyResetTimer = null;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared i18n utilities for install guide pages.
|
||||
*
|
||||
* Detects language from ?lang= query parameter or navigator.language.
|
||||
* Each page registers its own translation strings, then calls applyI18n().
|
||||
*/
|
||||
|
||||
var InstallI18n = (function () {
|
||||
var _strings = {};
|
||||
|
||||
var SUPPORTED = ['zh', 'en', 'de', 'es', 'fr', 'pt'];
|
||||
|
||||
function detectLang() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var lang = params.get('lang') || params.get('language');
|
||||
if (lang && SUPPORTED.indexOf(lang) !== -1) return lang;
|
||||
var nav = (navigator.language || '').toLowerCase();
|
||||
for (var i = 0; i < SUPPORTED.length; i++) {
|
||||
if (nav.startsWith(SUPPORTED[i])) return SUPPORTED[i];
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
function register(lang, translations) {
|
||||
_strings[lang] = Object.assign(_strings[lang] || {}, translations);
|
||||
}
|
||||
|
||||
function apply(lang) {
|
||||
lang = lang || detectLang();
|
||||
var strings = _strings[lang] || _strings['en'] || {};
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
var key = el.getAttribute('data-i18n');
|
||||
if (strings[key]) el.innerHTML = strings[key];
|
||||
});
|
||||
document.documentElement.lang = lang;
|
||||
if (strings.title) document.title = strings.title;
|
||||
return lang;
|
||||
}
|
||||
|
||||
function t(key, lang) {
|
||||
lang = lang || detectLang();
|
||||
var strings = _strings[lang] || _strings['en'] || {};
|
||||
return strings[key] || key;
|
||||
}
|
||||
|
||||
return {
|
||||
detectLang: detectLang,
|
||||
register: register,
|
||||
apply: apply,
|
||||
t: t,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,297 @@
|
||||
import { RelayState, STATE_TEXT, RELAY_PORT_OFFSET, clampPort, computeRelayPort, SETTINGS_KEYS, getSetting, setSetting } from '../../lib/constants.js'
|
||||
|
||||
// ── DOM refs ──
|
||||
|
||||
const statusCard = document.getElementById('status-card')
|
||||
const statusLabel = document.getElementById('status-label')
|
||||
const statusMeta = document.getElementById('status-meta')
|
||||
const statusDetail = document.getElementById('status-detail')
|
||||
const tabStats = document.getElementById('tab-stats')
|
||||
const relayToggle = document.getElementById('relay-toggle')
|
||||
const portInput = document.getElementById('port')
|
||||
const portStatus = document.getElementById('port-status')
|
||||
|
||||
// ── Status rendering ──
|
||||
|
||||
let _lastStatusJson = ''
|
||||
|
||||
function renderStatus(status) {
|
||||
const key = JSON.stringify(status ?? null)
|
||||
if (key === _lastStatusJson) return
|
||||
_lastStatusJson = key
|
||||
const state = status?.state || RelayState.DISABLED
|
||||
const cfg = STATE_TEXT[state] || STATE_TEXT[RelayState.DISABLED]
|
||||
|
||||
statusCard.dataset.state = state
|
||||
statusLabel.textContent = cfg.label
|
||||
statusDetail.textContent = cfg.detail
|
||||
|
||||
if (!toggleBusy) {
|
||||
relayToggle.checked = state !== RelayState.DISABLED
|
||||
}
|
||||
|
||||
const metaParts = []
|
||||
if (typeof status?.observerConnected === 'boolean') {
|
||||
metaParts.push(`observer ${status.observerConnected ? 'on' : 'off'}`)
|
||||
}
|
||||
if (typeof status?.controlConnected === 'boolean') {
|
||||
metaParts.push(`control ${status.controlConnected ? 'on' : 'off'}`)
|
||||
}
|
||||
if (Array.isArray(status?.debuggerTabs)) {
|
||||
metaParts.push(`debugger ${status.debuggerTabs.length}`)
|
||||
}
|
||||
statusMeta.textContent = metaParts.join(' · ')
|
||||
|
||||
tabStats.textContent = ''
|
||||
if (status?.attachedTabs > 0) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'tab-chip tab-chip-tabs'
|
||||
chip.textContent = `${status.attachedTabs} ${status.attachedTabs === 1 ? 'tab' : 'tabs'}`
|
||||
tabStats.appendChild(chip)
|
||||
}
|
||||
if (status?.agentTabs > 0) {
|
||||
const nonRetained = status.agentTabs - (status.retainedTabs || 0)
|
||||
if (nonRetained > 0) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'tab-chip tab-chip-agent'
|
||||
chip.textContent = `${nonRetained} agent`
|
||||
tabStats.appendChild(chip)
|
||||
}
|
||||
if (status.retainedTabs > 0) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'tab-chip tab-chip-retained'
|
||||
chip.textContent = `${status.retainedTabs} retained`
|
||||
tabStats.appendChild(chip)
|
||||
}
|
||||
}
|
||||
if (status?.lastError) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'tab-chip tab-chip-agent'
|
||||
chip.textContent = String(status.lastError)
|
||||
tabStats.appendChild(chip)
|
||||
}
|
||||
if (status?.controlLastError) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = 'tab-chip tab-chip-retained'
|
||||
chip.textContent = String(status.controlLastError)
|
||||
tabStats.appendChild(chip)
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBackgroundStatus() {
|
||||
try {
|
||||
const status = await chrome.runtime.sendMessage({ type: 'getRelayStatus' })
|
||||
if (!status) {
|
||||
renderStatus(null)
|
||||
return
|
||||
}
|
||||
renderStatus(status)
|
||||
} catch {
|
||||
renderStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toggle handler ──
|
||||
|
||||
let toggleBusy = false
|
||||
|
||||
relayToggle.addEventListener('change', async () => {
|
||||
if (toggleBusy) return
|
||||
toggleBusy = true
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'toggleRelay' })
|
||||
await queryBackgroundStatus()
|
||||
} catch (err) {
|
||||
console.warn('[lime-options] toggle failed:', err)
|
||||
} finally {
|
||||
toggleBusy = false
|
||||
}
|
||||
})
|
||||
|
||||
// ── Port settings ──
|
||||
|
||||
function setPortStatus(kind, message) {
|
||||
portStatus.dataset.kind = kind || ''
|
||||
portStatus.textContent = message || ''
|
||||
}
|
||||
|
||||
async function checkPort(controlPort) {
|
||||
const port = computeRelayPort(controlPort)
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', signal: AbortSignal.timeout(900) })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setPortStatus('ok', `Relay reachable at :${port}`)
|
||||
} catch {
|
||||
setPortStatus('error', `Relay not reachable at :${port}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPort() {
|
||||
const stored = await chrome.storage.local.get(['controlPort', 'relayPort'])
|
||||
const fromRelay =
|
||||
stored.relayPort != null
|
||||
? Number.parseInt(String(stored.relayPort), 10) - RELAY_PORT_OFFSET
|
||||
: undefined
|
||||
const port = clampPort(Number.isFinite(fromRelay) ? fromRelay : stored.controlPort)
|
||||
portInput.value = String(port)
|
||||
}
|
||||
|
||||
async function savePort() {
|
||||
const port = clampPort(portInput.value)
|
||||
await chrome.storage.local.set({ controlPort: port })
|
||||
portInput.value = String(port)
|
||||
await checkPort(port)
|
||||
}
|
||||
|
||||
document.getElementById('save').addEventListener('click', () => void savePort())
|
||||
|
||||
// ── Tab list ──
|
||||
|
||||
const tabsSection = document.getElementById('tabs-section')
|
||||
const tabsList = document.getElementById('tabs-list')
|
||||
|
||||
async function refreshTabList() {
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ type: 'getTabList' })
|
||||
const tabs = resp?.tabs || []
|
||||
tabs.sort((a, b) => {
|
||||
const rank = (t) => t.isRetained ? 0 : t.isAgent ? 1 : 2
|
||||
return rank(a) - rank(b)
|
||||
})
|
||||
tabsSection.style.display = tabs.length > 0 ? '' : 'none'
|
||||
tabsList.innerHTML = ''
|
||||
for (const t of tabs) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'tab-entry'
|
||||
row.style.cursor = 'pointer'
|
||||
row.addEventListener('click', () => {
|
||||
chrome.tabs.update(t.tabId, { active: true })
|
||||
chrome.tabs.get(t.tabId, (tab) => {
|
||||
if (tab?.windowId) chrome.windows.update(tab.windowId, { focused: true })
|
||||
})
|
||||
})
|
||||
|
||||
const dot = document.createElement('span')
|
||||
dot.className = 'tab-entry-state'
|
||||
dot.dataset.state = t.state
|
||||
dot.title = t.state
|
||||
|
||||
const title = document.createElement('span')
|
||||
title.className = 'tab-entry-title'
|
||||
title.textContent = t.title || `Tab ${t.tabId}`
|
||||
|
||||
const url = document.createElement('span')
|
||||
url.className = 'tab-entry-url'
|
||||
url.textContent = t.url || ''
|
||||
|
||||
row.append(dot, title, url)
|
||||
|
||||
if (t.isRetained) {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'tab-entry-badge'
|
||||
badge.dataset.type = 'retained'
|
||||
badge.textContent = 'retained'
|
||||
row.appendChild(badge)
|
||||
} else if (t.isAgent) {
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'tab-entry-badge'
|
||||
badge.dataset.type = 'agent'
|
||||
badge.textContent = 'agent'
|
||||
row.appendChild(badge)
|
||||
}
|
||||
|
||||
tabsList.appendChild(row)
|
||||
}
|
||||
} catch { /* background not ready */ }
|
||||
}
|
||||
|
||||
// ── Logs viewer ──
|
||||
|
||||
const logsContainer = document.getElementById('logs-container')
|
||||
const logsRefresh = document.getElementById('logs-refresh')
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const resp = await chrome.runtime.sendMessage({ type: 'getLogs', limit: 100 })
|
||||
const logs = resp?.logs || []
|
||||
if (logs.length === 0) {
|
||||
logsContainer.innerHTML = '<div class="logs-empty">No log entries yet</div>'
|
||||
return
|
||||
}
|
||||
logsContainer.innerHTML = ''
|
||||
for (const entry of logs) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'log-row'
|
||||
|
||||
const time = document.createElement('span')
|
||||
time.className = 'log-time'
|
||||
time.textContent = formatTime(entry.ts)
|
||||
|
||||
const dir = document.createElement('span')
|
||||
dir.className = 'log-dir'
|
||||
dir.dataset.dir = entry.dir
|
||||
dir.textContent = entry.dir
|
||||
|
||||
const method = document.createElement('span')
|
||||
method.className = 'log-method'
|
||||
method.textContent = entry.method || ''
|
||||
|
||||
const detail = document.createElement('span')
|
||||
detail.className = 'log-detail'
|
||||
detail.textContent = entry.detail || ''
|
||||
|
||||
row.append(time, dir, method, detail)
|
||||
logsContainer.appendChild(row)
|
||||
}
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight
|
||||
} catch { /* background not ready */ }
|
||||
}
|
||||
|
||||
logsRefresh.addEventListener('click', () => void refreshLogs())
|
||||
|
||||
// ── Event-driven status updates ──
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'local' && ('relayEnabled' in changes || 'controlPort' in changes || '_relayState' in changes)) {
|
||||
void queryBackgroundStatus()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void queryBackgroundStatus()
|
||||
void refreshTabList()
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback poll for state transitions not reflected in storage (connect/disconnect)
|
||||
const FALLBACK_POLL_MS = 2000
|
||||
setInterval(() => {
|
||||
void queryBackgroundStatus()
|
||||
void refreshTabList()
|
||||
}, FALLBACK_POLL_MS)
|
||||
|
||||
// ── Close group on disable setting ──
|
||||
|
||||
const closeGroupToggle = document.getElementById('closeGroupOnDisable')
|
||||
|
||||
async function loadCloseGroupSetting() {
|
||||
closeGroupToggle.checked = await getSetting(SETTINGS_KEYS.CLOSE_GROUP_ON_DISABLE, false)
|
||||
}
|
||||
|
||||
closeGroupToggle.addEventListener('change', () => {
|
||||
void setSetting(SETTINGS_KEYS.CLOSE_GROUP_ON_DISABLE, closeGroupToggle.checked)
|
||||
})
|
||||
|
||||
// ── Init ──
|
||||
|
||||
void loadPort()
|
||||
void loadCloseGroupSetting()
|
||||
void queryBackgroundStatus()
|
||||
void refreshTabList()
|
||||
void refreshLogs()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { RelayState } from '../../lib/constants.js'
|
||||
|
||||
const statusCard = document.getElementById('status-card')
|
||||
const label = document.getElementById('label')
|
||||
const toggle = document.getElementById('toggle')
|
||||
const meta = document.getElementById('meta')
|
||||
const tabsSection = document.getElementById('tabs-section')
|
||||
const tabsHeader = document.getElementById('tabs-header')
|
||||
const tabsList = document.getElementById('tabs-list')
|
||||
|
||||
const STATE_LABELS = {
|
||||
[RelayState.DISABLED]: 'Offline',
|
||||
[RelayState.DISCONNECTED]: 'Connecting…',
|
||||
[RelayState.CONNECTED]: 'Connected',
|
||||
}
|
||||
|
||||
const ARROW_SVG =
|
||||
'<svg class="tab-go" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4.5 2.5l4 3.5-4 3.5"/></svg>'
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div')
|
||||
div.appendChild(document.createTextNode(str))
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
function render(status) {
|
||||
const state = status?.state || RelayState.DISABLED
|
||||
statusCard.dataset.state = state
|
||||
label.textContent = STATE_LABELS[state] || 'Unknown'
|
||||
|
||||
if (!toggleBusy) {
|
||||
toggle.checked = state !== RelayState.DISABLED
|
||||
}
|
||||
|
||||
meta.innerHTML = ''
|
||||
if (status?.attachedTabs > 0) {
|
||||
addChip(`${status.attachedTabs}`, status.attachedTabs === 1 ? 'tab' : 'tabs', 'tabs')
|
||||
}
|
||||
if (status?.agentTabs > 0) {
|
||||
const nonRetained = status.agentTabs - (status.retainedTabs || 0)
|
||||
if (nonRetained > 0) addChip(`${nonRetained}`, 'agent', 'agent')
|
||||
if (status.retainedTabs > 0) addChip(`${status.retainedTabs}`, 'retained', 'retained')
|
||||
}
|
||||
}
|
||||
|
||||
function addChip(num, text, kind) {
|
||||
const chip = document.createElement('span')
|
||||
chip.className = `chip chip-${kind}`
|
||||
chip.innerHTML = `<span class="chip-num">${num}</span> ${text}`
|
||||
meta.appendChild(chip)
|
||||
}
|
||||
|
||||
function tabSortKey(t) {
|
||||
if (t.isRetained) return 0
|
||||
if (t.isAgent) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function renderTabs(tabs) {
|
||||
if (tabs) tabs.sort((a, b) => tabSortKey(a) - tabSortKey(b))
|
||||
const hasTabs = tabs && tabs.length > 0
|
||||
tabsSection.dataset.empty = String(!hasTabs)
|
||||
|
||||
if (!hasTabs) {
|
||||
tabsList.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
tabsList.innerHTML = ''
|
||||
tabs.forEach((tab) => {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'tab-item'
|
||||
el.addEventListener('click', () => {
|
||||
chrome.tabs.update(tab.tabId, { active: true })
|
||||
if (tab.windowId) chrome.windows.update(tab.windowId, { focused: true })
|
||||
window.close()
|
||||
})
|
||||
|
||||
const displayTitle = tab.title || tab.url || `Tab ${tab.tabId}`
|
||||
const safeTitle = escapeHtml(displayTitle)
|
||||
const safeState = escapeHtml(tab.state || 'virtual')
|
||||
let badge = ''
|
||||
if (tab.isAgent && !tab.isRetained) {
|
||||
badge = '<span class="tab-badge tab-badge-agent">agent</span>'
|
||||
} else if (tab.isRetained) {
|
||||
badge = '<span class="tab-badge tab-badge-retained">retained</span>'
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
`<span class="tab-dot" data-state="${safeState}"></span>` +
|
||||
`<span class="tab-title" title="${safeTitle}">${safeTitle}</span>` +
|
||||
badge +
|
||||
ARROW_SVG
|
||||
tabsList.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
tabsHeader.addEventListener('click', () => {
|
||||
const isOpen = tabsSection.dataset.open === 'true'
|
||||
tabsSection.dataset.open = String(!isOpen)
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [status, tabResp] = await Promise.all([
|
||||
chrome.runtime.sendMessage({ type: 'getRelayStatus' }),
|
||||
chrome.runtime.sendMessage({ type: 'getTabList' }),
|
||||
])
|
||||
render(status)
|
||||
renderTabs(tabResp?.tabs)
|
||||
} catch {
|
||||
render(null)
|
||||
renderTabs([])
|
||||
}
|
||||
}
|
||||
|
||||
let toggleBusy = false
|
||||
toggle.addEventListener('change', async () => {
|
||||
if (toggleBusy) return
|
||||
toggleBusy = true
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'toggleRelay' })
|
||||
await refresh()
|
||||
} finally {
|
||||
toggleBusy = false
|
||||
}
|
||||
})
|
||||
|
||||
document.getElementById('open-settings').addEventListener('click', () => {
|
||||
chrome.runtime.openOptionsPage()
|
||||
window.close()
|
||||
})
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'local' && ('relayEnabled' in changes || '_relayState' in changes)) {
|
||||
void refresh()
|
||||
}
|
||||
})
|
||||
|
||||
void refresh()
|
||||
setInterval(() => void refresh(), 1000)
|
||||
@@ -0,0 +1,612 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--accent: #14b8a6;
|
||||
--accent-soft: color-mix(in oklab, var(--accent) 10%, transparent);
|
||||
--accent-border: color-mix(in oklab, var(--accent) 25%, transparent);
|
||||
--green: #22C55E;
|
||||
--surface: color-mix(in oklab, canvas 95%, canvasText 5%);
|
||||
--border: color-mix(in oklab, canvasText 8%, transparent);
|
||||
--muted: color-mix(in oklab, canvasText 50%, transparent);
|
||||
--dim: color-mix(in oklab, canvasText 25%, transparent);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: canvasText;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { min-height: 100vh; background: canvas; }
|
||||
|
||||
.drag-handle {
|
||||
-webkit-app-region: drag;
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 40px 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-icon img { width: 32px; height: 32px; }
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
/* ── Chrome Web Store section ── */
|
||||
|
||||
.webstore-section {
|
||||
margin-bottom: 32px;
|
||||
animation: step-enter 0.4s ease-out 0.05s both;
|
||||
}
|
||||
|
||||
.webstore-card {
|
||||
padding: 20px 24px;
|
||||
border-radius: 14px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.webstore-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.webstore-badge svg { flex-shrink: 0; }
|
||||
|
||||
.webstore-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.webstore-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.webstore-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Manual install divider ── */
|
||||
|
||||
.manual-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.manual-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.manual-divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.manual-divider-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Steps ── */
|
||||
|
||||
.step-list {
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
padding-left: 64px;
|
||||
padding-bottom: 40px;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.step:last-child { padding-bottom: 0; }
|
||||
|
||||
.step::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.step::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 46px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.step:last-child::after { display: none; }
|
||||
|
||||
.step-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.step-body {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.step-body code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.step-body a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.step-body a:hover { text-decoration: underline; }
|
||||
|
||||
.step-note {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
line-height: 1.5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.step-note strong { font-weight: 700; }
|
||||
|
||||
.url-block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
margin: 10px 0;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.url-block:hover { border-color: var(--accent-border); }
|
||||
|
||||
.url-block .copy-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, canvas 80%, canvasText 5%);
|
||||
border: 1px solid var(--border);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
color: canvasText;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: color-mix(in oklab, var(--accent) 15%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn:active { transform: scale(0.97); }
|
||||
|
||||
.open-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 4px;
|
||||
padding: 10px 22px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.open-btn:hover {
|
||||
background: color-mix(in oklab, var(--accent) 85%, black);
|
||||
border-color: color-mix(in oklab, var(--accent) 85%, black);
|
||||
}
|
||||
|
||||
.open-btn:active { transform: scale(0.97); }
|
||||
|
||||
.open-btn svg { flex-shrink: 0; }
|
||||
|
||||
.url-block, .action-btn, .open-btn, a, button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Per-step collapsible ── */
|
||||
|
||||
.step-extras {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.step-extras-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 0;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.step-extras-toggle svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.step-extras-toggle[aria-expanded="true"] svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.step-extras-content {
|
||||
display: none;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.step-extras-content.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Collapsible extras ── */
|
||||
|
||||
.extras {
|
||||
margin-top: 48px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.extras-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 6px 0;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.extras-toggle svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.extras-toggle[aria-expanded="true"] svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.extras-content {
|
||||
display: none;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.extras-content.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 16px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.note:first-child { margin-top: 0; }
|
||||
|
||||
.note-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.note-body {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.note-body strong { color: canvasText; font-weight: 600; }
|
||||
|
||||
/* ── Extension name badge ── */
|
||||
|
||||
.ext-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.ext-name svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Path display with truncation ── */
|
||||
|
||||
.path-display {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.path-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.path-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-display:hover .path-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-display:hover .path-full {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.path-display .path-highlight {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-demo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Alternative method link ── */
|
||||
|
||||
.alt-method {
|
||||
margin-top: 36px;
|
||||
animation: step-enter 0.4s ease-out 0.6s both;
|
||||
}
|
||||
|
||||
.alt-method-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alt-method-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.alt-method-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.alt-method-btn:active { transform: scale(0.99); }
|
||||
|
||||
.alt-method-btn svg:first-child {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.alt-method-btn:hover svg:first-child { opacity: 1; }
|
||||
|
||||
.alt-method-btn svg:last-child {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Step entrance animation ── */
|
||||
|
||||
@keyframes step-enter {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.step {
|
||||
animation: step-enter 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.step:nth-child(1) { animation-delay: 0.1s; }
|
||||
.step:nth-child(2) { animation-delay: 0.25s; }
|
||||
.step:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
.extras {
|
||||
animation: step-enter 0.4s ease-out 0.55s both;
|
||||
}
|
||||
|
||||
/* ── Open button click feedback ── */
|
||||
|
||||
.open-btn.clicked {
|
||||
background: color-mix(in oklab, var(--green) 90%, black);
|
||||
border-color: color-mix(in oklab, var(--green) 90%, black);
|
||||
}
|
||||
|
||||
/* ── Success card ── */
|
||||
|
||||
.success-card {
|
||||
margin-top: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--green) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--green) 25%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.success-card svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.success-card span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f4f4f5;
|
||||
--surface-hover: #e4e4e7;
|
||||
--border: #e4e4e7;
|
||||
--text: #18181b;
|
||||
--muted: #71717a;
|
||||
--dim: #a1a1aa;
|
||||
--accent: #14b8a6;
|
||||
--accent-soft: rgba(20, 184, 166, 0.08);
|
||||
--accent-border: rgba(20, 184, 166, 0.2);
|
||||
--accent-glow: rgba(20, 184, 166, 0.15);
|
||||
--green: #34d399;
|
||||
--green-soft: rgba(52, 211, 153, 0.08);
|
||||
--green-glow: rgba(52, 211, 153, 0.35);
|
||||
--yellow: #f59e0b;
|
||||
--yellow-soft: rgba(245, 158, 11, 0.08);
|
||||
--yellow-glow: rgba(245, 158, 11, 0.12);
|
||||
--red: #ef4444;
|
||||
--coral: #f43f5e;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 52px 24px 72px;
|
||||
}
|
||||
|
||||
/* ── Entrance animation ── */
|
||||
|
||||
@keyframes entrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.anim-entrance {
|
||||
animation: entrance 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation-delay: calc(var(--delay, 0) * 60ms);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.header-icon:hover {
|
||||
transform: scale(1.06) rotate(-3deg);
|
||||
}
|
||||
|
||||
.header-icon img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.header-text h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Status card ── */
|
||||
|
||||
.status-card {
|
||||
position: relative;
|
||||
padding: 20px 22px;
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.4s, box-shadow 0.4s;
|
||||
}
|
||||
|
||||
.status-card[data-state="connected"] {
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
box-shadow: 0 2px 16px -4px var(--green-glow);
|
||||
}
|
||||
|
||||
.status-card[data-state="disconnected"] {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 16px -6px var(--yellow-glow);
|
||||
}
|
||||
|
||||
.status-card[data-state="disabled"] {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.status-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-card[data-state="connected"] .status-glow {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, var(--green-soft), transparent 60%);
|
||||
}
|
||||
|
||||
.status-card[data-state="disconnected"] .status-glow {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, var(--yellow-soft), transparent 60%);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--dim);
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-card[data-state="connected"] .status-indicator {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px var(--green-glow);
|
||||
}
|
||||
|
||||
.status-card[data-state="connected"] .status-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(52, 211, 153, 0.3);
|
||||
animation: indicator-ring 2.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes indicator-ring {
|
||||
0% { transform: scale(1); opacity: 0.7; }
|
||||
50% { transform: scale(1.5); opacity: 0; }
|
||||
100% { transform: scale(1.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.status-card[data-state="disconnected"] .status-indicator {
|
||||
background: var(--yellow);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--yellow-glow); }
|
||||
50% { opacity: 0.4; box-shadow: none; }
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.status-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Toggle switch ── */
|
||||
|
||||
.toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: var(--dim);
|
||||
transition: background 0.25s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(18px);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-stats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-left: 24px;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-stats:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.tab-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tab-chip-tabs {
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tab-chip-agent {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.tab-chip-retained {
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
/* ── Collapsible sections ── */
|
||||
|
||||
.collapsible {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.collapsible-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 4px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--dim);
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
transition: color 0.2s, background 0.15s;
|
||||
}
|
||||
|
||||
.collapsible-trigger:hover {
|
||||
color: var(--muted);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.collapsible-trigger::-webkit-details-marker { display: none; }
|
||||
|
||||
.collapsible-chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.collapsible[open] .collapsible-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
padding: 4px 0 12px 0;
|
||||
animation: slideDown 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Section (non-collapsible) ── */
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
margin-bottom: 10px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
transition: border-color 0.25s, transform 0.15s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--dim);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Settings row ── */
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Steps ── */
|
||||
|
||||
.steps {
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
}
|
||||
|
||||
.steps li {
|
||||
position: relative;
|
||||
padding-left: 34px;
|
||||
padding-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.steps li::before {
|
||||
content: counter(step);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.steps li:last-child { padding-bottom: 0; }
|
||||
|
||||
/* ── Indicator dots (in badge reference) ── */
|
||||
|
||||
.badge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
justify-self: center;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.indicator-dot--green {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green-glow);
|
||||
}
|
||||
|
||||
.indicator-dot--yellow {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 8px var(--yellow-glow);
|
||||
}
|
||||
|
||||
.indicator-dot--dim {
|
||||
background: var(--dim);
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Inline code ── */
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--surface-hover);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Port settings ── */
|
||||
|
||||
.port-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.port-row input {
|
||||
width: 100px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, monospace;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.port-row input:focus {
|
||||
border-color: var(--accent-border);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft), 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.port-row button {
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.port-row button:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.port-row button:active { opacity: 0.8; transform: scale(0.97); }
|
||||
|
||||
.port-hint {
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.port-status {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
min-height: 16px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.port-status[data-kind="ok"] { color: var(--green); }
|
||||
.port-status[data-kind="error"] { color: var(--red); }
|
||||
|
||||
/* ── Tab list ── */
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-list:empty + .tab-list-empty { display: block; }
|
||||
.tab-list:not(:empty) + .tab-list-empty { display: none; }
|
||||
|
||||
.tab-list-empty {
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tab-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hover);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.tab-entry:hover {
|
||||
background: var(--border);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.tab-entry-state {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tab-entry-state[data-state="connected"] {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px var(--green-glow);
|
||||
}
|
||||
.tab-entry-state[data-state="virtual"] { background: var(--dim); }
|
||||
.tab-entry-state[data-state="attaching"] {
|
||||
background: var(--yellow);
|
||||
box-shadow: 0 0 6px var(--yellow-glow);
|
||||
}
|
||||
|
||||
.tab-entry-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-entry-url {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tab-entry-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-entry-badge[data-type="agent"] {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.tab-entry-badge[data-type="retained"] {
|
||||
background: rgba(168, 85, 247, 0.08);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
/* ── Logs ── */
|
||||
|
||||
.logs-container {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
color: var(--dim);
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.log-time { color: var(--dim); flex-shrink: 0; }
|
||||
.log-dir { flex-shrink: 0; width: 16px; text-align: center; }
|
||||
.log-dir[data-dir="↓"] { color: var(--green); }
|
||||
.log-dir[data-dir="↑"] { color: var(--accent); }
|
||||
.log-method { color: var(--text); font-weight: 500; }
|
||||
.log-detail { color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.logs-refresh-btn {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.logs-refresh-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* ── Mini Toggle ── */
|
||||
|
||||
.mini-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mini-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.mini-toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.mini-toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.mini-toggle input:checked + .mini-toggle-track {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green-glow);
|
||||
}
|
||||
|
||||
.mini-toggle input:checked + .mini-toggle-track::after {
|
||||
transform: translateX(14px);
|
||||
box-shadow: 0 0 4px var(--green-glow);
|
||||
}
|
||||
@@ -151,6 +151,10 @@
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.badge-warn {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
border-radius: 14px;
|
||||
|
||||
@@ -3,6 +3,7 @@ const DEFAULT_SETTINGS = {
|
||||
bridgeKey: "",
|
||||
profileKey: "default",
|
||||
monitoringEnabled: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const bridgeStatusEl = document.getElementById("bridgeStatus");
|
||||
@@ -24,9 +25,9 @@ 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 setBadge(el, label, variant = "off") {
|
||||
el.textContent = label;
|
||||
el.className = `badge badge-${variant}`;
|
||||
}
|
||||
|
||||
function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
|
||||
@@ -41,15 +42,53 @@ function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
|
||||
|
||||
function applyStatus(status) {
|
||||
const connected = Boolean(status?.isConnected);
|
||||
const connecting = Boolean(status?.isConnecting);
|
||||
const controlConnected = Boolean(status?.isControlConnected);
|
||||
const controlConnecting = Boolean(status?.isControlConnecting);
|
||||
const enabled = status?.enabled !== false;
|
||||
const monitoring = Boolean(status?.monitoringEnabled);
|
||||
setBadge(bridgeStatusEl, connected, "已连接", "未连接");
|
||||
setBadge(monitorStatusEl, monitoring, "开启", "关闭");
|
||||
toggleConnBtnEl.textContent = connected ? "断开连接" : "立即连接";
|
||||
const errorText =
|
||||
typeof status?.lastError === "string" ? status.lastError.trim() : "";
|
||||
const controlErrorText =
|
||||
typeof status?.controlLastError === "string"
|
||||
? status.controlLastError.trim()
|
||||
: "";
|
||||
|
||||
if (connected) {
|
||||
setBadge(bridgeStatusEl, "已连接", "on");
|
||||
} else if (enabled && connecting) {
|
||||
setBadge(bridgeStatusEl, "连接中", "warn");
|
||||
} else if (enabled && errorText) {
|
||||
setBadge(bridgeStatusEl, "待重试", "warn");
|
||||
} else if (enabled) {
|
||||
setBadge(bridgeStatusEl, "已启用", "warn");
|
||||
} else {
|
||||
setBadge(bridgeStatusEl, "已停用", "off");
|
||||
}
|
||||
|
||||
setBadge(monitorStatusEl, monitoring ? "开启" : "关闭", monitoring ? "on" : "off");
|
||||
toggleConnBtnEl.textContent = enabled ? "停止自动连接" : "启用并连接";
|
||||
toggleMonitorBtnEl.textContent = monitoring ? "关闭页面监控" : "开启页面监控";
|
||||
|
||||
statusHintEl.textContent = connected
|
||||
? "浏览器扩展已经接入当前 Lime 运行时。后续如需重新安装或切换目录,请回到 Lime 的“连接器”页处理。"
|
||||
: "如果当前仍未连接,请先打开 Lime 的“连接器”页启用浏览器连接器,或重新同步扩展到你选择的目录。";
|
||||
if (connected && controlConnected) {
|
||||
statusHintEl.textContent =
|
||||
"浏览器扩展的 observer/control 双通道都已接入当前 Lime 运行时,后续会持续复用你当前已登录的 Chrome 标签页。";
|
||||
} else if (connected && (controlConnecting || controlErrorText)) {
|
||||
statusHintEl.textContent = controlErrorText
|
||||
? `页面观察已接入,但控制通道当前异常:${controlErrorText}`
|
||||
: "页面观察已接入,控制通道正在补连。";
|
||||
} else if (enabled && connecting) {
|
||||
statusHintEl.textContent =
|
||||
"扩展已启用,正在尝试连接本地 Lime relay。保持当前 Chrome 打开即可,连接成功后会自动抓取当前标签页。";
|
||||
} else if (enabled && errorText) {
|
||||
statusHintEl.textContent = `扩展已启用,但当前连接失败:${errorText}`;
|
||||
} else if (enabled) {
|
||||
statusHintEl.textContent =
|
||||
"扩展已启用,但当前还未连上 Lime。请确认桌面端已经启动,并检查连接器页中的地址和密钥。";
|
||||
} else {
|
||||
statusHintEl.textContent =
|
||||
"扩展当前处于停用状态,不会自动重连。需要时点击“启用并连接”,或回到 Lime 的“连接器”页重新同步配置。";
|
||||
}
|
||||
|
||||
const latestPageInfo = status?.latestPageInfo;
|
||||
if (latestPageInfo?.title || latestPageInfo?.url) {
|
||||
@@ -124,6 +163,7 @@ async function saveAndReconnect() {
|
||||
serverUrl: serverUrlEl.value.trim(),
|
||||
bridgeKey: bridgeKeyEl.value.trim(),
|
||||
profileKey: profileKeyEl.value.trim() || "default",
|
||||
enabled: true,
|
||||
reconnect: true,
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "0.98.0",
|
||||
"version": "0.99.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
||||
intervalMs: 1_000,
|
||||
launchUrl: "about:blank",
|
||||
openWindow: false,
|
||||
headless: false,
|
||||
streamMode: "both",
|
||||
};
|
||||
|
||||
@@ -29,6 +30,7 @@ Lime Browser Runtime Smoke
|
||||
--interval-ms <ms> 健康检查轮询间隔,默认 1000
|
||||
--launch-url <url> 启动浏览器会话的 URL,默认 about:blank
|
||||
--open-window 显式打开浏览器窗口
|
||||
--headless 以无界面浏览器会话执行 smoke,避免弹出空白 Chrome
|
||||
--stream-mode <mode> events | frames | both,默认 both
|
||||
-h, --help 显示帮助
|
||||
`);
|
||||
@@ -73,6 +75,10 @@ function parseArgs(argv) {
|
||||
options.openWindow = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--headless") {
|
||||
options.headless = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -179,6 +185,7 @@ async function main() {
|
||||
profile_key: profileKey,
|
||||
url: options.launchUrl,
|
||||
open_window: options.openWindow,
|
||||
headless: options.headless,
|
||||
stream_mode: options.streamMode,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -340,7 +340,11 @@ async function main() {
|
||||
|
||||
console.log(`[Smoke] 开始校验 session: ${args.sessionId}`);
|
||||
|
||||
await invoke("get_server_status");
|
||||
const diagnostics = await invoke("get_server_diagnostics");
|
||||
assert(
|
||||
diagnostics && typeof diagnostics.running === "boolean",
|
||||
"get_server_diagnostics 返回格式异常",
|
||||
);
|
||||
console.log("[Smoke] Dev Bridge 可用");
|
||||
|
||||
const runState = await waitTerminalState(
|
||||
|
||||
@@ -18,6 +18,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const LIME_SKIP_STARTUP_WINDOW_REVEAL = "LIME_SKIP_STARTUP_WINDOW_REVEAL";
|
||||
|
||||
const state = {
|
||||
child: null,
|
||||
@@ -155,7 +156,10 @@ function startHeadlessTauri() {
|
||||
state.child = spawn(npmCommand, ["run", "tauri:dev:headless"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
env: {
|
||||
...process.env,
|
||||
[LIME_SKIP_STARTUP_WINDOW_REVEAL]: "1",
|
||||
},
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
}
|
||||
@@ -367,6 +371,7 @@ async function main() {
|
||||
String(options.timeoutMs),
|
||||
"--interval-ms",
|
||||
String(options.intervalMs),
|
||||
"--headless",
|
||||
],
|
||||
"smoke:browser-runtime",
|
||||
);
|
||||
|
||||
Generated
+17
-17
@@ -5101,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5205,7 +5205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-agent"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5234,7 +5234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-browser-runtime"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -5251,7 +5251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-config"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5267,7 +5267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-core"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -5307,7 +5307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-credential"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -5342,7 +5342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-gateway"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum 0.7.9",
|
||||
@@ -5372,7 +5372,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-infra"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -5392,7 +5392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-mcp"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5424,7 +5424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-processor"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"lime-core",
|
||||
@@ -5443,7 +5443,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-providers"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -5498,7 +5498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-server-utils"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -5558,7 +5558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-services"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -5600,7 +5600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-skills"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -5618,7 +5618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-terminal"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -5645,7 +5645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lime-websocket"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/lime"
|
||||
@@ -192,7 +192,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "lime"
|
||||
version = "0.98.0"
|
||||
version = "0.99.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -26,6 +26,33 @@ fn emit_runtime_queue_event(
|
||||
emitter(event_name.to_string(), event);
|
||||
}
|
||||
|
||||
fn spawn_runtime_turn_task<C>(
|
||||
session_id: String,
|
||||
context: C,
|
||||
executor: RuntimeQueueExecutor<C>,
|
||||
emitter: RuntimeQueueEventEmitter,
|
||||
payload: Value,
|
||||
) where
|
||||
C: Clone + Send + Sync + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
let result = executor(context.clone(), payload).await;
|
||||
if let Err(error) = continue_runtime_queue_after_turn(
|
||||
session_id,
|
||||
context.clone(),
|
||||
executor.clone(),
|
||||
emitter.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn continue_runtime_queue_after_turn<C>(
|
||||
session_id: String,
|
||||
context: C,
|
||||
@@ -76,30 +103,13 @@ where
|
||||
},
|
||||
);
|
||||
|
||||
let runtime_handle = tokio::runtime::Handle::current();
|
||||
let executor_for_task = executor.clone();
|
||||
let emitter_for_task = emitter.clone();
|
||||
let context_for_task = context.clone();
|
||||
let session_id_for_task = session_id.clone();
|
||||
let payload = next_queued_turn.payload;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runtime_handle.block_on(async move {
|
||||
let result = executor_for_task(context_for_task.clone(), payload).await;
|
||||
if let Err(error) = continue_runtime_queue_after_turn(
|
||||
session_id_for_task,
|
||||
context_for_task.clone(),
|
||||
executor_for_task.clone(),
|
||||
emitter_for_task.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("[AsterAgent][Queue] 队列任务执行失败: {}", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
spawn_runtime_turn_task(
|
||||
session_id,
|
||||
context,
|
||||
executor,
|
||||
emitter,
|
||||
next_queued_turn.payload,
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -149,14 +159,8 @@ where
|
||||
.map_err(|error| format!("提交 runtime queue turn 失败: {error}"))?
|
||||
{
|
||||
RuntimeQueueSubmitResult::StartNow => {
|
||||
let result = executor(context.clone(), queued_task.payload).await;
|
||||
if let Err(error) =
|
||||
continue_runtime_queue_after_turn(session_id, context, executor.clone(), emitter)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Queue] 调度下一条排队 turn 失败: {}", error);
|
||||
}
|
||||
result
|
||||
spawn_runtime_turn_task(session_id, context, executor, emitter, queued_task.payload);
|
||||
Ok(())
|
||||
}
|
||||
RuntimeQueueSubmitResult::Busy => Err("当前会话仍在生成,无法立即开始执行".to_string()),
|
||||
RuntimeQueueSubmitResult::Enqueued {
|
||||
|
||||
@@ -66,6 +66,8 @@ pub enum TurnPromptAugmentationStageKind {
|
||||
WebSearch,
|
||||
RequestToolPolicy,
|
||||
Artifact,
|
||||
ServiceSkillLaunch,
|
||||
ServiceSkillLaunchPreload,
|
||||
Elicitation,
|
||||
TeamPreference,
|
||||
AutoContinue,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub async fn execute_action(
|
||||
"navigate" => navigate(session, &args).await,
|
||||
"click" => click(session, &args).await,
|
||||
"type" | "form_input" => type_text(session, &args).await,
|
||||
"javascript" => execute_javascript(session, &args).await,
|
||||
"scroll" | "scroll_page" => scroll(session, &args).await,
|
||||
"refresh_page" => evaluate_navigation(session, "window.location.reload()").await,
|
||||
"go_back" => evaluate_navigation(session, "window.history.back()").await,
|
||||
@@ -33,6 +34,7 @@ pub async fn execute_action(
|
||||
"page_info": page,
|
||||
}))
|
||||
}
|
||||
"find" => find_in_page(session, &args).await,
|
||||
"read_console_messages" => {
|
||||
let events =
|
||||
session.collect_console_messages(args.get("since").and_then(Value::as_u64));
|
||||
@@ -249,6 +251,53 @@ async fn scroll(session: &CdpSessionHandle, args: &Value) -> Result<Value, Strin
|
||||
Ok(json!({ "scrolled": true, "amount": signed_amount }))
|
||||
}
|
||||
|
||||
async fn execute_javascript(session: &CdpSessionHandle, args: &Value) -> Result<Value, String> {
|
||||
let expression = get_string_arg(
|
||||
args,
|
||||
&[
|
||||
"expression",
|
||||
"script",
|
||||
"code",
|
||||
"javascript",
|
||||
"text",
|
||||
"value",
|
||||
],
|
||||
)
|
||||
.ok_or_else(|| "javascript 需要 expression/script/code 参数".to_string())?;
|
||||
let return_by_value = args
|
||||
.get("return_by_value")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let timeout_ms = get_u64_arg(args, &["timeout_ms"]).unwrap_or(DEFAULT_ACTION_TIMEOUT_MS);
|
||||
let result = session
|
||||
.runtime_evaluate(expression.clone(), return_by_value, timeout_ms)
|
||||
.await?;
|
||||
Ok(json!({
|
||||
"evaluated": true,
|
||||
"expression": expression,
|
||||
"result": result,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn find_in_page(session: &CdpSessionHandle, args: &Value) -> Result<Value, String> {
|
||||
let query =
|
||||
get_string_arg(args, &["query"]).ok_or_else(|| "find 需要提供 query 参数".to_string())?;
|
||||
let page = if let Some(page_info) = capture_and_sync_page_info(session).await {
|
||||
page_info
|
||||
} else if let Some(page_info) = session.state().await.last_page_info.clone() {
|
||||
page_info
|
||||
} else {
|
||||
return Err("读取页面信息失败".to_string());
|
||||
};
|
||||
let matches = find_markdown_matches(&page.markdown, &query, 30);
|
||||
Ok(json!({
|
||||
"query": query,
|
||||
"match_count": matches.len(),
|
||||
"matches": matches,
|
||||
"page_info": page,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn evaluate_navigation(session: &CdpSessionHandle, script: &str) -> Result<Value, String> {
|
||||
let previous_url = session
|
||||
.state()
|
||||
@@ -398,6 +447,19 @@ fn normalize_url_for_navigation(url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_markdown_matches(markdown: &str, query: &str, limit: usize) -> Vec<String> {
|
||||
let normalized_query = query.trim().to_ascii_lowercase();
|
||||
if normalized_query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
markdown
|
||||
.lines()
|
||||
.filter(|line| line.to_ascii_lowercase().contains(&normalized_query))
|
||||
.take(limit)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_string_arg(args: &Value, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| {
|
||||
args.get(*key)
|
||||
@@ -456,4 +518,24 @@ mod tests {
|
||||
Some("https://www.36kr.com/newsflashes"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_markdown_matches_should_match_case_insensitively_and_limit_results() {
|
||||
let markdown = [
|
||||
"# GitHub",
|
||||
"AI Agent Starter",
|
||||
"agent framework",
|
||||
"AGENT runtime",
|
||||
"plain text",
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
assert_eq!(
|
||||
find_markdown_matches(&markdown, "agent", 2),
|
||||
vec![
|
||||
"AI Agent Starter".to_string(),
|
||||
"agent framework".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +655,7 @@ fn current_workspace_preferences_schema_version() -> u8 {
|
||||
}
|
||||
|
||||
fn default_enabled_themes() -> Vec<String> {
|
||||
vec!["social-media".to_string()]
|
||||
vec![]
|
||||
}
|
||||
|
||||
impl Default for ContentCreatorConfig {
|
||||
@@ -770,7 +770,9 @@ const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = &[
|
||||
"memory",
|
||||
];
|
||||
|
||||
const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "api-server", "provider-pool"];
|
||||
const REMOVED_NAV_ITEM_IDS: &[&str] = &["api-server"];
|
||||
|
||||
const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "provider-pool"];
|
||||
|
||||
const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[
|
||||
&["home-general", "claw", "video", "image-gen"],
|
||||
@@ -2185,10 +2187,34 @@ impl Default for Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn normalize_local_server_surface(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
|
||||
if self.server.host != default_host() {
|
||||
self.server.host = default_host();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if self.remote_management.allow_remote {
|
||||
self.remote_management.allow_remote = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn normalize_workspace_preferences(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
let current_version = current_workspace_preferences_schema_version();
|
||||
|
||||
let original_nav_len = self.navigation.enabled_items.len();
|
||||
self.navigation
|
||||
.enabled_items
|
||||
.retain(|item| !REMOVED_NAV_ITEM_IDS.contains(&item.as_str()));
|
||||
if self.navigation.enabled_items.len() != original_nav_len {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if self.content_creator.schema_version < current_version {
|
||||
if self.content_creator.enabled_themes.is_empty()
|
||||
|| has_same_members(
|
||||
@@ -2744,10 +2770,7 @@ mod unit_tests {
|
||||
assert_eq!(config.crash_reporting.sample_rate, 1.0);
|
||||
assert!(!config.crash_reporting.send_pii);
|
||||
assert_eq!(config.content_creator.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.content_creator.enabled_themes,
|
||||
vec!["social-media".to_string()]
|
||||
);
|
||||
assert_eq!(config.content_creator.enabled_themes, Vec::<String>::new());
|
||||
assert_eq!(config.navigation.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.navigation.enabled_items,
|
||||
@@ -2853,10 +2876,7 @@ mod unit_tests {
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(config.content_creator.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.content_creator.enabled_themes,
|
||||
vec!["social-media".to_string()]
|
||||
);
|
||||
assert_eq!(config.content_creator.enabled_themes, Vec::<String>::new());
|
||||
assert_eq!(config.navigation.schema_version, 1);
|
||||
assert_eq!(
|
||||
config.navigation.enabled_items,
|
||||
@@ -2908,6 +2928,42 @@ mod unit_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_workspace_preferences_removes_api_server_entry_even_on_current_schema() {
|
||||
let mut config = Config::default();
|
||||
config.navigation.enabled_items = vec![
|
||||
"home-general".to_string(),
|
||||
"claw".to_string(),
|
||||
REMOVED_NAV_ITEM_IDS[0].to_string(),
|
||||
"resources".to_string(),
|
||||
];
|
||||
|
||||
let changed = config.normalize_workspace_preferences();
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(
|
||||
config.navigation.enabled_items,
|
||||
vec![
|
||||
"home-general".to_string(),
|
||||
"claw".to_string(),
|
||||
"resources".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_local_server_surface_forces_loopback_and_disables_remote_management() {
|
||||
let mut config = Config::default();
|
||||
config.server.host = "192.168.1.9".to_string();
|
||||
config.remote_management.allow_remote = true;
|
||||
|
||||
let changed = config.normalize_local_server_surface();
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(config.server.host, "127.0.0.1");
|
||||
assert!(!config.remote_management.allow_remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_pool_config_default() {
|
||||
let pool = CredentialPoolConfig::default();
|
||||
|
||||
@@ -70,13 +70,15 @@ impl ConfigManager {
|
||||
///
|
||||
/// 如果文件不存在,返回默认配置
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let config = if path.exists() {
|
||||
let mut config = if path.exists() {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigError::ReadError(e.to_string()))?;
|
||||
Self::parse_yaml(&content)?
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
config.normalize_local_server_surface();
|
||||
config.normalize_workspace_preferences();
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
@@ -110,7 +112,7 @@ impl ConfigManager {
|
||||
let backup_path = path.with_extension("yaml.backup");
|
||||
let _ = std::fs::copy(path, backup_path);
|
||||
}
|
||||
let yaml = Self::to_yaml(&self.config)?;
|
||||
let yaml = Self::to_yaml(&normalized_config_for_persistence(&self.config))?;
|
||||
std::fs::write(path, yaml).map_err(|e| ConfigError::WriteError(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -119,6 +121,8 @@ impl ConfigManager {
|
||||
let content = std::fs::read_to_string(&self.config_path)
|
||||
.map_err(|e| ConfigError::ReadError(e.to_string()))?;
|
||||
self.config = Self::parse_yaml(&content)?;
|
||||
self.config.normalize_local_server_surface();
|
||||
self.config.normalize_workspace_preferences();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -239,6 +243,13 @@ impl ConfigManager {
|
||||
|
||||
use super::types::{LoggingConfig, RetrySettings, ServerConfig};
|
||||
|
||||
fn normalized_config_for_persistence(config: &Config) -> Config {
|
||||
let mut normalized = config.clone();
|
||||
normalized.normalize_local_server_surface();
|
||||
normalized.normalize_workspace_preferences();
|
||||
normalized
|
||||
}
|
||||
|
||||
impl Default for ConfigManager {
|
||||
fn default() -> Self {
|
||||
Self::new(Self::default_config_path())
|
||||
@@ -665,6 +676,9 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(&yaml_path)?;
|
||||
let mut config: Config = serde_yaml::from_str(&content)?;
|
||||
let mut should_save = config.normalize_workspace_preferences();
|
||||
if config.normalize_local_server_surface() {
|
||||
should_save = true;
|
||||
}
|
||||
// 如果配置中使用默认 API Key,生成强随机 Key 并保存
|
||||
if is_default_api_key(&config.server.api_key) {
|
||||
let new_key = generate_secure_api_key();
|
||||
@@ -685,6 +699,9 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(&json_path)?;
|
||||
let mut config: Config = serde_json::from_str(&content)?;
|
||||
let mut should_save = config.normalize_workspace_preferences();
|
||||
if config.normalize_local_server_surface() {
|
||||
should_save = true;
|
||||
}
|
||||
// 如果配置中使用默认 API Key,生成强随机 Key 并保存
|
||||
if is_default_api_key(&config.server.api_key) {
|
||||
let new_key = generate_secure_api_key();
|
||||
@@ -714,21 +731,24 @@ pub fn load_config() -> Result<Config, Box<dyn std::error::Error>> {
|
||||
|
||||
/// 保存配置(同时写入 YAML 与 JSON,兼容旧版)
|
||||
pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let normalized = normalized_config_for_persistence(config);
|
||||
|
||||
// 主配置优先写入 YAML
|
||||
save_config_yaml(config)?;
|
||||
save_config_yaml(&normalized)?;
|
||||
|
||||
// 兼容旧版 JSON 配置
|
||||
let path = json_config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
let content = serde_json::to_string_pretty(&normalized)?;
|
||||
std::fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 保存配置为 YAML 格式
|
||||
pub fn save_config_yaml(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let normalized = normalized_config_for_persistence(config);
|
||||
let path = ConfigManager::default_config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -737,7 +757,7 @@ pub fn save_config_yaml(config: &Config) -> Result<(), Box<dyn std::error::Error
|
||||
let backup_path = path.with_extension("yaml.backup");
|
||||
let _ = std::fs::copy(&path, &backup_path);
|
||||
}
|
||||
let content = serde_yaml::to_string(config)?;
|
||||
let content = serde_yaml::to_string(&normalized)?;
|
||||
std::fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ pub mod prompt_model;
|
||||
pub mod provider_model;
|
||||
pub mod provider_pool_model;
|
||||
pub mod provider_type;
|
||||
pub mod route_model;
|
||||
pub mod skill_model;
|
||||
pub mod vertex_model;
|
||||
|
||||
@@ -44,7 +43,7 @@ pub use skill_model::{
|
||||
SkillStandardCompliance, SkillState, SkillStates, BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
COVER_GENERATE_SKILL_DIRECTORY, DEFAULT_LIME_SKILL_DIRECTORIES, IMAGE_GENERATE_SKILL_DIRECTORY,
|
||||
LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
|
||||
VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
SITE_SEARCH_SKILL_DIRECTORY, SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
};
|
||||
pub use vertex_model::{VertexApiKeyEntry, VertexModelAlias};
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! 路由模型
|
||||
//!
|
||||
//! 用于多供应商路由功能的数据结构定义。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 单个路由信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteInfo {
|
||||
/// 路由选择器 (provider 类型或凭证名称)
|
||||
pub selector: String,
|
||||
/// Provider 类型
|
||||
pub provider_type: String,
|
||||
/// 关联的凭证数量
|
||||
pub credential_count: usize,
|
||||
/// 可用的端点列表
|
||||
pub endpoints: Vec<RouteEndpoint>,
|
||||
/// 标签 (如 "突破限制", "官方API/三方")
|
||||
pub tags: Vec<String>,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 路由端点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteEndpoint {
|
||||
/// 端点路径
|
||||
pub path: String,
|
||||
/// 协议类型
|
||||
pub protocol: String, // "openai" 或 "claude"
|
||||
/// 完整 URL
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// 路由列表响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteListResponse {
|
||||
/// 服务器基础 URL
|
||||
pub base_url: String,
|
||||
/// 默认 Provider
|
||||
pub default_provider: String,
|
||||
/// 所有可用路由
|
||||
pub routes: Vec<RouteInfo>,
|
||||
}
|
||||
|
||||
/// curl 示例
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurlExample {
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// curl 命令
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl RouteInfo {
|
||||
/// 创建新的路由信息
|
||||
pub fn new(selector: String, provider_type: String) -> Self {
|
||||
Self {
|
||||
selector,
|
||||
provider_type,
|
||||
credential_count: 0,
|
||||
endpoints: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加端点
|
||||
pub fn add_endpoint(&mut self, base_url: &str, protocol: &str) {
|
||||
let path = match protocol {
|
||||
"claude" => format!("/{}/v1/messages", self.selector),
|
||||
"openai" => format!("/{}/v1/chat/completions", self.selector),
|
||||
_ => return,
|
||||
};
|
||||
let url = format!("{base_url}{path}");
|
||||
self.endpoints.push(RouteEndpoint {
|
||||
path,
|
||||
protocol: protocol.to_string(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成 curl 示例
|
||||
pub fn generate_curl_examples(&self, api_key: &str) -> Vec<CurlExample> {
|
||||
let mut examples = Vec::new();
|
||||
|
||||
for endpoint in &self.endpoints {
|
||||
let (_model, body) = match endpoint.protocol.as_str() {
|
||||
"claude" => {
|
||||
let model = match self.provider_type.as_str() {
|
||||
"kiro" | "claude" => "claude-sonnet-4-5",
|
||||
"gemini" => "gemini-2.5-flash",
|
||||
"qwen" => "qwen3-coder-plus",
|
||||
"openai" => "gpt-4",
|
||||
_ => "claude-sonnet-4-5",
|
||||
};
|
||||
(
|
||||
model,
|
||||
format!(
|
||||
r#"{{
|
||||
"model": "{model}",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{{"role": "user", "content": "Hello!"}}]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
}
|
||||
"openai" => {
|
||||
let model = match self.provider_type.as_str() {
|
||||
"kiro" | "claude" => "claude-sonnet-4-5",
|
||||
"gemini" => "gemini-2.5-flash",
|
||||
"qwen" => "qwen3-coder-plus",
|
||||
"openai" => "gpt-4",
|
||||
_ => "claude-sonnet-4-5",
|
||||
};
|
||||
(
|
||||
model,
|
||||
format!(
|
||||
r#"{{
|
||||
"model": "{model}",
|
||||
"messages": [{{"role": "user", "content": "Hello!"}}]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let command = format!(
|
||||
r#"curl {} \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer {}" \
|
||||
-d '{}'"#,
|
||||
endpoint.url, api_key, body
|
||||
);
|
||||
|
||||
examples.push(CurlExample {
|
||||
description: format!("{} 协议", endpoint.protocol.to_uppercase()),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
examples
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,11 @@ pub const IMAGE_GENERATE_SKILL_DIRECTORY: &str = "image_generate";
|
||||
pub const LIBRARY_SKILL_DIRECTORY: &str = "library";
|
||||
pub const URL_PARSE_SKILL_DIRECTORY: &str = "url_parse";
|
||||
pub const RESEARCH_SKILL_DIRECTORY: &str = "research";
|
||||
pub const SITE_SEARCH_SKILL_DIRECTORY: &str = "site_search";
|
||||
pub const TYPESETTING_SKILL_DIRECTORY: &str = "typesetting";
|
||||
pub const SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY: &str = "social_post_with_cover";
|
||||
|
||||
pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 10] = [
|
||||
pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 11] = [
|
||||
VIDEO_GENERATE_SKILL_DIRECTORY,
|
||||
BROADCAST_GENERATE_SKILL_DIRECTORY,
|
||||
COVER_GENERATE_SKILL_DIRECTORY,
|
||||
@@ -41,6 +42,7 @@ pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 10] = [
|
||||
LIBRARY_SKILL_DIRECTORY,
|
||||
URL_PARSE_SKILL_DIRECTORY,
|
||||
RESEARCH_SKILL_DIRECTORY,
|
||||
SITE_SEARCH_SKILL_DIRECTORY,
|
||||
TYPESETTING_SKILL_DIRECTORY,
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY,
|
||||
];
|
||||
@@ -559,6 +561,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_lime_skill_directories_include_embedded_defaults() {
|
||||
assert!(is_default_lime_skill(VIDEO_GENERATE_SKILL_DIRECTORY));
|
||||
assert!(is_default_lime_skill(SITE_SEARCH_SKILL_DIRECTORY));
|
||||
assert!(is_default_lime_skill(
|
||||
SOCIAL_POST_WITH_COVER_SKILL_DIRECTORY
|
||||
));
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! 路由系统模块
|
||||
//!
|
||||
//! 支持动态路由注册、命名空间路由解析、模型映射。
|
||||
//! 支持当前主链路由规则、Amp CLI 路由与模型映射。
|
||||
//!
|
||||
//! 路由格式:
|
||||
//! - `/{provider-name}/v1/messages` - Provider 命名空间路由
|
||||
//! - `/{selector}/v1/messages` - 凭证选择器路由(向后兼容)
|
||||
//! - `/v1/messages` - 默认路由
|
||||
//! - `/v1/chat/completions` - 默认 OpenAI 协议路由
|
||||
//! - `/api/provider/{provider}/v1/*` - Amp CLI 路由
|
||||
//!
|
||||
//! 模型映射:
|
||||
@@ -17,8 +16,6 @@
|
||||
mod amp_router;
|
||||
mod hint_router;
|
||||
mod mapper;
|
||||
mod provider_router;
|
||||
mod route_registry;
|
||||
mod rules;
|
||||
|
||||
pub use amp_router::AmpRouter;
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//! Provider 路由解析器
|
||||
//!
|
||||
//! 解析请求路径,确定目标 Provider 和协议。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::route_registry::{RegisteredRoute, RouteRegistry, RouteType};
|
||||
use crate::models::provider_pool_model::PoolProviderType;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 路由解析结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteMatch {
|
||||
/// 匹配的路由
|
||||
pub route: RegisteredRoute,
|
||||
/// 请求的协议 (openai 或 claude)
|
||||
pub protocol: String,
|
||||
/// 请求的端点 (messages, chat/completions 等)
|
||||
pub endpoint: String,
|
||||
/// 路由选择器(从路径中提取)
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
impl RouteMatch {
|
||||
/// 是否是 Claude 协议
|
||||
pub fn is_claude_protocol(&self) -> bool {
|
||||
self.protocol == "claude" || self.endpoint == "messages"
|
||||
}
|
||||
|
||||
/// 是否是 OpenAI 协议
|
||||
pub fn is_openai_protocol(&self) -> bool {
|
||||
self.protocol == "openai" || self.endpoint == "chat/completions"
|
||||
}
|
||||
|
||||
/// 获取 Provider 类型
|
||||
pub fn provider_type(&self) -> Option<PoolProviderType> {
|
||||
self.route
|
||||
.provider_type
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 路由器
|
||||
pub struct ProviderRouter {
|
||||
/// 路由注册表
|
||||
registry: Arc<RwLock<RouteRegistry>>,
|
||||
/// 默认 Provider 引用(从配置动态获取)
|
||||
default_provider_ref: Arc<RwLock<String>>,
|
||||
}
|
||||
|
||||
impl ProviderRouter {
|
||||
/// 创建新的路由器
|
||||
pub fn new(registry: Arc<RwLock<RouteRegistry>>) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
default_provider_ref: Arc::new(RwLock::new("kiro".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带有默认 Provider 引用的路由器
|
||||
pub fn with_default_provider(
|
||||
registry: Arc<RwLock<RouteRegistry>>,
|
||||
default_provider_ref: Arc<RwLock<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
default_provider_ref,
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析请求路径
|
||||
///
|
||||
/// 支持的路径格式:
|
||||
/// - `/v1/messages` - 默认路由,Claude 协议
|
||||
/// - `/v1/chat/completions` - 默认路由,OpenAI 协议
|
||||
/// - `/{selector}/v1/messages` - 选择器路由,Claude 协议
|
||||
/// - `/{selector}/v1/chat/completions` - 选择器路由,OpenAI 协议
|
||||
pub async fn resolve(&self, path: &str) -> Option<RouteMatch> {
|
||||
let path = path.trim_start_matches('/');
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
match parts.as_slice() {
|
||||
// /v1/messages
|
||||
["v1", "messages"] => {
|
||||
let registry = self.registry.read().await;
|
||||
let default_provider = self.default_provider_ref.read().await.clone();
|
||||
let route = registry
|
||||
.enabled_routes()
|
||||
.into_iter()
|
||||
.find(|r| r.route_type == RouteType::Default)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| RegisteredRoute::default_route(&default_provider));
|
||||
|
||||
Some(RouteMatch {
|
||||
route,
|
||||
protocol: "claude".to_string(),
|
||||
endpoint: "messages".to_string(),
|
||||
selector: None,
|
||||
})
|
||||
}
|
||||
// /v1/chat/completions
|
||||
["v1", "chat", "completions"] => {
|
||||
let registry = self.registry.read().await;
|
||||
let default_provider = self.default_provider_ref.read().await.clone();
|
||||
let route = registry
|
||||
.enabled_routes()
|
||||
.into_iter()
|
||||
.find(|r| r.route_type == RouteType::Default)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| RegisteredRoute::default_route(&default_provider));
|
||||
|
||||
Some(RouteMatch {
|
||||
route,
|
||||
protocol: "openai".to_string(),
|
||||
endpoint: "chat/completions".to_string(),
|
||||
selector: None,
|
||||
})
|
||||
}
|
||||
// /{selector}/v1/messages
|
||||
[selector, "v1", "messages"] => {
|
||||
let registry = self.registry.read().await;
|
||||
let route = registry.find_by_selector(selector).cloned();
|
||||
|
||||
// 安全修复:未注册的 selector 不创建临时路由,直接返回 None
|
||||
let route = match route {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(RouteMatch {
|
||||
route,
|
||||
protocol: "claude".to_string(),
|
||||
endpoint: "messages".to_string(),
|
||||
selector: Some(selector.to_string()),
|
||||
})
|
||||
}
|
||||
// /{selector}/v1/chat/completions
|
||||
[selector, "v1", "chat", "completions"] => {
|
||||
let registry = self.registry.read().await;
|
||||
let route = registry.find_by_selector(selector).cloned();
|
||||
|
||||
// 安全修复:未注册的 selector 不创建临时路由,直接返回 None
|
||||
let route = match route {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::warn!("[ROUTER] 未注册的 selector: {},拒绝请求", selector);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(RouteMatch {
|
||||
route,
|
||||
protocol: "openai".to_string(),
|
||||
endpoint: "chat/completions".to_string(),
|
||||
selector: Some(selector.to_string()),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册凭证路由
|
||||
pub async fn register_credential(
|
||||
&self,
|
||||
provider_type: &str,
|
||||
credential_uuid: &str,
|
||||
credential_name: Option<&str>,
|
||||
) {
|
||||
let mut registry = self.registry.write().await;
|
||||
|
||||
// 注册命名空间路由
|
||||
let route =
|
||||
RegisteredRoute::provider_namespace(provider_type, credential_uuid, credential_name);
|
||||
registry.register(route);
|
||||
|
||||
// 注册 UUID 选择器路由
|
||||
let selector_route = RegisteredRoute::credential_selector(credential_uuid, provider_type);
|
||||
registry.register(selector_route);
|
||||
}
|
||||
|
||||
/// 注销凭证路由
|
||||
pub async fn unregister_credential(&self, credential_uuid: &str) {
|
||||
let mut registry = self.registry.write().await;
|
||||
registry.unregister(credential_uuid);
|
||||
}
|
||||
|
||||
/// 获取所有注册的路由
|
||||
pub async fn list_routes(&self) -> Vec<RegisteredRoute> {
|
||||
let registry = self.registry.read().await;
|
||||
registry.all_routes().to_vec()
|
||||
}
|
||||
|
||||
/// 生成路由 URL
|
||||
pub fn generate_url(&self, base_url: &str, route: &RegisteredRoute, protocol: &str) -> String {
|
||||
let endpoint = if protocol == "claude" {
|
||||
"messages"
|
||||
} else {
|
||||
"chat/completions"
|
||||
};
|
||||
|
||||
if route.route_type == RouteType::Default {
|
||||
format!("{base_url}/v1/{endpoint}")
|
||||
} else {
|
||||
let selector = route
|
||||
.credential_name
|
||||
.as_ref()
|
||||
.or(route.credential_uuid.as_ref())
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("unknown");
|
||||
format!("{base_url}/{selector}/v1/{endpoint}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_default_routes() {
|
||||
let registry = Arc::new(RwLock::new(RouteRegistry::new()));
|
||||
let router = ProviderRouter::new(registry);
|
||||
|
||||
let match1 = router.resolve("/v1/messages").await.unwrap();
|
||||
assert_eq!(match1.protocol, "claude");
|
||||
assert_eq!(match1.endpoint, "messages");
|
||||
assert!(match1.selector.is_none());
|
||||
|
||||
let match2 = router.resolve("/v1/chat/completions").await.unwrap();
|
||||
assert_eq!(match2.protocol, "openai");
|
||||
assert_eq!(match2.endpoint, "chat/completions");
|
||||
assert!(match2.selector.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_selector_routes() {
|
||||
let registry = Arc::new(RwLock::new(RouteRegistry::new()));
|
||||
let router = ProviderRouter::new(registry);
|
||||
|
||||
// 安全修复后,未注册的 selector 会返回 None,需要先注册
|
||||
router
|
||||
.register_credential("kiro", "uuid-selector-test", Some("my-kiro"))
|
||||
.await;
|
||||
|
||||
let match1 = router.resolve("/my-kiro/v1/messages").await.unwrap();
|
||||
assert_eq!(match1.protocol, "claude");
|
||||
assert_eq!(match1.selector, Some("my-kiro".to_string()));
|
||||
|
||||
let match2 = router
|
||||
.resolve("/my-kiro/v1/chat/completions")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(match2.protocol, "openai");
|
||||
assert_eq!(match2.selector, Some("my-kiro".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_and_resolve() {
|
||||
let registry = Arc::new(RwLock::new(RouteRegistry::new()));
|
||||
let router = ProviderRouter::new(registry);
|
||||
|
||||
router
|
||||
.register_credential("kiro", "uuid-123", Some("my-kiro-account"))
|
||||
.await;
|
||||
|
||||
let match1 = router
|
||||
.resolve("/my-kiro-account/v1/messages")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(match1.route.credential_uuid, Some("uuid-123".to_string()));
|
||||
assert_eq!(match1.route.provider_type, Some("kiro".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! 路由注册表
|
||||
//!
|
||||
//! 管理所有注册的路由,支持动态添加和查询。
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 路由类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RouteType {
|
||||
/// Provider 命名空间路由 (如 /claude-kiro-oauth/v1/messages)
|
||||
ProviderNamespace,
|
||||
/// 凭证选择器路由 (如 /{uuid}/v1/messages)
|
||||
CredentialSelector,
|
||||
/// 默认路由 (如 /v1/messages)
|
||||
Default,
|
||||
}
|
||||
|
||||
/// 注册的路由信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisteredRoute {
|
||||
/// 路由路径模式
|
||||
pub path_pattern: String,
|
||||
/// 路由类型
|
||||
pub route_type: RouteType,
|
||||
/// Provider 类型(如果适用)
|
||||
pub provider_type: Option<String>,
|
||||
/// 凭证 UUID(如果适用)
|
||||
pub credential_uuid: Option<String>,
|
||||
/// 凭证名称(如果适用)
|
||||
pub credential_name: Option<String>,
|
||||
/// 支持的协议
|
||||
pub protocols: Vec<String>,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
/// 优先级(数字越小优先级越高)
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
impl RegisteredRoute {
|
||||
/// 创建 Provider 命名空间路由
|
||||
pub fn provider_namespace(
|
||||
provider_type: &str,
|
||||
credential_uuid: &str,
|
||||
credential_name: Option<&str>,
|
||||
) -> Self {
|
||||
let path_pattern = format!(
|
||||
"/{}/v1/{{endpoint}}",
|
||||
Self::generate_route_name(provider_type, credential_name)
|
||||
);
|
||||
Self {
|
||||
path_pattern,
|
||||
route_type: RouteType::ProviderNamespace,
|
||||
provider_type: Some(provider_type.to_string()),
|
||||
credential_uuid: Some(credential_uuid.to_string()),
|
||||
credential_name: credential_name.map(|s| s.to_string()),
|
||||
protocols: vec!["openai".to_string(), "claude".to_string()],
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建凭证选择器路由
|
||||
pub fn credential_selector(credential_uuid: &str, provider_type: &str) -> Self {
|
||||
Self {
|
||||
path_pattern: format!("/{credential_uuid}/v1/{{endpoint}}"),
|
||||
route_type: RouteType::CredentialSelector,
|
||||
provider_type: Some(provider_type.to_string()),
|
||||
credential_uuid: Some(credential_uuid.to_string()),
|
||||
credential_name: None,
|
||||
protocols: vec!["openai".to_string(), "claude".to_string()],
|
||||
enabled: true,
|
||||
priority: 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建默认路由
|
||||
pub fn default_route(provider_type: &str) -> Self {
|
||||
Self {
|
||||
path_pattern: "/v1/{endpoint}".to_string(),
|
||||
route_type: RouteType::Default,
|
||||
provider_type: Some(provider_type.to_string()),
|
||||
credential_uuid: None,
|
||||
credential_name: None,
|
||||
protocols: vec!["openai".to_string(), "claude".to_string()],
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成路由名称
|
||||
fn generate_route_name(provider_type: &str, credential_name: Option<&str>) -> String {
|
||||
if let Some(name) = credential_name {
|
||||
// 将名称转换为 URL 友好格式
|
||||
name.to_lowercase()
|
||||
.replace(' ', "-")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.collect()
|
||||
} else {
|
||||
provider_type.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取路由的显示名称
|
||||
pub fn display_name(&self) -> String {
|
||||
if let Some(name) = &self.credential_name {
|
||||
name.clone()
|
||||
} else if let Some(provider) = &self.provider_type {
|
||||
provider.clone()
|
||||
} else {
|
||||
"default".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由注册表
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RouteRegistry {
|
||||
/// 所有注册的路由
|
||||
routes: Vec<RegisteredRoute>,
|
||||
/// 路由名称到索引的映射
|
||||
name_index: HashMap<String, usize>,
|
||||
/// UUID 到索引的映射
|
||||
uuid_index: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl RouteRegistry {
|
||||
/// 创建新的路由注册表
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 注册路由
|
||||
pub fn register(&mut self, route: RegisteredRoute) {
|
||||
let index = self.routes.len();
|
||||
|
||||
// 更新索引
|
||||
if let Some(name) = &route.credential_name {
|
||||
self.name_index.insert(name.to_lowercase(), index);
|
||||
}
|
||||
if let Some(uuid) = &route.credential_uuid {
|
||||
self.uuid_index.insert(uuid.clone(), index);
|
||||
}
|
||||
|
||||
self.routes.push(route);
|
||||
|
||||
// 按优先级排序
|
||||
self.sort_by_priority();
|
||||
}
|
||||
|
||||
/// 注销路由
|
||||
pub fn unregister(&mut self, credential_uuid: &str) {
|
||||
if let Some(&index) = self.uuid_index.get(credential_uuid) {
|
||||
if index < self.routes.len() {
|
||||
let route = &self.routes[index];
|
||||
if let Some(name) = &route.credential_name {
|
||||
self.name_index.remove(&name.to_lowercase());
|
||||
}
|
||||
self.uuid_index.remove(credential_uuid);
|
||||
self.routes.remove(index);
|
||||
|
||||
// 重建索引
|
||||
self.rebuild_indices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按名称查找路由
|
||||
pub fn find_by_name(&self, name: &str) -> Option<&RegisteredRoute> {
|
||||
self.name_index
|
||||
.get(&name.to_lowercase())
|
||||
.and_then(|&idx| self.routes.get(idx))
|
||||
}
|
||||
|
||||
/// 按 UUID 查找路由
|
||||
pub fn find_by_uuid(&self, uuid: &str) -> Option<&RegisteredRoute> {
|
||||
self.uuid_index
|
||||
.get(uuid)
|
||||
.and_then(|&idx| self.routes.get(idx))
|
||||
}
|
||||
|
||||
/// 按选择器查找路由(名称或 UUID)
|
||||
pub fn find_by_selector(&self, selector: &str) -> Option<&RegisteredRoute> {
|
||||
self.find_by_name(selector)
|
||||
.or_else(|| self.find_by_uuid(selector))
|
||||
}
|
||||
|
||||
/// 获取所有路由
|
||||
pub fn all_routes(&self) -> &[RegisteredRoute] {
|
||||
&self.routes
|
||||
}
|
||||
|
||||
/// 获取启用的路由
|
||||
pub fn enabled_routes(&self) -> Vec<&RegisteredRoute> {
|
||||
self.routes.iter().filter(|r| r.enabled).collect()
|
||||
}
|
||||
|
||||
/// 按 Provider 类型获取路由
|
||||
pub fn routes_by_provider(&self, provider_type: &str) -> Vec<&RegisteredRoute> {
|
||||
self.routes
|
||||
.iter()
|
||||
.filter(|r| r.provider_type.as_deref() == Some(provider_type))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 清空所有路由
|
||||
pub fn clear(&mut self) {
|
||||
self.routes.clear();
|
||||
self.name_index.clear();
|
||||
self.uuid_index.clear();
|
||||
}
|
||||
|
||||
/// 按优先级排序
|
||||
fn sort_by_priority(&mut self) {
|
||||
self.routes.sort_by_key(|r| r.priority);
|
||||
self.rebuild_indices();
|
||||
}
|
||||
|
||||
/// 重建索引
|
||||
fn rebuild_indices(&mut self) {
|
||||
self.name_index.clear();
|
||||
self.uuid_index.clear();
|
||||
|
||||
for (index, route) in self.routes.iter().enumerate() {
|
||||
if let Some(name) = &route.credential_name {
|
||||
self.name_index.insert(name.to_lowercase(), index);
|
||||
}
|
||||
if let Some(uuid) = &route.credential_uuid {
|
||||
self.uuid_index.insert(uuid.clone(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_route_registration() {
|
||||
let mut registry = RouteRegistry::new();
|
||||
|
||||
let route =
|
||||
RegisteredRoute::provider_namespace("kiro", "uuid-123", Some("my-kiro-account"));
|
||||
registry.register(route);
|
||||
|
||||
assert!(registry.find_by_name("my-kiro-account").is_some());
|
||||
assert!(registry.find_by_uuid("uuid-123").is_some());
|
||||
assert!(registry.find_by_selector("my-kiro-account").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_unregistration() {
|
||||
let mut registry = RouteRegistry::new();
|
||||
|
||||
let route =
|
||||
RegisteredRoute::provider_namespace("kiro", "uuid-123", Some("my-kiro-account"));
|
||||
registry.register(route);
|
||||
|
||||
registry.unregister("uuid-123");
|
||||
|
||||
assert!(registry.find_by_name("my-kiro-account").is_none());
|
||||
assert!(registry.find_by_uuid("uuid-123").is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,6 @@
|
||||
//!
|
||||
//! 提供托盘菜单文本的格式化函数
|
||||
|
||||
/// 格式化服务器状态文本
|
||||
///
|
||||
/// # 示例输出
|
||||
/// - "● Lime 网关:运行中 (127.0.0.1:8080)"
|
||||
/// - "○ Lime 网关:未启动"
|
||||
pub fn format_server_status(running: bool, host: &str, port: u16) -> String {
|
||||
if running {
|
||||
format!("● Lime 网关:运行中 ({host}:{port})")
|
||||
} else {
|
||||
"○ Lime 网关:未启动".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化凭证状态文本
|
||||
///
|
||||
/// # 示例输出
|
||||
@@ -57,14 +44,6 @@ pub fn format_current_model_status(
|
||||
format!("◉ Claw 模型:{normalized_provider} / {normalized_model} · {normalized_theme}")
|
||||
}
|
||||
|
||||
/// 格式化 API 地址
|
||||
///
|
||||
/// # 示例输出
|
||||
/// - "http://127.0.0.1:8080"
|
||||
pub fn format_api_address(host: &str, port: u16) -> String {
|
||||
format!("http://{host}:{port}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -75,20 +54,10 @@ mod tests {
|
||||
/// **Validates: Requirements 2.2, 2.3, 2.4**
|
||||
#[test]
|
||||
fn prop_menu_content_formatting(
|
||||
host in "[a-z0-9.]{1,50}",
|
||||
port in 1024u16..65535,
|
||||
available in 0usize..100,
|
||||
total in 0usize..100,
|
||||
requests in 0u64..1000000
|
||||
) {
|
||||
let running_status = format_server_status(true, &host, port);
|
||||
prop_assert!(running_status.contains(&host), "运行状态应包含 host");
|
||||
prop_assert!(running_status.contains(&port.to_string()), "运行状态应包含 port");
|
||||
prop_assert!(running_status.contains("运行中"), "运行状态应包含'运行中'");
|
||||
|
||||
let stopped_status = format_server_status(false, &host, port);
|
||||
prop_assert!(stopped_status.contains("已停止"), "停止状态应包含'已停止'");
|
||||
|
||||
let cred_status = format_credential_status(available, total);
|
||||
prop_assert!(cred_status.contains(&available.to_string()), "凭证状态应包含可用数");
|
||||
prop_assert!(cred_status.contains(&total.to_string()), "凭证状态应包含总数");
|
||||
@@ -97,29 +66,6 @@ mod tests {
|
||||
prop_assert!(req_status.contains(&requests.to_string()), "请求统计应包含请求次数");
|
||||
}
|
||||
|
||||
/// **Feature: system-tray, Property 4: API 地址格式化正确性**
|
||||
/// **Validates: Requirements 4.2**
|
||||
#[test]
|
||||
fn prop_api_address_formatting(
|
||||
host in "[a-z0-9.]{1,50}",
|
||||
port in 1024u16..65535
|
||||
) {
|
||||
let address = format_api_address(&host, port);
|
||||
let expected = format!("http://{host}:{port}");
|
||||
prop_assert_eq!(address, expected, "API 地址格式应为 http://{{host}}:{{port}}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_server_status_running() {
|
||||
let status = format_server_status(true, "127.0.0.1", 8080);
|
||||
assert_eq!(status, "● Lime 网关:运行中 (127.0.0.1:8080)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_server_status_stopped() {
|
||||
let status = format_server_status(false, "127.0.0.1", 8080);
|
||||
assert_eq!(status, "○ Lime 网关:未启动");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -151,10 +97,4 @@ mod tests {
|
||||
let status = format_current_model_status("", "", None);
|
||||
assert_eq!(status, "◉ Claw 模型:未同步");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_api_address() {
|
||||
let address = format_api_address("127.0.0.1", 8080);
|
||||
assert_eq!(address, "http://127.0.0.1:8080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 托盘菜单元数据模块
|
||||
//!
|
||||
//! 提供与 Tauri 无关的菜单 ID、地址解析和菜单元信息工具函数。
|
||||
//! 提供与 Tauri 无关的菜单 ID 和菜单元信息工具函数。
|
||||
|
||||
/// 菜单项 ID 常量
|
||||
pub mod menu_ids {
|
||||
@@ -8,18 +8,12 @@ pub mod menu_ids {
|
||||
pub const CURRENT_MODEL_INFO: &str = "current_model_info";
|
||||
/// 快速切换模型
|
||||
pub const QUICK_MODEL_ROOT: &str = "quick_model_root";
|
||||
/// 状态信息
|
||||
pub const STATUS_INFO: &str = "status_info";
|
||||
/// 凭证信息
|
||||
pub const CREDENTIAL_INFO: &str = "credential_info";
|
||||
/// 请求信息
|
||||
pub const REQUEST_INFO: &str = "request_info";
|
||||
/// 分隔符 1
|
||||
pub const SEPARATOR_1: &str = "sep_1";
|
||||
/// 启动服务器
|
||||
pub const START_SERVER: &str = "start_server";
|
||||
/// 停止服务器
|
||||
pub const STOP_SERVER: &str = "stop_server";
|
||||
/// 刷新所有 Token
|
||||
pub const REFRESH_TOKENS: &str = "refresh_tokens";
|
||||
/// 健康检查
|
||||
@@ -28,8 +22,6 @@ pub mod menu_ids {
|
||||
pub const SEPARATOR_2: &str = "sep_2";
|
||||
/// 打开主窗口
|
||||
pub const OPEN_WINDOW: &str = "open_window";
|
||||
/// 复制 API 地址
|
||||
pub const COPY_API_ADDRESS: &str = "copy_api_address";
|
||||
/// 打开日志目录
|
||||
pub const OPEN_LOG_DIR: &str = "open_log_dir";
|
||||
/// 分隔符 3
|
||||
@@ -44,15 +36,11 @@ pub mod menu_ids {
|
||||
/// 获取所有必需的菜单项 ID 列表
|
||||
pub fn all_required_ids() -> Vec<&'static str> {
|
||||
vec![
|
||||
STATUS_INFO,
|
||||
CREDENTIAL_INFO,
|
||||
REQUEST_INFO,
|
||||
START_SERVER,
|
||||
STOP_SERVER,
|
||||
REFRESH_TOKENS,
|
||||
HEALTH_CHECK,
|
||||
OPEN_WINDOW,
|
||||
COPY_API_ADDRESS,
|
||||
OPEN_LOG_DIR,
|
||||
AUTO_START,
|
||||
QUIT,
|
||||
@@ -60,26 +48,6 @@ pub mod menu_ids {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析服务器地址字符串为 host 和 port
|
||||
///
|
||||
/// 支持格式:
|
||||
/// - "host:port" -> (host, port)
|
||||
/// - "host" -> (host, 8080)
|
||||
/// - "" -> ("127.0.0.1", 8080)
|
||||
pub fn parse_server_address(address: &str) -> (String, u16) {
|
||||
if address.is_empty() {
|
||||
return ("127.0.0.1".to_string(), 8080);
|
||||
}
|
||||
|
||||
if let Some((host, port_str)) = address.rsplit_once(':') {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
return (host.to_string(), port);
|
||||
}
|
||||
}
|
||||
|
||||
(address.to_string(), 8080)
|
||||
}
|
||||
|
||||
/// 获取菜单中包含的所有菜单项 ID
|
||||
pub fn get_menu_item_ids() -> Vec<&'static str> {
|
||||
menu_ids::all_required_ids()
|
||||
@@ -130,61 +98,26 @@ mod tests {
|
||||
fn test_menu_ids_completeness() {
|
||||
let ids = menu_ids::all_required_ids();
|
||||
|
||||
assert!(ids.contains(&menu_ids::STATUS_INFO), "应包含 STATUS_INFO");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::CREDENTIAL_INFO),
|
||||
"应包含 CREDENTIAL_INFO"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::REQUEST_INFO), "应包含 REQUEST_INFO");
|
||||
assert!(ids.contains(&menu_ids::START_SERVER), "应包含 START_SERVER");
|
||||
assert!(ids.contains(&menu_ids::STOP_SERVER), "应包含 STOP_SERVER");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::REFRESH_TOKENS),
|
||||
"应包含 REFRESH_TOKENS"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::HEALTH_CHECK), "应包含 HEALTH_CHECK");
|
||||
assert!(ids.contains(&menu_ids::OPEN_WINDOW), "应包含 OPEN_WINDOW");
|
||||
assert!(
|
||||
ids.contains(&menu_ids::COPY_API_ADDRESS),
|
||||
"应包含 COPY_API_ADDRESS"
|
||||
);
|
||||
assert!(ids.contains(&menu_ids::OPEN_LOG_DIR), "应包含 OPEN_LOG_DIR");
|
||||
assert!(ids.contains(&menu_ids::AUTO_START), "应包含 AUTO_START");
|
||||
assert!(ids.contains(&menu_ids::QUIT), "应包含 QUIT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_with_port() {
|
||||
let (host, port) = parse_server_address("127.0.0.1:8080");
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_without_port() {
|
||||
let (host, port) = parse_server_address("localhost");
|
||||
assert_eq!(host, "localhost");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_empty() {
|
||||
let (host, port) = parse_server_address("");
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_server_address_ipv6() {
|
||||
let (host, port) = parse_server_address("[::1]:9000");
|
||||
assert_eq!(host, "[::1]");
|
||||
assert_eq!(port, 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_menu_item_ids() {
|
||||
let ids = get_menu_item_ids();
|
||||
assert_eq!(ids.len(), 12, "应有 12 个必需的菜单项");
|
||||
assert_eq!(ids.len(), 8, "应有 8 个必需的菜单项");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -208,7 +141,6 @@ mod tests {
|
||||
proptest! {
|
||||
#[test]
|
||||
fn prop_menu_ids_completeness(
|
||||
_server_running in any::<bool>(),
|
||||
_available in 0usize..100,
|
||||
_total in 0usize..100,
|
||||
_requests in 0u64..1000000,
|
||||
@@ -217,15 +149,11 @@ mod tests {
|
||||
let ids = menu_ids::all_required_ids();
|
||||
|
||||
let required = vec![
|
||||
menu_ids::STATUS_INFO,
|
||||
menu_ids::CREDENTIAL_INFO,
|
||||
menu_ids::REQUEST_INFO,
|
||||
menu_ids::START_SERVER,
|
||||
menu_ids::STOP_SERVER,
|
||||
menu_ids::REFRESH_TOKENS,
|
||||
menu_ids::HEALTH_CHECK,
|
||||
menu_ids::OPEN_WINDOW,
|
||||
menu_ids::COPY_API_ADDRESS,
|
||||
menu_ids::OPEN_LOG_DIR,
|
||||
menu_ids::AUTO_START,
|
||||
menu_ids::QUIT,
|
||||
|
||||
@@ -8,13 +8,13 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrayIconStatus {
|
||||
/// 正常运行(绿色)- 服务器运行且凭证健康
|
||||
/// 正常运行(绿色)- 已同步且所有账号可用
|
||||
Running,
|
||||
/// 警告状态(黄色)- 有凭证即将过期或余额不足
|
||||
/// 警告状态(黄色)- 部分账号不可用或存在告警
|
||||
Warning,
|
||||
/// 错误状态(红色)- 服务器停止或所有凭证无效
|
||||
/// 错误状态(红色)- 当前没有可用账号
|
||||
Error,
|
||||
/// 停止状态(灰色)- 服务器未启动
|
||||
/// 停止状态(灰色)- 托盘状态尚未同步
|
||||
#[default]
|
||||
Stopped,
|
||||
}
|
||||
@@ -82,10 +82,6 @@ pub struct TrayQuickModelGroup {
|
||||
pub struct TrayStateSnapshot {
|
||||
/// 图标状态
|
||||
pub icon_status: TrayIconStatus,
|
||||
/// 服务器是否运行
|
||||
pub server_running: bool,
|
||||
/// 服务器地址
|
||||
pub server_address: String,
|
||||
/// 可用凭证数
|
||||
pub available_credentials: usize,
|
||||
/// 总凭证数
|
||||
@@ -110,8 +106,6 @@ impl Default for TrayStateSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
icon_status: TrayIconStatus::Stopped,
|
||||
server_running: false,
|
||||
server_address: String::new(),
|
||||
available_credentials: 0,
|
||||
total_credentials: 0,
|
||||
today_requests: 0,
|
||||
@@ -125,32 +119,24 @@ impl Default for TrayStateSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据服务器状态和凭证健康状态计算托盘图标状态
|
||||
/// 根据凭证健康状态计算托盘图标状态
|
||||
///
|
||||
/// # 规则
|
||||
/// - 服务器未运行 -> Stopped
|
||||
/// - 服务器运行 + 所有凭证无效 -> Error
|
||||
/// - 服务器运行 + 有凭证警告 -> Warning
|
||||
/// - 服务器运行 + 所有凭证健康 -> Running
|
||||
pub fn calculate_icon_status(
|
||||
server_running: bool,
|
||||
credentials: &[CredentialHealth],
|
||||
) -> TrayIconStatus {
|
||||
if !server_running {
|
||||
return TrayIconStatus::Stopped;
|
||||
}
|
||||
|
||||
/// - 无账号或无可用账号 -> Error
|
||||
/// - 部分账号不可用或有告警 -> Warning
|
||||
/// - 所有账号健康 -> Running
|
||||
pub fn calculate_icon_status(credentials: &[CredentialHealth]) -> TrayIconStatus {
|
||||
if credentials.is_empty() {
|
||||
return TrayIconStatus::Error;
|
||||
}
|
||||
|
||||
let all_invalid = credentials.iter().all(|c| !c.is_valid);
|
||||
if all_invalid {
|
||||
let available = credentials.iter().filter(|c| c.is_valid).count();
|
||||
if available == 0 {
|
||||
return TrayIconStatus::Error;
|
||||
}
|
||||
|
||||
let has_warning = credentials.iter().any(|c| c.has_warning());
|
||||
if has_warning {
|
||||
if available < credentials.len() || has_warning {
|
||||
return TrayIconStatus::Warning;
|
||||
}
|
||||
|
||||
@@ -193,29 +179,23 @@ mod tests {
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3**
|
||||
#[test]
|
||||
fn prop_icon_status_mapping(
|
||||
server_running in any::<bool>(),
|
||||
credentials in prop::collection::vec(arb_credential_health(), 0..10)
|
||||
) {
|
||||
let status = calculate_icon_status(server_running, &credentials);
|
||||
|
||||
if !server_running {
|
||||
prop_assert_eq!(status, TrayIconStatus::Stopped);
|
||||
return Ok(());
|
||||
}
|
||||
let status = calculate_icon_status(&credentials);
|
||||
|
||||
if credentials.is_empty() {
|
||||
prop_assert_eq!(status, TrayIconStatus::Error);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let all_invalid = credentials.iter().all(|c| !c.is_valid);
|
||||
if all_invalid {
|
||||
let available = credentials.iter().filter(|c| c.is_valid).count();
|
||||
if available == 0 {
|
||||
prop_assert_eq!(status, TrayIconStatus::Error);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let has_warning = credentials.iter().any(|c| c.has_warning());
|
||||
if has_warning {
|
||||
if available < credentials.len() || has_warning {
|
||||
prop_assert_eq!(status, TrayIconStatus::Warning);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -251,27 +231,27 @@ mod tests {
|
||||
assert!(health2.has_warning());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_server_stopped() {
|
||||
let credentials = vec![CredentialHealth::healthy()];
|
||||
let status = calculate_icon_status(false, &credentials);
|
||||
assert_eq!(status, TrayIconStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_no_credentials() {
|
||||
let credentials = vec![];
|
||||
let status = calculate_icon_status(true, &credentials);
|
||||
let status = calculate_icon_status(&credentials);
|
||||
assert_eq!(status, TrayIconStatus::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_all_invalid() {
|
||||
let credentials = vec![CredentialHealth::invalid(), CredentialHealth::invalid()];
|
||||
let status = calculate_icon_status(true, &credentials);
|
||||
let status = calculate_icon_status(&credentials);
|
||||
assert_eq!(status, TrayIconStatus::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_partially_invalid() {
|
||||
let credentials = vec![CredentialHealth::healthy(), CredentialHealth::invalid()];
|
||||
let status = calculate_icon_status(&credentials);
|
||||
assert_eq!(status, TrayIconStatus::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_with_warning() {
|
||||
let credentials = vec![
|
||||
@@ -282,14 +262,14 @@ mod tests {
|
||||
is_low_balance: false,
|
||||
},
|
||||
];
|
||||
let status = calculate_icon_status(true, &credentials);
|
||||
let status = calculate_icon_status(&credentials);
|
||||
assert_eq!(status, TrayIconStatus::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_icon_status_running() {
|
||||
let credentials = vec![CredentialHealth::healthy(), CredentialHealth::healthy()];
|
||||
let status = calculate_icon_status(true, &credentials);
|
||||
let status = calculate_icon_status(&credentials);
|
||||
assert_eq!(status, TrayIconStatus::Running);
|
||||
}
|
||||
|
||||
|
||||
@@ -994,14 +994,14 @@ fn validate_command(
|
||||
}
|
||||
|
||||
if normalized == "run_adapter" {
|
||||
let script = payload
|
||||
let adapter_name = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("script"))
|
||||
.and_then(|value| value.get("adapter_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if script.is_none() {
|
||||
return Err("run_adapter 命令需要提供 payload.script。".to_string());
|
||||
if adapter_name.is_none() {
|
||||
return Err("run_adapter 命令需要提供 payload.adapter_name。".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,7 +1183,8 @@ mod tests {
|
||||
"run_adapter",
|
||||
&None,
|
||||
&Some(json!({
|
||||
"script": "(async () => ({ ok: true }))()",
|
||||
"adapter_name": "github/search",
|
||||
"args": {"query": "mcp"},
|
||||
})),
|
||||
)
|
||||
.is_ok());
|
||||
@@ -1466,7 +1467,8 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"script": "(async () => ({ ok: true }))()",
|
||||
"adapter_name": "github/search",
|
||||
"args": {"query": "mcp"},
|
||||
})),
|
||||
false,
|
||||
);
|
||||
@@ -1474,8 +1476,9 @@ mod tests {
|
||||
assert_eq!(payload["data"]["command"], json!("run_adapter"));
|
||||
assert_eq!(payload["data"]["target"], json!("202"));
|
||||
assert_eq!(
|
||||
payload["data"]["payload"]["script"],
|
||||
json!("(async () => ({ ok: true }))()")
|
||||
payload["data"]["payload"]["adapter_name"],
|
||||
json!("github/search")
|
||||
);
|
||||
assert_eq!(payload["data"]["payload"]["args"]["query"], json!("mcp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ use lime_core::logger::LogStore;
|
||||
use lime_core::models::anthropic::*;
|
||||
use lime_core::models::openai::*;
|
||||
use lime_core::models::provider_pool_model::CredentialData;
|
||||
use lime_core::models::route_model::{RouteInfo, RouteListResponse};
|
||||
use lime_credential::CredentialSyncService;
|
||||
use lime_infra::injection::Injector;
|
||||
use lime_processor::{RequestContext, RequestProcessor};
|
||||
@@ -281,9 +280,6 @@ pub struct ServerState {
|
||||
/// 路由器引用(用于动态更新默认 Provider)
|
||||
pub router_ref: Option<Arc<RwLock<lime_core::router::Router>>>,
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
/// 服务器运行时使用的 API key(启动时从配置复制)
|
||||
/// 用于 test_api 命令,确保测试使用的 API key 和服务器一致
|
||||
pub running_api_key: Option<String>,
|
||||
/// 服务器实际监听的 host(可能与配置不同,因为会自动切换到有效的 IP)
|
||||
pub running_host: Option<String>,
|
||||
/// 能力路由指标(能力过滤/模型回退/Provider 回退)
|
||||
@@ -332,7 +328,6 @@ impl ServerState {
|
||||
default_provider_ref,
|
||||
router_ref: None,
|
||||
shutdown_tx: None,
|
||||
running_api_key: None,
|
||||
running_host: None,
|
||||
capability_routing_metrics_store: Arc::new(
|
||||
middleware::capability_routing_metrics::CapabilityRoutingMetricsStore::new(),
|
||||
@@ -454,7 +449,6 @@ impl ServerState {
|
||||
|
||||
let port = self.config.server.port;
|
||||
let api_key = self.config.server.api_key.clone();
|
||||
let api_key_for_state = api_key.clone(); // 用于保存到 running_api_key
|
||||
let default_provider_ref = self.default_provider_ref.clone();
|
||||
|
||||
// 重新加载凭证
|
||||
@@ -577,8 +571,6 @@ impl ServerState {
|
||||
|
||||
self.running = true;
|
||||
self.start_time = Some(std::time::Instant::now());
|
||||
// 保存服务器运行时使用的 API key,用于 test_api 命令
|
||||
self.running_api_key = Some(api_key_for_state);
|
||||
// 保存服务器实际监听的 host(可能与配置不同)
|
||||
self.running_host = Some(running_host);
|
||||
Ok(())
|
||||
@@ -590,7 +582,6 @@ impl ServerState {
|
||||
}
|
||||
self.running = false;
|
||||
self.start_time = None;
|
||||
self.running_api_key = None;
|
||||
self.running_host = None;
|
||||
self.router_ref = None;
|
||||
}
|
||||
@@ -1169,7 +1160,6 @@ async fn run_server(
|
||||
.route("/cache", get(cache_diagnostics))
|
||||
.route("/stats", get(stats_diagnostics))
|
||||
.route("/v1/models", get(models))
|
||||
.route("/v1/routes", get(list_routes))
|
||||
.route("/v1/chat/completions", post(
|
||||
|State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -1201,15 +1191,6 @@ async fn run_server(
|
||||
"/lime-chrome-control/:lime_key",
|
||||
get(handlers::chrome_control_ws_upgrade),
|
||||
)
|
||||
// 多供应商路由
|
||||
.route(
|
||||
"/{selector}/v1/messages",
|
||||
post(anthropic_messages_with_selector),
|
||||
)
|
||||
.route(
|
||||
"/{selector}/v1/chat/completions",
|
||||
post(chat_completions_with_selector),
|
||||
)
|
||||
// Kiro凭证管理API路由
|
||||
.merge(kiro_api_routes)
|
||||
// 凭证 API 路由(用于 aster Agent 集成)
|
||||
@@ -1738,263 +1719,6 @@ async fn gemini_generate_content(
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有可用路由
|
||||
async fn list_routes(State(state): State<AppState>) -> impl IntoResponse {
|
||||
// 处理 base_url:检查 IP 是否有效(在当前网卡列表中或是特殊地址)
|
||||
let display_base_url = {
|
||||
// 从 base_url 中提取 host 部分
|
||||
let url_parts: Vec<&str> = state.base_url.split("://").collect();
|
||||
let host_port = if url_parts.len() > 1 {
|
||||
url_parts[1]
|
||||
} else {
|
||||
&state.base_url
|
||||
};
|
||||
let host = host_port.split(':').next().unwrap_or("localhost");
|
||||
|
||||
// 检查是否需要替换 IP
|
||||
let should_replace = if host == "0.0.0.0" || host == "127.0.0.1" || host == "localhost" {
|
||||
// 0.0.0.0 需要替换为局域网 IP,127.0.0.1 和 localhost 保持不变
|
||||
host == "0.0.0.0"
|
||||
} else {
|
||||
// 检查 IP 是否在当前网卡列表中
|
||||
if let Ok(network_info) = lime_core::network::get_network_info() {
|
||||
!network_info.all_ips.contains(&host.to_string())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if should_replace {
|
||||
// 获取局域网 IP 进行替换
|
||||
// 优先选择 192.168.x.x 或 10.x.x.x 开头的 IP(真正的局域网 IP)
|
||||
if let Ok(network_info) = lime_core::network::get_network_info() {
|
||||
let new_ip = network_info
|
||||
.all_ips
|
||||
.iter()
|
||||
.find(|ip| ip.starts_with("192.168.") || ip.starts_with("10."))
|
||||
.or(network_info.lan_ip.as_ref())
|
||||
.or_else(|| network_info.all_ips.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "localhost".to_string());
|
||||
state.base_url.replace(host, &new_ip)
|
||||
} else {
|
||||
state.base_url.replace(host, "localhost")
|
||||
}
|
||||
} else {
|
||||
state.base_url.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let routes = match &state.db {
|
||||
Some(db) => state
|
||||
.pool_service
|
||||
.get_available_routes(db, &display_base_url)
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// 获取默认 Provider
|
||||
let default_provider = state.default_provider.read().await.clone();
|
||||
|
||||
// 添加默认路由
|
||||
let mut all_routes = vec![RouteInfo {
|
||||
selector: "default".to_string(),
|
||||
provider_type: default_provider.clone(),
|
||||
credential_count: 1,
|
||||
endpoints: vec![
|
||||
lime_core::models::route_model::RouteEndpoint {
|
||||
path: "/v1/messages".to_string(),
|
||||
protocol: "claude".to_string(),
|
||||
url: format!("{display_base_url}/v1/messages"),
|
||||
},
|
||||
lime_core::models::route_model::RouteEndpoint {
|
||||
path: "/v1/chat/completions".to_string(),
|
||||
protocol: "openai".to_string(),
|
||||
url: format!("{display_base_url}/v1/chat/completions"),
|
||||
},
|
||||
],
|
||||
tags: vec!["默认".to_string()],
|
||||
enabled: true,
|
||||
}];
|
||||
all_routes.extend(routes);
|
||||
|
||||
let response = RouteListResponse {
|
||||
base_url: display_base_url,
|
||||
default_provider,
|
||||
routes: all_routes,
|
||||
};
|
||||
|
||||
Json(response)
|
||||
}
|
||||
|
||||
/// 带选择器的 Anthropic messages 处理
|
||||
async fn anthropic_messages_with_selector(
|
||||
State(state): State<AppState>,
|
||||
Path(selector): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<AnthropicMessagesRequest>,
|
||||
) -> Response {
|
||||
// 使用 Anthropic 格式的认证验证
|
||||
if let Err(e) = handlers::verify_api_key_anthropic(&headers, &state.api_key).await {
|
||||
state.logs.write().await.add(
|
||||
"warn",
|
||||
&format!("Unauthorized request to /{selector}/v1/messages"),
|
||||
);
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[REQ] POST /{}/v1/messages model={} stream={}",
|
||||
selector, request.model, request.stream
|
||||
),
|
||||
);
|
||||
|
||||
// 尝试解析凭证(不降级,指定什么就用什么)
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
// 首先尝试按名称查找
|
||||
if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) {
|
||||
Some(cred)
|
||||
}
|
||||
// 然后尝试按 UUID 查找
|
||||
else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) {
|
||||
Some(cred)
|
||||
}
|
||||
// 最后尝试按 provider 类型选择(不降级)
|
||||
else if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential(db, &selector, Some(&request.model))
|
||||
{
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match credential {
|
||||
Some(cred) => {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[ROUTE] Using credential: type={} name={:?} uuid={}",
|
||||
cred.provider_type,
|
||||
cred.name,
|
||||
&cred.uuid[..8]
|
||||
),
|
||||
);
|
||||
|
||||
// 根据凭证类型调用相应的 Provider
|
||||
// 注意:这里没有 Flow 捕获,因为是通过 selector 路由的请求
|
||||
handlers::call_provider_anthropic(&state, &cred, &request, None).await
|
||||
}
|
||||
None => {
|
||||
// 不再回退到默认 provider,直接返回错误
|
||||
state.logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[ROUTE] No available credentials for selector '{selector}', refusing to fallback"
|
||||
),
|
||||
);
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": {
|
||||
"type": "provider_unavailable",
|
||||
"message": format!("No available credentials for selector '{}'", selector)
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带选择器的 OpenAI chat completions 处理
|
||||
async fn chat_completions_with_selector(
|
||||
State(state): State<AppState>,
|
||||
Path(selector): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<ChatCompletionRequest>,
|
||||
) -> Response {
|
||||
if let Err(e) = handlers::verify_api_key(&headers, &state.api_key).await {
|
||||
state.logs.write().await.add(
|
||||
"warn",
|
||||
&format!("Unauthorized request to /{selector}/v1/chat/completions"),
|
||||
);
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[REQ] POST /{}/v1/chat/completions model={} stream={}",
|
||||
selector, request.model, request.stream
|
||||
),
|
||||
);
|
||||
|
||||
// 尝试解析凭证(不降级,指定什么就用什么)
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) {
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) {
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) =
|
||||
state
|
||||
.pool_service
|
||||
.select_credential(db, &selector, Some(&request.model))
|
||||
{
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match credential {
|
||||
Some(cred) => {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[ROUTE] Using credential: type={} name={:?} uuid={}",
|
||||
cred.provider_type,
|
||||
cred.name,
|
||||
&cred.uuid[..8]
|
||||
),
|
||||
);
|
||||
|
||||
// 注意:这里没有 Flow 捕获,因为是通过 selector 路由的请求
|
||||
handlers::call_provider_openai(&state, &cred, &request, None).await
|
||||
}
|
||||
None => {
|
||||
// 不再回退到默认 provider,直接返回错误
|
||||
state.logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[ROUTE] No available credentials for selector '{selector}', refusing to fallback"
|
||||
),
|
||||
);
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": {
|
||||
"message": format!("No available credentials for selector '{}'", selector),
|
||||
"type": "provider_unavailable",
|
||||
"code": "no_credentials"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部 Anthropic messages 处理 (使用默认 Kiro)
|
||||
/// 预留:用于内部直接调用 Kiro API
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -18,7 +18,6 @@ use lime_core::models::provider_pool_model::{
|
||||
HealthCheckResult, OAuthStatus, PoolProviderType, PoolStats, ProviderCredential,
|
||||
ProviderPoolOverview,
|
||||
};
|
||||
use lime_core::models::route_model::RouteInfo;
|
||||
use lime_providers::providers::antigravity::TokenRefreshError;
|
||||
use lime_providers::providers::kiro::KiroProvider;
|
||||
use reqwest::Client;
|
||||
@@ -159,6 +158,16 @@ impl ProviderPoolService {
|
||||
Ok(credentials.iter().map(|c| c.into()).collect())
|
||||
}
|
||||
|
||||
/// 获取指定 UUID 的凭证
|
||||
pub fn get_by_uuid(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let conn = lime_core::database::lock_db(db)?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 添加凭证
|
||||
pub fn add_credential(
|
||||
&self,
|
||||
@@ -1712,75 +1721,6 @@ impl ProviderPoolService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据名称获取凭证
|
||||
pub fn get_by_name(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
name: &str,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let conn = lime_core::database::lock_db(db)?;
|
||||
ProviderPoolDao::get_by_name(&conn, name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据 UUID 获取凭证
|
||||
pub fn get_by_uuid(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let conn = lime_core::database::lock_db(db)?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取所有可用的路由端点
|
||||
pub fn get_available_routes(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<RouteInfo>, String> {
|
||||
let conn = lime_core::database::lock_db(db)?;
|
||||
let grouped = ProviderPoolDao::get_grouped(&conn).map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
|
||||
let mut routes = Vec::new();
|
||||
|
||||
// 为每种 Provider 类型创建路由
|
||||
for (provider_type, credentials) in &grouped {
|
||||
let available: Vec<_> = credentials.iter().filter(|c| c.is_available()).collect();
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Provider 类型路由 (轮询)
|
||||
let mut route = RouteInfo::new(provider_type.to_string(), provider_type.to_string());
|
||||
route.credential_count = available.len();
|
||||
route.add_endpoint(base_url, "claude");
|
||||
route.add_endpoint(base_url, "openai");
|
||||
route.tags.push("轮询".to_string());
|
||||
routes.push(route);
|
||||
}
|
||||
|
||||
// 为每个命名凭证创建路由
|
||||
for credentials in grouped.values() {
|
||||
for cred in credentials {
|
||||
if let Some(name) = &cred.name {
|
||||
if cred.is_available() {
|
||||
let mut route =
|
||||
RouteInfo::new(name.clone(), cred.provider_type.to_string());
|
||||
route.credential_count = 1;
|
||||
route.enabled = !cred.is_disabled;
|
||||
route.add_endpoint(base_url, "claude");
|
||||
route.add_endpoint(base_url, "openai");
|
||||
route.tags.push("指定凭证".to_string());
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(routes)
|
||||
}
|
||||
|
||||
/// 获取 OAuth 凭证状态
|
||||
pub fn get_oauth_status(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: site_search
|
||||
description: 通过站点适配器检索指定站点内容(GitHub、知乎、B站、36Kr、linux.do、什么值得买、Yahoo Finance)。
|
||||
allowed-tools: lime_site_run, lime_site_list, lime_site_search, lime_site_info
|
||||
metadata:
|
||||
lime_argument_hint: 输入目标站点、查询关键词或标的、返回数量,以及是否要保存到当前内容/项目。
|
||||
lime_when_to_use: 用户明确要在某个站点检索、查看热榜、读取报价或问题列表时使用;通用联网调研优先交给 research。
|
||||
lime_version: 1.1.0
|
||||
lime_execution_mode: prompt
|
||||
lime_surface: chat
|
||||
lime_category: research
|
||||
---
|
||||
|
||||
你是 Lime 的站点检索助手。
|
||||
|
||||
## 工作目标
|
||||
|
||||
在用户已经指向具体站点时,选择合适的站点适配器,返回清晰、可追溯、可继续加工的结果。
|
||||
|
||||
## 执行规则
|
||||
|
||||
- 先判断用户是否真的指定了站点或站点语义。
|
||||
未指定站点、只是泛化联网检索时,不要硬走本技能,优先交给 `research`。
|
||||
- 不要机械执行 `lime_site_list -> lime_site_search -> lime_site_info -> lime_site_run` 全套链路。
|
||||
已知适配器时,优先直接 `lime_site_info` + `lime_site_run`。
|
||||
- 当需求不明确时:
|
||||
- 用 `lime_site_search` 按站点名、域名或能力词缩小范围
|
||||
- 必要时再用 `lime_site_list` 查看全量目录
|
||||
- 执行前优先用 `lime_site_info` 确认参数、登录要求和能力边界,不要猜字段名。
|
||||
- 对于已经进入本技能的任务,首步不要退回 `WebSearch`、`research`、`webReader` 之类通用检索/阅读工具替代执行。
|
||||
正确主链是先选 adapter,再调用 `lime_site_run`。
|
||||
- 如果用户明确要求沉淀结果,可在 `lime_site_run` 中附带 `content_id` / `project_id` / `save_title`。
|
||||
未明确要求时,默认先在对话中返回结果摘要。
|
||||
- 某些适配器依赖登录态或已连接浏览器会话。
|
||||
遇到权限受限、需要登录或工具不可用时,直接说明限制和下一步,不要伪造结果。
|
||||
|
||||
## 站点映射
|
||||
|
||||
当前内置站点适配器按业务语义可分为:
|
||||
|
||||
- GitHub:`github/search`、`github/issues`
|
||||
- 知乎:`zhihu/search`、`zhihu/hot`
|
||||
- B 站:`bilibili/search`
|
||||
- 36Kr:`36kr/newsflash`
|
||||
- linux.do:`linux-do/categories`、`linux-do/hot`
|
||||
- 什么值得买:`smzdm/search`
|
||||
- Yahoo Finance:`yahoo-finance/quote`
|
||||
|
||||
全量参数、登录提示和域名清单见 `references/adapter-catalog.md`。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 识别用户意图
|
||||
- 搜索类:仓库、问题、视频、商品
|
||||
- 榜单类:热榜、热门话题、快讯
|
||||
- 读取类:股票报价、社区分类
|
||||
2. 选择最匹配的适配器
|
||||
- 仓库搜索优先 `github/search`
|
||||
- 仓库 issue 列表优先 `github/issues`
|
||||
- 热榜/热门优先 `zhihu/hot`、`linux-do/hot`、`36kr/newsflash`
|
||||
- 股票报价优先 `yahoo-finance/quote`
|
||||
3. 用 `lime_site_info` 校验参数
|
||||
4. 用 `lime_site_run` 执行
|
||||
5. 如有必要,再补充通用背景检索或额外阅读
|
||||
6. 基于结构化结果整理结论并标注来源站点
|
||||
|
||||
## 输出格式
|
||||
|
||||
根据场景灵活组织,至少包含:
|
||||
|
||||
- 站点与适配器:本次使用了哪个站点、哪个 adapter
|
||||
- 结果摘要:找到了什么、数量大概多少
|
||||
- 关键结果:标题、链接、摘要、价格/报价/状态等关键字段
|
||||
- 限制与下一步:如需要登录、结果不完整、建议切换其他站点
|
||||
|
||||
如果工具返回了保存路径或写回结果,也要在结尾明确说明。
|
||||
@@ -0,0 +1,42 @@
|
||||
# Site Adapter Catalog
|
||||
|
||||
当前运行时事实源仍然是:
|
||||
|
||||
- `lime_site_list`
|
||||
- `lime_site_search`
|
||||
- `lime_site_info`
|
||||
|
||||
本文件只是当前内置 bundled adapters 的速查表,便于 `site_search` 快速选型。
|
||||
|
||||
## 适配器总览
|
||||
|
||||
| 适配器 | 域名 | 用途 | 必填参数 | 可选参数 | 登录要求 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `36kr/newsflash` | www.36kr.com | 采集 36Kr 快讯列表。 | 无 | limit | 无 |
|
||||
| `bilibili/search` | search.bilibili.com | 按关键词采集 B 站视频搜索结果。 | query | limit | 无 |
|
||||
| `github/issues` | github.com | 采集指定 GitHub 仓库的 issue 列表。 | repo | query, state, limit | 私有仓库或受限 issue 需要先在浏览器中登录 GitHub。 |
|
||||
| `github/search` | github.com | 按关键词采集 GitHub 仓库搜索结果。 | query | limit | 若需要访问更完整的搜索结果,请先在浏览器中登录 GitHub。 |
|
||||
| `linux-do/categories` | linux.do | 读取 linux.do 分类列表。 | 无 | limit | 请先在浏览器中登录 linux.do,再重试该命令。 |
|
||||
| `linux-do/hot` | linux.do | 读取 linux.do 热门话题。 | 无 | limit, period | 请先在浏览器中登录 linux.do,再重试该命令。 |
|
||||
| `smzdm/search` | search.smzdm.com | 按关键词采集什么值得买搜索结果。 | query | limit | 无 |
|
||||
| `yahoo-finance/quote` | finance.yahoo.com | 读取 Yahoo Finance 股票行情摘要。 | symbol | 无 | 无 |
|
||||
| `zhihu/hot` | www.zhihu.com | 采集知乎热榜问题列表。 | 无 | limit | 请先在浏览器中登录知乎,再重试该命令。 |
|
||||
| `zhihu/search` | www.zhihu.com | 按关键词采集知乎搜索结果。 | query | limit | 请先在浏览器中登录知乎,再重试该命令。 |
|
||||
|
||||
## 语义映射
|
||||
|
||||
- 仓库搜索:`github/search`
|
||||
- 仓库 issue:`github/issues`
|
||||
- 视频搜索:`bilibili/search`
|
||||
- 问题搜索:`zhihu/search`
|
||||
- 热榜/热门:`zhihu/hot`、`linux-do/hot`
|
||||
- 社区目录:`linux-do/categories`
|
||||
- 快讯流:`36kr/newsflash`
|
||||
- 商品搜索:`smzdm/search`
|
||||
- 行情摘要:`yahoo-finance/quote`
|
||||
|
||||
## 使用提醒
|
||||
|
||||
- 适配器参数以 `lime_site_info` 返回为准,不要只依赖本文件。
|
||||
- 如果用户没有指定站点,只是泛化调研,不应优先走 `site_search`,而应优先走 `research`。
|
||||
- 如果用户要求保存结果到当前内容或项目,执行 `lime_site_run` 时再补 `content_id`、`project_id`、`save_title`。
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::agent_tools::catalog::{
|
||||
tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names,
|
||||
ToolCapability, ToolLifecycle, ToolPermissionPlane, ToolSourceKind, ToolSurfaceProfile,
|
||||
WorkspaceToolSurface,
|
||||
WorkspaceToolSurface, BROWSER_RUNTIME_TOOL_PREFIX,
|
||||
};
|
||||
use crate::agent_tools::execution::{
|
||||
resolve_tool_execution_policy_resolution, ToolExecutionPolicySource,
|
||||
@@ -18,6 +18,55 @@ use lime_core::tool_calling::{
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
fn extract_harness_object(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
let metadata = request_metadata?;
|
||||
let object = metadata.as_object()?;
|
||||
if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) {
|
||||
return Some(harness);
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
fn extract_harness_nested_object<'a>(
|
||||
request_metadata: Option<&'a serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn should_lock_service_skill_launch_to_site_tools(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
let Some(launch) = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
kind == "site_adapter"
|
||||
&& extract_object_string(launch, &["adapter_name", "adapterName"]).is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeExtensionSourceKind {
|
||||
@@ -212,6 +261,8 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
persisted_policy: persisted_execution_policy.as_ref(),
|
||||
request_metadata: request_metadata.as_ref(),
|
||||
};
|
||||
let lock_service_skill_launch_to_site_tools =
|
||||
should_lock_service_skill_launch_to_site_tools(request_metadata.as_ref());
|
||||
|
||||
let mut mcp_servers = mcp_server_names
|
||||
.into_iter()
|
||||
@@ -229,6 +280,9 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
|
||||
let catalog_tools = tool_catalog_entries_for_surface(surface)
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
!lock_service_skill_launch_to_site_tools || entry.name != BROWSER_RUNTIME_TOOL_PREFIX
|
||||
})
|
||||
.map(|entry| {
|
||||
let resolution =
|
||||
resolve_tool_execution_policy_resolution(entry.name, execution_policy_input);
|
||||
@@ -251,8 +305,12 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let registry_tools =
|
||||
build_registry_inventory(®istry_definitions, &caller, execution_policy_input);
|
||||
let registry_tools = build_registry_inventory(
|
||||
®istry_definitions,
|
||||
&caller,
|
||||
execution_policy_input,
|
||||
lock_service_skill_launch_to_site_tools,
|
||||
);
|
||||
let extension_surfaces = build_extension_surface_inventory(
|
||||
&extension_configs,
|
||||
&visible_extension_tools,
|
||||
@@ -336,9 +394,14 @@ fn build_registry_inventory(
|
||||
definitions: &[ToolDefinition],
|
||||
caller: &str,
|
||||
execution_policy_input: ToolExecutionResolverInput<'_>,
|
||||
lock_service_skill_launch_to_site_tools: bool,
|
||||
) -> Vec<RuntimeRegistryToolInventoryEntry> {
|
||||
let mut result = definitions
|
||||
.iter()
|
||||
.filter(|definition| {
|
||||
!lock_service_skill_launch_to_site_tools
|
||||
|| !definition.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)
|
||||
})
|
||||
.map(|definition| {
|
||||
let metadata =
|
||||
extract_tool_surface_metadata(&definition.name, &definition.input_schema);
|
||||
@@ -930,6 +993,66 @@ mod tests {
|
||||
.any(|name| name.starts_with("mcp__lime-browser__")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_inventory_hides_browser_compat_tools_for_service_skill_launch() {
|
||||
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
surface: WorkspaceToolSurface::browser_assist(),
|
||||
caller: "assistant".to_string(),
|
||||
agent_initialized: true,
|
||||
warnings: Vec::new(),
|
||||
persisted_execution_policy: None,
|
||||
request_metadata: Some(json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github"
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent"
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
mcp_server_names: Vec::new(),
|
||||
mcp_tools: Vec::new(),
|
||||
registry_definitions: vec![
|
||||
definition(
|
||||
"mcp__lime-browser__browser_navigate",
|
||||
"browser navigate",
|
||||
json!({ "type": "object" }),
|
||||
),
|
||||
definition(
|
||||
"lime_site_run",
|
||||
"run site adapter",
|
||||
json!({ "type": "object" }),
|
||||
),
|
||||
],
|
||||
extension_configs: Vec::new(),
|
||||
visible_extension_tools: Vec::new(),
|
||||
searchable_extension_tools: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(!inventory
|
||||
.catalog_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX));
|
||||
assert!(!inventory
|
||||
.registry_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX)));
|
||||
assert!(inventory
|
||||
.catalog_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == "lime_site_run"));
|
||||
assert!(inventory
|
||||
.registry_tools
|
||||
.iter()
|
||||
.any(|entry| entry.name == "lime_site_run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_inventory_uses_effective_execution_policy_provenance() {
|
||||
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
//! API 测试和兼容性检查命令
|
||||
//! API 兼容性与模型查询命令
|
||||
//!
|
||||
//! 包含 API 测试、模型列表和兼容性检查命令。
|
||||
//! 保留面向开发者与内部工具的只读能力。
|
||||
|
||||
use crate::app::types::{AppState, LogState, ProviderType};
|
||||
use crate::commands::model_registry_cmd::ModelRegistryState;
|
||||
|
||||
/// 测试结果
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct TestResult {
|
||||
pub success: bool,
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
pub time_ms: u64,
|
||||
pub response_headers: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 模型信息
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ModelInfo {
|
||||
@@ -298,85 +288,3 @@ pub async fn get_available_models(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 测试 API
|
||||
#[tauri::command]
|
||||
pub async fn test_api(
|
||||
state: tauri::State<'_, AppState>,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Option<String>,
|
||||
auth: bool,
|
||||
) -> Result<TestResult, String> {
|
||||
let s = state.read().await;
|
||||
// 使用 status() 获取实际监听的地址(可能与配置不同)
|
||||
let status = s.status();
|
||||
let base_url = format!("http://{}:{}", status.host, status.port);
|
||||
let api_key = s
|
||||
.running_api_key
|
||||
.as_ref()
|
||||
.unwrap_or(&s.config.server.api_key);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let url = format!("{base_url}{path}");
|
||||
|
||||
tracing::info!("Testing API: {} {}", method, url);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let mut req = match method.as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
_ => return Err("Unsupported method".to_string()),
|
||||
};
|
||||
|
||||
req = req.header("Content-Type", "application/json");
|
||||
|
||||
if auth {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
|
||||
if let Some(b) = body {
|
||||
req = req.body(b);
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let response_headers = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|text| (name.as_str().to_string(), text.to_string()))
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let time_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
tracing::info!(
|
||||
"API test result: status={}, body_len={}",
|
||||
status,
|
||||
body.len()
|
||||
);
|
||||
|
||||
Ok(TestResult {
|
||||
success: (200..300).contains(&status),
|
||||
status,
|
||||
body,
|
||||
time_ms,
|
||||
response_headers,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("API test error: {}", e);
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ pub async fn save_config(
|
||||
config_manager: tauri::State<'_, GlobalConfigManagerState>,
|
||||
config: config::Config,
|
||||
) -> Result<(), String> {
|
||||
let mut config = config;
|
||||
config.normalize_local_server_surface();
|
||||
config.normalize_workspace_preferences();
|
||||
let host = config.server.host.to_lowercase();
|
||||
|
||||
tracing::info!(
|
||||
@@ -44,12 +47,6 @@ pub async fn save_config(
|
||||
);
|
||||
}
|
||||
|
||||
// 禁止开启远程管理
|
||||
if config.remote_management.allow_remote {
|
||||
tracing::warn!("[CONFIG] 安全限制:不允许开启远程管理功能");
|
||||
return Err("安全限制:不允许开启远程管理功能".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.config = config.clone();
|
||||
@@ -214,7 +211,7 @@ pub async fn set_endpoint_provider(
|
||||
|
||||
/// 根据 API Key Provider 更新环境变量
|
||||
///
|
||||
/// 当用户在 API Server 页面选择一个 API Key Provider 时调用
|
||||
/// 当用户切换本机默认 Provider 的凭证来源时调用
|
||||
/// 会更新 ~/.claude/settings.json 和 shell 配置文件中的环境变量
|
||||
#[tauri::command]
|
||||
pub async fn update_provider_env_vars(
|
||||
|
||||
@@ -1,131 +1,10 @@
|
||||
//! 服务器控制命令
|
||||
//! 服务器诊断命令
|
||||
//!
|
||||
//! 包含服务器启动、停止、状态查询等命令。
|
||||
//! 保留仅供开发者与运行时诊断使用的只读查询。
|
||||
|
||||
use crate::app::types::{AppState, LogState};
|
||||
use crate::app::TokenCacheServiceState;
|
||||
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
|
||||
use crate::app::types::AppState;
|
||||
use crate::commands::telemetry_cmd::TelemetryState;
|
||||
use crate::database;
|
||||
use chrono::{Duration, Utc};
|
||||
use lime_infra::telemetry::RequestStatus;
|
||||
use lime_server as server;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 启动服务器
|
||||
#[tauri::command]
|
||||
pub async fn start_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
db: tauri::State<'_, database::DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
token_cache: tauri::State<'_, TokenCacheServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let mut s = state.write().await;
|
||||
logs.write().await.add("info", "Starting server...");
|
||||
s.start(
|
||||
logs.inner().clone(),
|
||||
pool_service.0.clone(),
|
||||
token_cache.0.clone(),
|
||||
Some(db.inner().clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 使用 status() 获取实际使用的地址(可能已经自动切换到有效的 IP)
|
||||
let status = s.status();
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!("Server started on {}:{}", status.host, status.port),
|
||||
);
|
||||
Ok("Server started".to_string())
|
||||
}
|
||||
|
||||
/// 停止服务器
|
||||
#[tauri::command]
|
||||
pub async fn stop_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
) -> Result<String, String> {
|
||||
let mut s = state.write().await;
|
||||
s.stop().await;
|
||||
logs.write().await.add("info", "Server stopped");
|
||||
Ok("Server stopped".to_string())
|
||||
}
|
||||
|
||||
/// 获取服务器状态
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
state: tauri::State<'_, AppState>,
|
||||
db: tauri::State<'_, database::DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
telemetry_state: tauri::State<'_, TelemetryState>,
|
||||
) -> Result<server::ServerStatus, String> {
|
||||
let s = state.read().await;
|
||||
let mut status = s.status();
|
||||
|
||||
// 从遥测系统获取真实请求计数
|
||||
let stats = telemetry_state.stats.read();
|
||||
let summary = stats.summary(None);
|
||||
status.requests = summary.total_requests;
|
||||
|
||||
// 最近 1 分钟统计
|
||||
let one_minute_ago = Utc::now() - Duration::minutes(1);
|
||||
let recent_logs: Vec<_> = stats
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.filter(|log| log.timestamp >= one_minute_ago)
|
||||
.collect();
|
||||
|
||||
let total_1m = recent_logs.len() as u64;
|
||||
let error_count_1m = recent_logs
|
||||
.iter()
|
||||
.filter(|log| matches!(log.status, RequestStatus::Failed | RequestStatus::Timeout))
|
||||
.count() as u64;
|
||||
status.error_rate_1m = if total_1m == 0 {
|
||||
0.0
|
||||
} else {
|
||||
error_count_1m as f64 / total_1m as f64
|
||||
};
|
||||
|
||||
let mut latencies: Vec<u64> = recent_logs.iter().map(|log| log.duration_ms).collect();
|
||||
latencies.sort_unstable();
|
||||
status.p95_latency_ms_1m = if latencies.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let last_index = latencies.len().saturating_sub(1);
|
||||
let p95_index = (last_index * 95) / 100;
|
||||
latencies.get(p95_index).copied()
|
||||
};
|
||||
|
||||
// 使用凭证健康状态近似熔断状态:统计当前不健康的上游类型数量
|
||||
status.open_circuit_count = match pool_service.0.get_all_credential_health(db.inner()) {
|
||||
Ok(health_list) => {
|
||||
let unhealthy_provider_count = health_list
|
||||
.into_iter()
|
||||
.filter(|item| !item.is_healthy)
|
||||
.map(|item| item.provider_type)
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
u32::try_from(unhealthy_provider_count).unwrap_or(u32::MAX)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("[SERVER_STATUS] 获取凭证健康状态失败: {}", err);
|
||||
0
|
||||
}
|
||||
};
|
||||
// 使用 Retry 状态日志作为活跃请求近似值
|
||||
status.active_requests = recent_logs
|
||||
.iter()
|
||||
.filter(|log| matches!(log.status, RequestStatus::Retrying))
|
||||
.count() as u64;
|
||||
status.capability_routing = s.capability_routing_metrics_store.snapshot();
|
||||
status.response_cache = s.response_cache_store.stats();
|
||||
status.request_dedup = s.request_dedup_store.stats();
|
||||
status.idempotency = s.idempotency_store.stats();
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// 获取服务器诊断信息(对标 /stats 与 /cache 端点)
|
||||
#[tauri::command]
|
||||
|
||||
+32
-31
@@ -16,6 +16,23 @@ use super::commands as app_commands;
|
||||
use super::types::{AppState, TrayManagerState};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const SKIP_STARTUP_WINDOW_REVEAL_ENV: &str = "LIME_SKIP_STARTUP_WINDOW_REVEAL";
|
||||
|
||||
fn env_flag_enabled(key: &str) -> bool {
|
||||
matches!(
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("1") | Some("true") | Some("yes") | Some("on")
|
||||
)
|
||||
}
|
||||
|
||||
fn should_reveal_main_window_on_startup() -> bool {
|
||||
!env_flag_enabled(SKIP_STARTUP_WINDOW_REVEAL_ENV)
|
||||
}
|
||||
|
||||
fn compiled_updater_public_key() -> Option<&'static str> {
|
||||
option_env!("LIME_UPDATER_PUBLIC_KEY")
|
||||
@@ -161,8 +178,12 @@ pub fn run() {
|
||||
tracing::info!("[单实例] 收到来自新实例的参数: {:?}", args);
|
||||
|
||||
// 将窗口带到前台
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
if should_reveal_main_window_on_startup() {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&window);
|
||||
}
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
let deep_link_urls: Vec<String> = args
|
||||
@@ -266,10 +287,14 @@ pub fn run() {
|
||||
|
||||
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&main_window);
|
||||
if should_reveal_main_window_on_startup() {
|
||||
reveal_main_window(&main_window);
|
||||
} else {
|
||||
tracing::info!("[启动] 已跳过主窗口展示流程(headless smoke 模式)");
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if crate::profiling::should_open_webview_devtools() {
|
||||
if env_flag_enabled("LIME_OPEN_WEBVIEW_DEVTOOLS") {
|
||||
main_window.open_devtools();
|
||||
tracing::info!("[Profiling] 已自动打开主窗口 WebView DevTools");
|
||||
}
|
||||
@@ -756,8 +781,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
// 启动服务器(使用共享的遥测实例)
|
||||
let server_started;
|
||||
let server_address;
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
logs.write()
|
||||
@@ -783,28 +806,21 @@ pub fn run() {
|
||||
logs.write()
|
||||
.await
|
||||
.add("info", &format!("[启动] 服务器已启动: {host}:{port}"));
|
||||
server_started = true;
|
||||
server_address = format!("{host}:{port}");
|
||||
}
|
||||
Err(e) => {
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[启动] 服务器启动失败: {e}"));
|
||||
server_started = false;
|
||||
server_address = String::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新托盘状态
|
||||
// Requirements 7.1: API 服务器状态变化时更新托盘图标
|
||||
if let Some(tray_state) = app_handle.try_state::<TrayManagerState<tauri::Wry>>() {
|
||||
let tray_guard = tray_state.0.read().await;
|
||||
if let Some(tray_manager) = tray_guard.as_ref() {
|
||||
let current_state = tray_manager.get_state().await;
|
||||
let icon_status = if !server_started {
|
||||
TrayIconStatus::Stopped
|
||||
} else if total_credentials > 0 && available_credentials == 0 {
|
||||
let icon_status = if total_credentials == 0 || available_credentials == 0 {
|
||||
TrayIconStatus::Error
|
||||
} else if available_credentials < total_credentials {
|
||||
TrayIconStatus::Warning
|
||||
@@ -814,8 +830,6 @@ pub fn run() {
|
||||
|
||||
let snapshot = TrayStateSnapshot {
|
||||
icon_status,
|
||||
server_running: server_started,
|
||||
server_address,
|
||||
available_credentials,
|
||||
total_credentials,
|
||||
today_requests: current_state.today_requests,
|
||||
@@ -1038,9 +1052,6 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Server commands (from app::commands)
|
||||
app_commands::start_server,
|
||||
app_commands::stop_server,
|
||||
app_commands::get_server_status,
|
||||
app_commands::get_server_diagnostics,
|
||||
// Config commands (from app::commands)
|
||||
app_commands::get_config,
|
||||
@@ -1089,7 +1100,6 @@ pub fn run() {
|
||||
app_commands::report_frontend_crash,
|
||||
app_commands::report_frontend_debug_log,
|
||||
// API test commands (from app::commands)
|
||||
app_commands::test_api,
|
||||
app_commands::get_available_models,
|
||||
app_commands::check_api_compatibility,
|
||||
// Switch commands
|
||||
@@ -1320,9 +1330,6 @@ pub fn run() {
|
||||
// API Key Provider connection test command
|
||||
commands::api_key_provider_cmd::test_api_key_provider_connection,
|
||||
commands::api_key_provider_cmd::test_api_key_provider_chat,
|
||||
// Route commands
|
||||
commands::route_cmd::get_available_routes,
|
||||
commands::route_cmd::get_route_curl_examples,
|
||||
// Resilience config commands
|
||||
commands::resilience_cmd::get_retry_config,
|
||||
commands::resilience_cmd::update_retry_config,
|
||||
@@ -1360,12 +1367,6 @@ pub fn run() {
|
||||
// Usage commands
|
||||
commands::usage_cmd::get_kiro_usage,
|
||||
// Tray commands
|
||||
commands::tray_cmd::sync_tray_state,
|
||||
commands::tray_cmd::update_tray_server_status,
|
||||
commands::tray_cmd::update_tray_credential_status,
|
||||
commands::tray_cmd::get_tray_state,
|
||||
commands::tray_cmd::refresh_tray_menu,
|
||||
commands::tray_cmd::refresh_tray_with_stats,
|
||||
commands::tray_cmd::sync_tray_model_shortcuts,
|
||||
// Plugin commands
|
||||
commands::plugin_cmd::get_plugin_status,
|
||||
@@ -1479,8 +1480,6 @@ pub fn run() {
|
||||
commands::models_cmd::toggle_model_enabled,
|
||||
commands::models_cmd::add_provider,
|
||||
commands::models_cmd::remove_provider,
|
||||
// Network commands
|
||||
commands::network_cmd::get_network_info,
|
||||
// Orchestrator commands
|
||||
commands::orchestrator_cmd::init_orchestrator,
|
||||
commands::orchestrator_cmd::get_orchestrator_config,
|
||||
@@ -1569,9 +1568,11 @@ pub fn run() {
|
||||
commands::browser_connector_cmd::set_browser_connector_install_root_cmd,
|
||||
commands::browser_connector_cmd::set_browser_connector_enabled_cmd,
|
||||
commands::browser_connector_cmd::set_system_connector_enabled_cmd,
|
||||
commands::browser_connector_cmd::set_browser_action_capability_enabled_cmd,
|
||||
commands::browser_connector_cmd::get_browser_connector_install_status_cmd,
|
||||
commands::browser_connector_cmd::install_browser_connector_extension_cmd,
|
||||
commands::browser_connector_cmd::open_browser_extensions_page_cmd,
|
||||
commands::browser_connector_cmd::open_browser_remote_debugging_page_cmd,
|
||||
// Browser profile commands
|
||||
commands::browser_profile_cmd::list_browser_profiles_cmd,
|
||||
commands::browser_profile_cmd::save_browser_profile_cmd,
|
||||
|
||||
@@ -55,7 +55,7 @@ pub async fn agent_start_process(
|
||||
};
|
||||
|
||||
agent_state.init_agent_with_db(&db).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner()).await?;
|
||||
ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?;
|
||||
let base_url = if gateway_running {
|
||||
Some(format!("http://{host}:{port}"))
|
||||
} else {
|
||||
|
||||
@@ -149,6 +149,8 @@ const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
|
||||
const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url";
|
||||
const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】";
|
||||
const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】";
|
||||
const SERVICE_SKILL_LAUNCH_PROMPT_MARKER: &str = "【站点技能启动】";
|
||||
const SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER: &str = "【站点技能预执行结果】";
|
||||
const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】";
|
||||
const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]";
|
||||
const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]";
|
||||
@@ -264,6 +266,7 @@ mod reply_runtime;
|
||||
mod request_model_resolution;
|
||||
mod run_metadata;
|
||||
mod runtime_turn;
|
||||
mod service_skill_launch;
|
||||
mod session_runtime;
|
||||
mod subagent_runtime;
|
||||
pub(crate) mod tool_runtime;
|
||||
@@ -280,7 +283,6 @@ use self::tool_runtime::{
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
|
||||
pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action};
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_runtime::{
|
||||
build_action_resume_runtime_status, build_runtime_action_user_data,
|
||||
@@ -335,6 +337,8 @@ pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensio
|
||||
pub(crate) use prompt_context::build_team_preference_system_prompt;
|
||||
pub(crate) use prompt_context::{
|
||||
merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context,
|
||||
merge_system_prompt_with_service_skill_launch,
|
||||
merge_system_prompt_with_service_skill_launch_preload,
|
||||
merge_system_prompt_with_team_preference,
|
||||
};
|
||||
#[cfg(test)]
|
||||
@@ -360,11 +364,18 @@ pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_execut
|
||||
pub(crate) use runtime_turn::{
|
||||
resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use service_skill_launch::build_service_skill_launch_run_request;
|
||||
pub(crate) use service_skill_launch::{
|
||||
append_service_skill_launch_session_permissions, preload_service_skill_launch_execution,
|
||||
should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution,
|
||||
};
|
||||
pub(crate) use session_runtime::{
|
||||
delete_runtime_session_internal, persist_session_provider_routing,
|
||||
resolve_recent_preference_from_sources, resolve_session_provider_selector,
|
||||
resolve_session_recent_harness_context, resolve_session_recent_preferences,
|
||||
resolve_session_recent_team_selection, SessionRecentHarnessContext,
|
||||
resolve_session_recent_runtime_context, SessionRecentHarnessContext,
|
||||
SessionRecentRuntimeContext,
|
||||
};
|
||||
pub(crate) use subagent_runtime::{
|
||||
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
|
||||
|
||||
@@ -173,6 +173,328 @@ pub(crate) fn merge_system_prompt_with_elicitation_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn render_json_inline(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
let rendered = serde_json::to_string(value?).ok()?;
|
||||
let normalized = rendered.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_run_example(
|
||||
adapter_name: &str,
|
||||
args: Option<&serde_json::Value>,
|
||||
profile_key: Option<&str>,
|
||||
target_id: Option<&str>,
|
||||
content_id: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
save_title: Option<&str>,
|
||||
) -> String {
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert(
|
||||
"adapter_name".to_string(),
|
||||
serde_json::Value::String(adapter_name.to_string()),
|
||||
);
|
||||
payload.insert(
|
||||
"args".to_string(),
|
||||
args.cloned()
|
||||
.filter(|value| value.is_object())
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
);
|
||||
|
||||
if let Some(value) = profile_key.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"profile_key".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = target_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"target_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = content_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"content_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = project_id.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"project_id".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(value) = save_title.filter(|value| !value.trim().is_empty()) {
|
||||
payload.insert(
|
||||
"save_title".to_string(),
|
||||
serde_json::Value::String(value.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(payload).to_string()
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_system_prompt(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let launch = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
)?;
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
if kind != "site_adapter" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?;
|
||||
let skill_title = extract_object_string(launch, &["skill_title", "skillTitle"]);
|
||||
let save_mode = extract_object_string(launch, &["save_mode", "saveMode"])
|
||||
.unwrap_or_else(|| "project_resource".to_string());
|
||||
let save_title = extract_object_string(launch, &["save_title", "saveTitle"]);
|
||||
let content_id = extract_object_string(launch, &["content_id", "contentId"]);
|
||||
let project_id = extract_object_string(launch, &["project_id", "projectId"]);
|
||||
let args_json = render_json_inline(launch.get("args")).unwrap_or_else(|| "{}".to_string());
|
||||
let launch_readiness = launch
|
||||
.get("launch_readiness")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let readiness_status =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["status"]));
|
||||
let readiness_message =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["message"]));
|
||||
let readiness_hint = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["report_hint", "reportHint"]));
|
||||
let launch_profile_key = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]));
|
||||
let launch_target_id =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"]));
|
||||
let browser_assist =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]);
|
||||
let browser_profile_key = browser_assist
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]));
|
||||
let resolved_profile_key = launch_profile_key.or(browser_profile_key);
|
||||
let run_example = build_service_skill_launch_run_example(
|
||||
&adapter_name,
|
||||
launch.get("args"),
|
||||
resolved_profile_key.as_deref(),
|
||||
launch_target_id.as_deref(),
|
||||
content_id.as_deref(),
|
||||
project_id.as_deref(),
|
||||
save_title.as_deref(),
|
||||
);
|
||||
|
||||
let mut lines = vec![
|
||||
SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
|
||||
"- 当前回合来自站点技能启动,不要把它当成普通聊天或纯文本分析。".to_string(),
|
||||
"- 第一步优先调用 lime_site_run,不要先停留在解释、总结或泛化检索。".to_string(),
|
||||
"- 在 lime_site_run 完成前,不要先用 WebSearch、research、webReader 一类通用检索/阅读工具替代执行。".to_string(),
|
||||
"- 在 lime_site_run 完成前,不要直接调用 mcp__lime-browser__browser_navigate、mcp__lime-browser__read_page、browser_navigate、browser_run_code、Playwright code 或其他 mcp__lime-browser__* / browser_* 底层浏览器工具。".to_string(),
|
||||
"- lime_site_run 的参数必须是一个严格 JSON 对象;不要写注释、不要漏引号、不要输出半截对象,也不要把整个 JSON 再包成字符串。".to_string(),
|
||||
format!("- 当前站点适配器:{adapter_name}。"),
|
||||
format!("- 当前执行参数(JSON):{args_json}。"),
|
||||
format!("- 第一工具调用示例(lime_site_run 参数 JSON):{run_example}。"),
|
||||
format!("- 当前保存模式:{save_mode}。"),
|
||||
format!(
|
||||
"- 当前技能标题:{}。",
|
||||
skill_title.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 project_id:{}。",
|
||||
project_id.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 content_id:{}。",
|
||||
content_id.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
format!(
|
||||
"- 当前 save_title:{}。",
|
||||
save_title.unwrap_or_else(|| "未提供".to_string())
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(status) = readiness_status.as_deref() {
|
||||
lines.push(format!("- 浏览器会话检测状态:{status}。"));
|
||||
}
|
||||
if let Some(message) = readiness_message.as_deref() {
|
||||
lines.push(format!("- 浏览器会话检测说明:{message}"));
|
||||
}
|
||||
if let Some(hint) = readiness_hint.as_deref() {
|
||||
lines.push(format!("- 浏览器恢复提示:{hint}"));
|
||||
}
|
||||
if let Some(profile_key) = resolved_profile_key.as_deref() {
|
||||
lines.push(format!(
|
||||
"- 调用 lime_site_run 时必须显式透传 profile_key={profile_key}。"
|
||||
));
|
||||
}
|
||||
if let Some(target_id) = launch_target_id.as_deref() {
|
||||
lines.push(format!(
|
||||
"- 调用 lime_site_run 时必须显式透传 target_id={target_id}。"
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"- 如果工具返回 attached_session_required、no_matching_context、登录受限或权限受限,不要伪造成功结果;直接说明当前缺少可执行的浏览器上下文,并要求用户先完成连接、登录或授权。".to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- 当前任务已经明确来自技能启动,不要再让用户额外确认“是否继续执行站点技能”。".to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- 只有在 lime_site_run 完成后,为了补充背景资料或交叉验证,才允许再决定是否追加 WebSearch 或其他通用检索工具。".to_string(),
|
||||
);
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_service_skill_launch(
|
||||
base_prompt: Option<String>,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let Some(service_skill_prompt) = build_service_skill_launch_system_prompt(request_metadata)
|
||||
else {
|
||||
return base_prompt;
|
||||
};
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(service_skill_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{service_skill_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(service_skill_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
|
||||
let total_chars = value.chars().count();
|
||||
if total_chars <= max_chars {
|
||||
return value;
|
||||
}
|
||||
|
||||
let truncated = value.chars().take(max_chars).collect::<String>();
|
||||
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
|
||||
}
|
||||
|
||||
fn build_service_skill_launch_preload_prompt(
|
||||
execution: &ServiceSkillLaunchPreloadExecution,
|
||||
) -> String {
|
||||
let request_summary = serde_json::json!({
|
||||
"adapter_name": execution.request.adapter_name.clone(),
|
||||
"args": execution.request.args.clone(),
|
||||
"profile_key": execution.request.profile_key.clone(),
|
||||
"target_id": execution.request.target_id.clone(),
|
||||
"timeout_ms": execution.request.timeout_ms,
|
||||
"content_id": execution.request.content_id.clone(),
|
||||
"project_id": execution.request.project_id.clone(),
|
||||
"save_title": execution.request.save_title.clone(),
|
||||
"require_attached_session": execution.request.require_attached_session,
|
||||
"skill_title": execution.request.skill_title.clone(),
|
||||
});
|
||||
let request_json = truncate_prompt_text(
|
||||
serde_json::to_string(&request_summary).unwrap_or_else(|_| "{}".to_string()),
|
||||
4_000,
|
||||
);
|
||||
let result_summary = serde_json::json!({
|
||||
"ok": execution.result.ok,
|
||||
"adapter": execution.result.adapter.clone(),
|
||||
"profile_key": execution.result.profile_key.clone(),
|
||||
"session_id": execution.result.session_id.clone(),
|
||||
"target_id": execution.result.target_id.clone(),
|
||||
"entry_url": execution.result.entry_url.clone(),
|
||||
"source_url": execution.result.source_url.clone(),
|
||||
"data": execution.result.data.clone(),
|
||||
"error_code": execution.result.error_code.clone(),
|
||||
"error_message": execution.result.error_message.clone(),
|
||||
"auth_hint": execution.result.auth_hint.clone(),
|
||||
"report_hint": execution.result.report_hint.clone(),
|
||||
"saved_content": execution.result.saved_content.clone(),
|
||||
"saved_project_id": execution.result.saved_project_id.clone(),
|
||||
});
|
||||
let result_json = truncate_prompt_text(
|
||||
serde_json::to_string(&result_summary).unwrap_or_else(|_| "{}".to_string()),
|
||||
12_000,
|
||||
);
|
||||
let adapter_name = execution
|
||||
.adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.name.clone())
|
||||
.unwrap_or_else(|| execution.request.adapter_name.clone());
|
||||
let adapter_description = execution
|
||||
.adapter
|
||||
.as_ref()
|
||||
.map(|adapter| adapter.description.clone())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "未提供".to_string());
|
||||
let execution_requirement = if execution.result.ok {
|
||||
"- 站点技能已经在系统侧预执行成功。请直接基于下面的结构化结果完成答复,不要再次调用 lime_site_run,也不要回退到底层浏览器兼容工具。".to_string()
|
||||
} else {
|
||||
"- 站点技能已经在系统侧预执行,但执行失败。请直接根据失败结果向用户说明缺少的浏览器上下文、登录态或权限,不要伪造采集成功,也不要再次尝试调用 lime_site_run / mcp__lime-browser__* / browser_*。".to_string()
|
||||
};
|
||||
let failure_contract = match execution.result.error_code.as_deref() {
|
||||
Some("attached_session_required") => {
|
||||
"- 当前失败类型为 attached_session_required:必须明确告诉用户先连接并附着到目标站点页面,再重试当前技能。".to_string()
|
||||
}
|
||||
Some("no_matching_context") => {
|
||||
"- 当前失败类型为 no_matching_context:必须告诉用户先把浏览器切到目标站点或正确页面,再重试当前技能。".to_string()
|
||||
}
|
||||
_ => "- 如果用户追问失败原因,优先引用 error_code / error_message / report_hint / auth_hint,而不是自行编造执行细节。".to_string(),
|
||||
};
|
||||
|
||||
[
|
||||
SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER.to_string(),
|
||||
execution_requirement,
|
||||
failure_contract,
|
||||
format!("- 当前适配器:{adapter_name}。"),
|
||||
format!("- 当前适配器说明:{adapter_description}"),
|
||||
format!("- 已预执行请求(JSON):{request_json}。"),
|
||||
format!("- 已预执行结果(JSON):{result_json}。"),
|
||||
"- 除非用户明确要求“重跑一次 / 换关键词 / 换筛选条件 / 重新抓取”,否则本回合不要再次调用任何站点执行工具。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_service_skill_launch_preload(
|
||||
base_prompt: Option<String>,
|
||||
execution: Option<&ServiceSkillLaunchPreloadExecution>,
|
||||
) -> Option<String> {
|
||||
let Some(execution) = execution else {
|
||||
return base_prompt;
|
||||
};
|
||||
let preload_prompt = build_service_skill_launch_preload_prompt(execution);
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(preload_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{preload_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(preload_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_team_roles(role_items: &[serde_json::Value]) -> Vec<String> {
|
||||
role_items
|
||||
.iter()
|
||||
|
||||
@@ -327,12 +327,15 @@ async fn execute_aster_chat_request(
|
||||
}
|
||||
}
|
||||
ensure_tool_search_tool_registered(state).await?;
|
||||
let request_session_id = request.session_id.clone();
|
||||
let mcp_runtime_prepare_future = async {
|
||||
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
|
||||
(start_fail, mcp_fail)
|
||||
};
|
||||
let session_recent_runtime_context_future =
|
||||
resolve_session_recent_runtime_context(&request_session_id);
|
||||
|
||||
if let Some(resolved_provider_config) =
|
||||
resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request).await?
|
||||
{
|
||||
request.provider_config = Some(resolved_provider_config);
|
||||
}
|
||||
let should_resolve_session_recent_harness_context = extract_harness_string(
|
||||
request.metadata.as_ref(),
|
||||
&["theme", "harness_theme", "harnessTheme"],
|
||||
@@ -348,11 +351,22 @@ async fn execute_aster_chat_request(
|
||||
.is_none()
|
||||
|| extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"])
|
||||
.is_none();
|
||||
let session_recent_harness_context = if should_resolve_session_recent_harness_context {
|
||||
resolve_session_recent_harness_context(&request.session_id).await?
|
||||
} else {
|
||||
SessionRecentHarnessContext::default()
|
||||
let provider_config_future =
|
||||
resolve_runtime_request_provider_config(app, db, api_key_provider_service, &request);
|
||||
let session_recent_harness_context_future = async {
|
||||
if should_resolve_session_recent_harness_context {
|
||||
resolve_session_recent_harness_context(&request.session_id).await
|
||||
} else {
|
||||
Ok(SessionRecentHarnessContext::default())
|
||||
}
|
||||
};
|
||||
let (resolved_provider_config, session_recent_harness_context) = tokio::try_join!(
|
||||
provider_config_future,
|
||||
session_recent_harness_context_future
|
||||
)?;
|
||||
if let Some(resolved_provider_config) = resolved_provider_config {
|
||||
request.provider_config = Some(resolved_provider_config);
|
||||
}
|
||||
normalize_runtime_turn_request_metadata(
|
||||
&mut request,
|
||||
session_recent_harness_context.theme.as_deref(),
|
||||
@@ -464,16 +478,17 @@ async fn execute_aster_chat_request(
|
||||
session_state_snapshot.with_working_dir(Some(workspace_root.clone()));
|
||||
}
|
||||
|
||||
// 启动并注入 MCP extensions 到 Aster Agent
|
||||
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
|
||||
let ((start_fail, mcp_fail), session_recent_runtime_context) = tokio::join!(
|
||||
mcp_runtime_prepare_future,
|
||||
session_recent_runtime_context_future
|
||||
);
|
||||
|
||||
if start_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整",
|
||||
start_fail
|
||||
);
|
||||
}
|
||||
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
|
||||
if mcp_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具",
|
||||
@@ -481,8 +496,10 @@ async fn execute_aster_chat_request(
|
||||
);
|
||||
}
|
||||
|
||||
let session_recent_preferences = resolve_session_recent_preferences(session_id).await?;
|
||||
let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?;
|
||||
let SessionRecentRuntimeContext {
|
||||
preferences: session_recent_preferences,
|
||||
team_selection: session_recent_team_selection,
|
||||
} = session_recent_runtime_context?;
|
||||
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
|
||||
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
|
||||
let resolved_request_web_search = resolve_request_web_search_preference_from_sources(
|
||||
@@ -639,10 +656,19 @@ async fn execute_aster_chat_request(
|
||||
prompt_with_artifact.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
|
||||
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
|
||||
prompt_with_artifact,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
|
||||
prompt_with_service_skill_launch.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
|
||||
prompt_with_service_skill_launch,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::Elicitation,
|
||||
prompt_with_elicitation.clone(),
|
||||
@@ -863,6 +889,18 @@ async fn execute_aster_chat_request(
|
||||
let auto_continue_metadata = auto_continue_config.clone();
|
||||
let request_metadata = request.metadata.clone();
|
||||
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
|
||||
let service_skill_preload =
|
||||
preload_service_skill_launch_execution(db, request_metadata.as_ref())
|
||||
.await
|
||||
.map_err(|error| format!("站点技能预执行失败: {error}"))?;
|
||||
let system_prompt = merge_system_prompt_with_service_skill_launch_preload(
|
||||
system_prompt,
|
||||
service_skill_preload.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunchPreload,
|
||||
system_prompt.clone(),
|
||||
);
|
||||
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
|
||||
let run_observation = Arc::new(Mutex::new(ChatRunObservation::default()));
|
||||
let run_observation_for_finalize = run_observation.clone();
|
||||
@@ -943,6 +981,22 @@ async fn execute_aster_chat_request(
|
||||
if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) {
|
||||
run_start_metadata.insert("turn_input".to_string(), turn_input_value);
|
||||
}
|
||||
if let Some(preload) = service_skill_preload.as_ref() {
|
||||
run_start_metadata.insert(
|
||||
"service_skill_launch_preload".to_string(),
|
||||
serde_json::json!({
|
||||
"executed": true,
|
||||
"adapter_name": preload.request.adapter_name,
|
||||
"ok": preload.result.ok,
|
||||
"error_code": preload.result.error_code,
|
||||
"saved_content_id": preload
|
||||
.result
|
||||
.saved_content
|
||||
.as_ref()
|
||||
.map(|content| content.content_id.clone()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let run_start_metadata_for_finalize = run_start_metadata.clone();
|
||||
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
|
||||
db.clone(),
|
||||
@@ -2093,6 +2147,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() {
|
||||
let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目";
|
||||
let metadata = json!({
|
||||
"harness": {
|
||||
"browser_assist": {
|
||||
"enabled": true,
|
||||
"profile_key": "attached-github",
|
||||
},
|
||||
"service_skill_launch": {
|
||||
"kind": "site_adapter",
|
||||
"skill_title": "GitHub 仓库线索检索",
|
||||
"adapter_name": "github/search",
|
||||
"args": {
|
||||
"query": "AI Agent",
|
||||
"limit": 10
|
||||
},
|
||||
"save_mode": "current_content",
|
||||
"content_id": "content-1",
|
||||
"project_id": "project-1",
|
||||
"launch_readiness": {
|
||||
"status": "ready",
|
||||
"message": "已检测到 github.com 的真实浏览器页面。",
|
||||
"target_id": "tab-github"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let prompt_with_web_search =
|
||||
Some("基础系统提示\n- 如果需要可使用 WebSearch 补充信息。".to_string());
|
||||
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
|
||||
prompt_with_web_search.clone(),
|
||||
Some(&metadata),
|
||||
)
|
||||
.expect("service skill prompt");
|
||||
|
||||
let mut turn_input_builder =
|
||||
TurnInputEnvelopeBuilder::new("session-service-skill", "workspace-service-skill");
|
||||
turn_input_builder
|
||||
.set_base_system_prompt(
|
||||
TurnSystemPromptSource::Frontend,
|
||||
Some("基础系统提示".to_string()),
|
||||
)
|
||||
.set_turn_context_metadata_from_value(Some(&metadata))
|
||||
.set_effective_user_message(user_message)
|
||||
.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::WebSearch,
|
||||
prompt_with_web_search,
|
||||
)
|
||||
.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
|
||||
Some(prompt_with_service_skill_launch.clone()),
|
||||
);
|
||||
|
||||
let envelope = turn_input_builder.build();
|
||||
let diagnostics = envelope.diagnostics_snapshot();
|
||||
let final_prompt = envelope.system_prompt().expect("final prompt");
|
||||
let service_skill_stage = diagnostics
|
||||
.prompt_augmentation_stages
|
||||
.iter()
|
||||
.find(|stage| stage.stage == TurnPromptAugmentationStageKind::ServiceSkillLaunch)
|
||||
.expect("service skill stage");
|
||||
|
||||
assert_eq!(
|
||||
diagnostics.effective_user_message_len,
|
||||
user_message.chars().count()
|
||||
);
|
||||
assert!(diagnostics.has_turn_context_metadata);
|
||||
assert!(diagnostics
|
||||
.turn_context_metadata_keys
|
||||
.contains(&"harness".to_string()));
|
||||
assert!(service_skill_stage.changed);
|
||||
assert!(final_prompt.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER));
|
||||
assert!(final_prompt.contains("第一步优先调用 lime_site_run"));
|
||||
assert!(final_prompt.contains("不要先用 WebSearch、research、webReader"));
|
||||
assert!(final_prompt.contains("不要直接调用 mcp__lime-browser__browser_navigate"));
|
||||
assert!(final_prompt.contains("第一工具调用示例(lime_site_run 参数 JSON)"));
|
||||
assert!(final_prompt.contains("profile_key=attached-github"));
|
||||
assert!(final_prompt.contains("target_id=tab-github"));
|
||||
assert!(final_prompt.contains("\"adapter_name\":\"github/search\""));
|
||||
assert!(final_prompt.contains("attached_session_required、no_matching_context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() {
|
||||
let mut request = AsterChatRequest {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use crate::services::site_capability_service::{
|
||||
get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest,
|
||||
SiteAdapterDefinition, SiteAdapterRunResult,
|
||||
};
|
||||
|
||||
const SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS: &[&str] = &[
|
||||
"mcp__lime-browser__*",
|
||||
"browser_*",
|
||||
"mcp__playwright__*",
|
||||
"playwright*",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ServiceSkillLaunchSiteAdapterContext {
|
||||
pub(crate) adapter_name: String,
|
||||
pub(crate) args: serde_json::Value,
|
||||
pub(crate) profile_key: Option<String>,
|
||||
pub(crate) target_id: Option<String>,
|
||||
pub(crate) content_id: Option<String>,
|
||||
pub(crate) project_id: Option<String>,
|
||||
pub(crate) save_title: Option<String>,
|
||||
pub(crate) skill_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ServiceSkillLaunchPreloadExecution {
|
||||
pub(crate) request: RunSiteAdapterRequest,
|
||||
pub(crate) adapter: Option<SiteAdapterDefinition>,
|
||||
pub(crate) result: SiteAdapterRunResult,
|
||||
}
|
||||
|
||||
fn extract_object_string(
|
||||
object: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| object.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn normalized_optional_object(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value.and_then(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_service_skill_launch_site_adapter_context(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<ServiceSkillLaunchSiteAdapterContext> {
|
||||
let launch = extract_harness_nested_object(
|
||||
request_metadata,
|
||||
&["service_skill_launch", "serviceSkillLaunch"],
|
||||
)?;
|
||||
let kind =
|
||||
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_adapter".to_string());
|
||||
if kind != "site_adapter" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let adapter_name = extract_object_string(launch, &["adapter_name", "adapterName"])?;
|
||||
let launch_readiness = normalized_optional_object(launch.get("launch_readiness"));
|
||||
let browser_assist =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]);
|
||||
let profile_key = launch_readiness
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]))
|
||||
.or_else(|| {
|
||||
browser_assist
|
||||
.and_then(|value| extract_object_string(value, &["profile_key", "profileKey"]))
|
||||
});
|
||||
let target_id =
|
||||
launch_readiness.and_then(|value| extract_object_string(value, &["target_id", "targetId"]));
|
||||
|
||||
Some(ServiceSkillLaunchSiteAdapterContext {
|
||||
adapter_name,
|
||||
args: launch
|
||||
.get("args")
|
||||
.cloned()
|
||||
.filter(|value| value.is_object())
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
profile_key,
|
||||
target_id,
|
||||
content_id: extract_object_string(launch, &["content_id", "contentId"]),
|
||||
project_id: extract_object_string(launch, &["project_id", "projectId"]),
|
||||
save_title: extract_object_string(launch, &["save_title", "saveTitle"]),
|
||||
skill_title: extract_object_string(launch, &["skill_title", "skillTitle"]),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn should_lock_service_skill_launch_to_site_tools(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
extract_service_skill_launch_site_adapter_context(request_metadata).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn service_skill_launch_browser_deny_patterns() -> &'static [&'static str] {
|
||||
SERVICE_SKILL_LAUNCH_BROWSER_DENY_PATTERNS
|
||||
}
|
||||
|
||||
pub(crate) fn build_service_skill_launch_run_request(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<RunSiteAdapterRequest> {
|
||||
let context = extract_service_skill_launch_site_adapter_context(request_metadata)?;
|
||||
Some(RunSiteAdapterRequest {
|
||||
adapter_name: context.adapter_name,
|
||||
args: context.args,
|
||||
profile_key: context.profile_key,
|
||||
target_id: context.target_id,
|
||||
timeout_ms: None,
|
||||
content_id: context.content_id,
|
||||
project_id: context.project_id,
|
||||
save_title: context.save_title,
|
||||
require_attached_session: Some(true),
|
||||
skill_title: context.skill_title,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn preload_service_skill_launch_execution(
|
||||
db: &DbConnection,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Result<Option<ServiceSkillLaunchPreloadExecution>, String> {
|
||||
let Some(request) = build_service_skill_launch_run_request(request_metadata) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let adapter = get_site_adapter(&request.adapter_name);
|
||||
let result = run_site_adapter_with_optional_save(db, request.clone()).await;
|
||||
|
||||
Ok(Some(ServiceSkillLaunchPreloadExecution {
|
||||
request,
|
||||
adapter,
|
||||
result,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn append_service_skill_launch_session_permissions(
|
||||
permissions: &mut Vec<ToolPermission>,
|
||||
session_id: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
if !should_lock_service_skill_launch_to_site_tools(request_metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = session_id.trim();
|
||||
let conditions = if session_id.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PermissionCondition {
|
||||
condition_type: ConditionType::Session,
|
||||
field: Some("session_id".to_string()),
|
||||
operator: ConditionOperator::Equals,
|
||||
value: serde_json::json!(session_id),
|
||||
validator: None,
|
||||
description: Some("仅对当前站点技能启动回合生效".to_string()),
|
||||
}]
|
||||
};
|
||||
|
||||
for pattern in service_skill_launch_browser_deny_patterns() {
|
||||
permissions.push(ToolPermission {
|
||||
tool: (*pattern).to_string(),
|
||||
allowed: false,
|
||||
priority: 1250,
|
||||
conditions: conditions.clone(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(
|
||||
"站点技能启动回合已锁定为 site adapter 执行,禁止直接回退到底层浏览器兼容工具"
|
||||
.to_string(),
|
||||
),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user