chore: release v0.99.0

This commit is contained in:
coso
2026-03-30 14:31:05 +08:00
parent 149e63c2f7
commit ae7b3163a9
246 changed files with 26175 additions and 13109 deletions
+17 -19
View File
@@ -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`
+11 -4
View File
@@ -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,正常用户态调用默认仍保持有界面浏览器
## 相关检查脚本
+10 -4
View File
@@ -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 页面
### 话题模型恢复验证
+6 -1
View File
@@ -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`
+2 -2
View File
@@ -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 填写错误或请求头格式不正确。
+4
View File
@@ -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
+730 -5
View File
@@ -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
}
+28
View File
@@ -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')
}
}
}
+57
View File
@@ -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(`![${node.alt}](${node.src})`)
} 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(`![${node.alt}](${node.src})`)
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')
}
+65
View File
@@ -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: '' })
}
+55
View File
@@ -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)
},
}
}
+32 -12
View File
@@ -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 &amp; 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>
+167
View File
@@ -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>
+438
View File
@@ -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);
}
+4
View File
@@ -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;
+49 -9
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.98.0",
"version": "0.99.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+7
View File
@@ -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,
},
});
+5 -1
View File
@@ -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(
+6 -1
View File
@@ -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",
);
+17 -17
View File
@@ -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",
+2 -2
View File
@@ -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"
+36 -32
View File
@@ -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(),
]
);
}
}
+66 -10
View File
@@ -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();
+25 -5
View File
@@ -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(())
}
+2 -3
View File
@@ -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
));
+2 -5
View File
@@ -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());
}
}
-60
View File
@@ -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");
}
}
+2 -74
View File
@@ -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,
+27 -47
View File
@@ -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);
}
+11 -8
View File
@@ -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"));
}
}
-276
View File
@@ -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`。
+126 -3
View File
@@ -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(&registry_definitions, &caller, execution_policy_input);
let registry_tools = build_registry_inventory(
&registry_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 {
+2 -94
View File
@@ -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())
}
}
}
+4 -7
View File
@@ -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(
+3 -124
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 {
+13 -2
View File
@@ -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