docs: define local cdp association task

This commit is contained in:
QiuSW
2026-07-25 15:36:23 +08:00
parent 176e3cdad2
commit 972c637716
9 changed files with 87 additions and 25 deletions
+6 -2
View File
@@ -12,12 +12,16 @@
| BR-008 | 只允许管理工具注册且经过身份校验的 PID。 | P0 | | BR-008 | 只允许管理工具注册且经过身份校验的 PID。 | P0 |
| BR-009 | 支持本地配置持久化,绝不保存浏览器凭证。 | P1 | | BR-009 | 支持本地配置持久化,绝不保存浏览器凭证。 | P1 |
| BR-010 | 提供 CLI 或本地 API,供脚本启动、查询和关闭实例。 | P1 | | BR-010 | 提供 CLI 或本地 API,供脚本启动、查询和关闭实例。 | P1 |
| BR-011 | 可选启用本地 CDP,用于读取窗口/tab 信息;默认关闭。 | P2 | | BR-011 | Chub 启动的 Chrome/Edge 必须启用仅限 loopback 的本地 CDP;端口从设置的起始端口分配,空值默认 9666。 | P1 |
| BR-012 | 同一 user data dir 被外部浏览器占用时,只能在其 CDP 端点可验证时关联为外部实例;不得接管生命周期。 | P1 |
## 验收标准 ## 验收标准
- 两个不同 user data dir 可以并行启动,互不阻塞。 - 两个不同 user data dir 可以并行启动,互不阻塞。
- 同一 user data dir 的第二次启动被拒绝,并显示已知 PID/来源。 - 同一 user data dir 的第二次启动被拒绝,并显示已知 PID/来源。
- 新启动实例使用 `127.0.0.1` 上的远程调试端口;从默认或已保存的起始端口开始递增,并在列表显示实际关联端口。
- 外部实例只有在其 `DevToolsActivePort` 和本地 CDP 端点均通过浏览器类型校验时才显示“外部已关联”;关联后不得显示关闭、强制关闭或重启入口。
- 外部实例未启用 CDP、端点失效或权限不足时,显示“外部占用”或“未知占用”,且不得再次使用同一目录启动浏览器。
- 关闭操作不会影响其他 Chrome/Edge 实例。 - 关闭操作不会影响其他 Chrome/Edge 实例。
- 工具重启后,无法确认身份的外部进程只能报告 UNKNOWN,不得被关闭。 - 工具重启后,无法确认身份的外部进程只能报告 UNKNOWN,不得被关闭。
- 异常退出后状态在合理时间内变为 EXITED/FAILED。 - 异常退出后状态在合理时间内变为 EXITED/FAILED。
@@ -26,4 +30,4 @@
## 不做 ## 不做
全局杀 Chrome/Edge、自动登录、页面操作、验证码处理、平台 API 集成、远程控制和跨用户权限提升。 全局杀 Chrome/Edge、自动登录、页面操作、验证码处理、平台 API 集成、远程控制和跨用户权限提升;通过 CDP 管理或关闭外部实例。
+6 -3
View File
@@ -12,6 +12,8 @@ Platform Windows
├─ graceful close / force kill ├─ graceful close / force kill
├─ Job Object ├─ Job Object
└─ profile occupancy inspector └─ profile occupancy inspector
├─ loopback CDP endpoint inspector
└─ remote-debug port allocator
↓ ↓
Chrome / Edge processes Chrome / Edge processes
``` ```
@@ -20,7 +22,7 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层
## 实例身份 ## 实例身份
每个实例至少记录 `instance_id`、browser kind、executable、root PID、user data dir、profile directory、非敏感参数摘要、创建时间、状态和退出码。 每个实例至少记录 `instance_id`、browser kind、executable、root PID、user data dir、profile directory、非敏感参数摘要、关联的 loopback CDP 端口、归属(托管或外部关联)、创建时间、状态和退出码。
进程身份校验至少比较 PID 存活、executable、命令行中的规范化 `--user-data-dir` 和注册记录。无法确认身份时只能报告 UNKNOWN,不允许关闭。 进程身份校验至少比较 PID 存活、executable、命令行中的规范化 `--user-data-dir` 和注册记录。无法确认身份时只能报告 UNKNOWN,不允许关闭。
@@ -28,13 +30,14 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层
`CREATED -> STARTING -> RUNNING -> STOPPING -> EXITED`;启动失败为 `FAILED`,无法确认的外部实例为 `UNKNOWN`。 `CREATED -> STARTING -> RUNNING -> STOPPING -> EXITED`;启动失败为 `FAILED`,无法确认的外部实例为 `UNKNOWN`。
- Start:规范化路径和参数,检查 user data dir,占用检查通过后注册 pending,再启动。 - Start:规范化路径和参数,先检查 user data dir。未占用时从配置的起始端口选择可用 loopback 端口,注册 pending 后以受控的 `--remote-debugging-address=127.0.0.1` 和 `--remote-debugging-port` 启动,并以 `DevToolsActivePort` 与 `/json/version` 验证实际端点。
- 外部关联:目录被占用时只检查该目录中的 `DevToolsActivePort` 和其 loopback `/json/version`;端点与浏览器类型均匹配才报告 `EXTERNAL_ASSOCIATED`,不注册、关闭、重启或强制终止该 PID。无法验证时保持 `UNKNOWN`/占用并拒绝启动。
- Stop:校验身份,优先窗口关闭/浏览器协议,超时后经明确授权强制终止。 - Stop:校验身份,优先窗口关闭/浏览器协议,超时后经明确授权强制终止。
- Force stop:使用实例 Job Object 或已校验进程树,不按名称全局终止。 - Force stop:使用实例 Job Object 或已校验进程树,不按名称全局终止。
- Restart:旧实例退出或完成超时处理后重新执行同一配置。 - Restart:旧实例退出或完成超时处理后重新执行同一配置。
- App shutdown:取消监控,不默认终止用户浏览器。 - App shutdown:取消监控,不默认终止用户浏览器。
Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。 Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。CDP 端点只绑定 `127.0.0.1`;端口号和 `/json/version` 是诊断数据,WebSocket URL 不进入持久化、事件或普通 UI 文案。
## 风险 ## 风险
+1
View File
@@ -37,6 +37,7 @@
| ID | 任务 | 依赖 | 状态 | | ID | 任务 | 依赖 | 状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE | | T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DOING |
## Backlog ## Backlog
+5
View File
@@ -8,6 +8,7 @@
| US-004 | 用户可以优雅关闭实例;无响应时在确认后强制终止。 | P0 | | US-004 | 用户可以优雅关闭实例;无响应时在确认后强制终止。 | P0 |
| US-005 | 用户可以重启指定实例,并确认旧实例已经退出。 | P0 | | US-005 | 用户可以重启指定实例,并确认旧实例已经退出。 | P0 |
| US-006 | 用户可以通过 CLI/API 查询、启动和关闭实例。 | P1 | | US-006 | 用户可以通过 CLI/API 查询、启动和关闭实例。 | P1 |
| US-007 | 用户可以看到实例关联的本地调试端口;同目录外部浏览器已启用调试时,Chub 只关联并展示状态。 | P1 |
## 验收场景 ## 验收场景
@@ -22,3 +23,7 @@
### US-006 CLI/API ### US-006 CLI/API
CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配和权限不足必须拒绝操作并给出恢复建议。 CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配和权限不足必须拒绝操作并给出恢复建议。
### US-007 本地 CDP 与外部关联
给定未占用的 user data dir,启动时从设置中的起始端口分配一个 loopback CDP 端口,并显示实际端口。给定同目录已由外部浏览器占用,只有 `DevToolsActivePort` 与 `/json/version` 可验证时才显示“外部已关联”;否则拒绝第二次启动。无论哪种外部状态,Chub 都不提供生命周期控制。
+5 -3
View File
@@ -2,11 +2,12 @@
| ID | 页面/组件 | 交互 | 状态覆盖 | 优先级 | | ID | 页面/组件 | 交互 | 状态覆盖 | 优先级 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| IX-001 | 实例列表 | 新建、启动、刷新、查看详情 | 加载、空、运行、退出、失败 | P0 | | IX-001 | 实例列表 | 新建、启动、刷新、查看详情 | 加载、空、托管运行、外部已关联、外部占用、退出、失败 | P0 |
| IX-002 | 启动表单 | 浏览器类型、路径、user data dir、URL、参数校验 | 默认、校验错误、提交中、成功 | P0 | | IX-002 | 启动表单 | 浏览器类型、路径、user data dir、URL、参数校验 | 默认、校验错误、提交中、成功 | P0 |
| IX-003 | 实例详情 | 优雅关闭、强制终止、重启 | 运行中、关闭中、超时确认、权限错误 | P0 | | IX-003 | 实例详情 | 优雅关闭、强制终止、重启 | 运行中、关闭中、超时确认、权限错误 | P0 |
| IX-004 | 占用提示 | 查看 PID/来源并重试 | 被占用、身份未知、已释放 | P0 | | IX-004 | 占用提示 | 查看 PID/来源和关联端口并重试 | 外部已关联、被占用、身份未知、已释放 | P0 |
| IX-005 | 设置 | 默认路径、日志和数据目录 | 保存成功、路径无效、权限拒绝 | P1 | | IX-005 | 设置 | 默认路径、日志、数据目录和远程调试起始端口 | 保存成功、路径无效、端口无效、权限拒绝 | P1 |
| IX-006 | 本地 CDP 启动 | 从起始端口分配、校验端点、展示实际端口 | 分配中、已关联、端口冲突、端点失效、外部不接管 | P1 |
## 通用规则 ## 通用规则
@@ -16,3 +17,4 @@
- 关闭完成后焦点回到实例行;失败时保留表单和用户输入。 - 关闭完成后焦点回到实例行;失败时保留表单和用户输入。
- 验证 960x640、600x500、100%–200% 缩放、键盘、深色和高对比度回退。 - 验证 960x640、600x500、100%–200% 缩放、键盘、深色和高对比度回退。
- 不依赖悬停或触控手势完成关键任务。 - 不依赖悬停或触控手势完成关键任务。
- 实例列表使用外层边框、文字状态和图标区分归属;端口为空时显示“—”,外部关联时操作区不显示关闭或重启。
+5 -3
View File
@@ -4,7 +4,9 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT
## 核心类型 ## 核心类型
代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责当前已确定的 browser kind、绝对 user data dir,以及非空时的 http/https URL 校验;空 URL 表示打开浏览器默认页。 代码权威类型位于 `internal/domain/browser.go`:`BrowserKind`、`LaunchSpec`、`InstanceStatus`、`InstanceView`、`BrowserEvent` 和稳定错误码。`LaunchSpec.Normalize()` 负责 browser kind、绝对 user data dir、受控的 loopback remote debugging port,以及非空时的 http/https URL 校验;空 URL 表示打开浏览器默认页。
`LaunchSpec` 使用已分配的 `RemoteDebugPort`;端口分配不属于 UI,也不允许由 `ExtraArgs` 覆盖。`InstanceView` 返回实际关联端口、PID、占用来源和归属。外部关联只公开经 `DevToolsActivePort` 与 `/json/version` 验证的端口,不公开 CDP WebSocket URL。
## Application service ## Application service
@@ -22,7 +24,7 @@ type BrowserManager interface {
平台实现通过实例级 `StopProfile(userDataDir, force)` 执行关闭;force 只对已注册并经过身份绑定的实例生效。 平台实现通过实例级 `StopProfile(userDataDir, force)` 执行关闭;force 只对已注册并经过身份绑定的实例生效。
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。 参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。CDP 端点检测和端口分配均通过 platform port 执行,UI 只消费 DTO 结果。
## CLI 当前合约 ## CLI 当前合约
@@ -43,7 +45,7 @@ chub events
| 事件 | 触发 | 负载 | | 事件 | 触发 | 负载 |
| --- | --- | --- | | --- | --- | --- |
| `browser.started` | 新实例完成启动 | InstanceView | | `browser.started` | 新实例完成启动 | InstanceView |
| `browser.status.changed` | 状态变化 | instance_id、status、pid、error_code | | `browser.status.changed` | 状态变化 | instance_id、status、pid、remote_debug_port、ownership、error_code |
| `browser.exited` | 进程退出 | instance_id、exit_code | | `browser.exited` | 进程退出 | instance_id、exit_code |
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。 事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
+4 -3
View File
@@ -2,12 +2,13 @@
## 快照 ## 快照
- 日期:2026-07-22 - 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-301 已完成;T-201 至 T-208 已完成) - 阶段:Phase 3 真实实例操作(T-302 进行中;T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 已完成 - 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和紧凑启动/删除图标。启动通过异步回调接到 Windows 浏览器启动器,并显示启动中、运行中或失败;删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例列表固定显示实例名称、浏览器类型、用户数据目录、状态和紧凑启动/删除图标。启动通过异步回调接到 Windows 浏览器启动器,并显示启动中、运行中或失败;删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。 - 当前任务:T-302 正在为托管实例接入 loopback CDP 端口分配、同目录外部浏览器的只读关联、实例列表端口字段和设置中的远程调试起始端口;外部关联不接管进程生命周期。
- blocker:无;T-302 完成后可继续接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
## 当前目录 ## 当前目录
+43
View File
@@ -0,0 +1,43 @@
---
id: T-302
title: 本地 CDP 启动、外部关联与实例端口展示
phase: 3
deps: [T-102, T-301]
status: DOING
created: 2026-07-25
owner: codex
---
## 需求与背景
Chub 需要为新启动的 Chrome/Edge 分配仅限 loopback 的 remote debugging port;设置中的起始端口留空时使用 `9666`。实例列表需要展示实际关联端口并使用清晰的边框分组。
如果其他程序已经使用同一规范化 User Data Dir 启动浏览器,Chub 不得再次启动同目录浏览器。只有该目录中的 `DevToolsActivePort` 与 `127.0.0.1:<port>/json/version` 可验证为同类浏览器时,Chub 才能将其标为“外部已关联”;此关联不赋予关闭、强制关闭、重启或其他 CDP 页面控制权限。
## 方案与边界
- `LaunchSpec` 将 remote debugging port 作为受控领域字段;`BuildArgs` 固定生成 `--remote-debugging-address=127.0.0.1` 和 `--remote-debugging-port=<port>`,额外参数继续不得覆盖。
- 平台端从保存的起始端口开始,在最多 100 个端口范围中探测可用候选;启动后等待 `DevToolsActivePort` 并请求 loopback `/json/version`,只有验证成功才把该端口写入运行时结果。端口探测与浏览器绑定之间存在竞争时,不自动杀死或重复启动浏览器。
- 启动前先合并 Chub launcher registry 与 Windows 外部 profile inspector 结果。目录被外部占用时,只读取该目录的 `DevToolsActivePort`,不扫描全系统 Chrome/Edge 进程或端口。
- 外部端点验证失败、端口文件过期、权限不足或浏览器类型不匹配时,保守报告外部/未知占用并拒绝启动;不得借此接管外部 PID。
- Gio 设置增加带 Label 和帮助文本的“远程调试起始端口”数字输入;空值在保存时归一化为 `9666`,有效值为 `1024` 至 `65535`。
- Gio 实例列表增加“调试端口”字段及 1dp 外层边框、圆角和表头分隔;端口值为实际关联端口、检测中或 `—`。行级启动结果沿现有异步 channel 回到 UI 线程,避免阻塞 frame。
- 托管运行、外部已关联、外部占用和启动失败使用不同文字状态;外部已关联只保留“重新检测”语义,不成为可关闭/重启的托管实例。
## 验收要点
- 启动 Chrome/Edge 时实际追加 loopback remote debugging 参数,默认从 `9666` 选择;首个端口占用时尝试后续端口,并在实例列表显示已验证的实际端口。
- 同一规范化 User Data Dir 的外部 Chrome/Edge 若存在有效 `DevToolsActivePort`,启动操作不创建新进程,列表显示“外部已关联”、PID、来源和端口。
- 外部浏览器未启用 CDP 或无法验证时,不创建新进程,列表显示外部/未知占用与可恢复说明。
- 端口输入可保存、恢复、校验和取消;空值恢复为 `9666`,非法端口在字段附近显示错误。
- 实例列表外框、表头和紧凑端口列在默认窗口宽度下不挤压启动/删除图标;端口为空显示 `—`。
- 外部关联没有关闭、重启、强制终止或全局按进程名操作;CDP WebSocket URL 不进入配置、日志、事件或 UI。
- `go test ./...`、`go vet ./...`、Windows build、真实 Chrome/Edge CDP/外部关联 smoke 和既有 Windows smoke 通过。
## 执行记录
- 状态:DOING
- 变更:已建立需求、架构、API、交互、原型和任务契约;待实现。
- 验证:文档待实现验证。
- 阻塞:无。
- 残余风险:Chrome 默认用户目录可能忽略 remote debugging 开关;端口预探测存在不可消除的短暂竞争,最终以端点验证为准。
+12 -11
View File
@@ -117,6 +117,7 @@
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } .status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.status.running { color: var(--success); background: var(--success-soft); } .status.running { color: var(--success); background: var(--success-soft); }
.status.starting { color: var(--accent-strong); background: var(--accent-soft); } .status.starting { color: var(--accent-strong); background: var(--accent-soft); }
.status.associated { color: var(--warning); background: var(--warning-soft); }
.status.occupied { color: var(--warning); background: var(--warning-soft); } .status.occupied { color: var(--warning); background: var(--warning-soft); }
.status.exited { color: var(--muted); background: var(--surface-3); } .status.exited { color: var(--muted); background: var(--surface-3); }
.row-actions { display: flex; justify-content: flex-end; gap: 6px; opacity: .7; } .row-actions { display: flex; justify-content: flex-end; gap: 6px; opacity: .7; }
@@ -237,8 +238,8 @@
</section> </section>
<section class="workspace" aria-labelledby="listTitle"> <section class="workspace" aria-labelledby="listTitle">
<div class="toolbar"><div class="search"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="搜索实例、路径或 PID" aria-label="搜索实例、路径或 PID"></div><select id="browserFilter" aria-label="筛选浏览器"><option value="all">全部浏览器</option><option value="chrome">Chrome</option><option value="edge">Edge</option></select><select id="statusFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="running">运行中</option><option value="starting">启动中</option><option value="occupied">被占用</option><option value="exited">已退出</option></select><div class="toolbar-spacer"></div><button class="secondary" id="refresh">↻ 刷新状态</button></div> <div class="toolbar"><div class="search"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="搜索实例、路径、PID 或端口" aria-label="搜索实例、路径、PID 或端口"></div><select id="browserFilter" aria-label="筛选浏览器"><option value="all">全部浏览器</option><option value="chrome">Chrome</option><option value="edge">Edge</option></select><select id="statusFilter" aria-label="筛选状态"><option value="all">全部状态</option><option value="running">运行中</option><option value="starting">启动中</option><option value="associated">外部已关联</option><option value="occupied">被占用</option><option value="exited">已退出</option></select><div class="toolbar-spacer"></div><button class="secondary" id="refresh">↻ 刷新状态</button></div>
<div class="table-wrap"><table><caption id="listTitle" class="sr-only">浏览器实例列表</caption><thead><tr><th scope="col">实例名称</th><th scope="col">浏览器类型</th><th scope="col">用户数据目录</th><th scope="col">状态</th><th scope="col">启动</th></tr></thead><tbody id="instanceRows"></tbody></table><div class="empty-state" id="emptyState"><strong>没有匹配的实例</strong><span>尝试清除筛选,或新建一个浏览器实例。</span></div></div> <div class="table-wrap"><table><caption id="listTitle" class="sr-only">浏览器实例列表</caption><thead><tr><th scope="col">实例名称</th><th scope="col">浏览器类型</th><th scope="col">用户数据目录</th><th scope="col">调试端口</th><th scope="col">状态</th><th scope="col">操作</th></tr></thead><tbody id="instanceRows"></tbody></table><div class="empty-state" id="emptyState"><strong>没有匹配的实例</strong><span>尝试清除筛选,或新建一个浏览器实例。</span></div></div>
<div class="table-footer"><span id="resultCount">显示 4 个实例</span><span>最后检查:刚刚</span></div> <div class="table-footer"><span id="resultCount">显示 4 个实例</span><span>最后检查:刚刚</span></div>
</section> </section>
</section> </section>
@@ -256,7 +257,7 @@
</div> </div>
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div> <div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
</form> </form>
<aside class="settings-card"><div><h2>运行行为</h2><p>影响后续启动和应用关闭时的默认行为。</p></div><div class="setting-list"><label class="check-line"><input type="checkbox" id="closeOnExit"><span><b>退出 Chub 时关闭本工具启动的实例</b><br><small class="help">默认关闭。不会影响外部浏览器。</small></span></label><label class="check-line"><input type="checkbox" id="enableCdp"><span><b>允许本地 CDP</b><br><small class="help">仅绑定 loopback;MVP 默认关闭。</small></span></label><div class="settings-status" id="settingsStatus">设置已保存 · 最后更新:刚刚</div></div></aside> <aside class="settings-card"><div><h2>运行行为</h2><p>影响后续启动和应用关闭时的默认行为。</p></div><div class="setting-list"><label class="check-line"><input type="checkbox" id="closeOnExit"><span><b>退出 Chub 时关闭本工具启动的实例</b><br><small class="help">默认关闭。不会影响外部浏览器。</small></span></label><div class="field"><label for="remoteDebugPort">远程调试起始端口</label><input id="remoteDebugPort" type="number" min="1024" max="65535" value="9666" inputmode="numeric"><small>留空时使用 9666。启动实例仅绑定 127.0.0.1;端口被占用时顺序尝试下一个可用端口。</small></div><div class="settings-status" id="settingsStatus">设置已保存 · 最后更新:刚刚</div></div></aside>
</div> </div>
</section> </section>
</main> </main>
@@ -269,10 +270,10 @@
<script> <script>
const instances = [ const instances = [
{ id: 'inst-chrome-shop', name: 'Shopee · SG 主店', kind: 'chrome', browser: 'Chrome', status: 'running', statusText: '运行中', pid: '18420', dir: 'D:\\Chub\\profiles\\shopee-sg', started: '今天 09:42', url: 'https://seller.shopee.sg/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' }, { id: 'inst-chrome-shop', name: 'Shopee · SG 主店', kind: 'chrome', browser: 'Chrome', status: 'running', statusText: '运行中(托管)', pid: '18420', port: '9666', dir: 'D:\\Chub\\profiles\\shopee-sg', started: '今天 09:42', url: 'https://seller.shopee.sg/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
{ id: 'inst-edge-review', name: '素材审核 · Edge', kind: 'edge', browser: 'Edge', status: 'starting', statusText: '启动中', pid: '—', dir: 'D:\\Chub\\profiles\\asset-review', started: '正在启动', url: 'https://example.com/review', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' }, { id: 'inst-edge-review', name: '素材审核 · Edge', kind: 'edge', browser: 'Edge', status: 'starting', statusText: '启动中', pid: '—', port: '检测中…', dir: 'D:\\Chub\\profiles\\asset-review', started: '正在启动', url: 'https://example.com/review', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
{ id: 'inst-chrome-ph', name: 'Shopee · PH 备用', kind: 'chrome', browser: 'Chrome', status: 'occupied', statusText: '被占用', pid: '16108', dir: 'D:\\Chub\\profiles\\shopee-ph', started: '昨天 18:16', url: 'https://seller.shopee.ph/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' }, { id: 'inst-chrome-ph', name: 'Shopee · PH 备用', kind: 'chrome', browser: 'Chrome', status: 'associated', statusText: '外部已关联', pid: '16108', port: '9668', dir: 'D:\\Chub\\profiles\\shopee-ph', started: '昨天 18:16', url: 'https://seller.shopee.ph/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
{ id: 'inst-edge-test', name: '测试环境 · Edge', kind: 'edge', browser: 'Edge', status: 'exited', statusText: '已退出', pid: '—', dir: 'D:\\Chub\\profiles\\edge-test', started: '昨天 16:04', url: 'https://localhost:3000', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', exit: '退出码 0' } { id: 'inst-edge-test', name: '测试环境 · Edge', kind: 'edge', browser: 'Edge', status: 'exited', statusText: '已退出', pid: '—', port: '—', dir: 'D:\\Chub\\profiles\\edge-test', started: '昨天 16:04', url: 'https://localhost:3000', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', exit: '退出码 0' }
]; ];
let selectedId = 'inst-chrome-shop'; let selectedId = 'inst-chrome-shop';
let modalAction = 'stop'; let modalAction = 'stop';
@@ -280,14 +281,14 @@
const statusClass = (s) => s; const statusClass = (s) => s;
function filtered() { function filtered() {
const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value; const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value;
return instances.filter(x => (!q || [x.name, x.browser, x.pid, x.dir].join(' ').toLowerCase().includes(q)) && (browser === 'all' || x.kind === browser) && (status === 'all' || x.status === status)); return instances.filter(x => (!q || [x.name, x.browser, x.pid, x.port, x.dir].join(' ').toLowerCase().includes(q)) && (browser === 'all' || x.kind === browser) && (status === 'all' || x.status === status));
} }
function renderRows() { function renderRows() {
const rows = filtered(), body = $('instanceRows'); body.innerHTML = ''; const rows = filtered(), body = $('instanceRows'); body.innerHTML = '';
rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.innerHTML = ` rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.innerHTML = `
<td><div class="instance-name"><span class="browser-logo ${x.kind}" aria-hidden="true">${x.kind === 'chrome' ? 'C' : 'E'}</span><div><strong>${x.name}</strong><small>${x.id}</small></div></div></td> <td><div class="instance-name"><span class="browser-logo ${x.kind}" aria-hidden="true">${x.kind === 'chrome' ? 'C' : 'E'}</span><div><strong>${x.name}</strong><small>${x.id}</small></div></div></td>
<td>${x.browser}</td><td class="mono">${x.dir}</td><td><span class="status ${statusClass(x.status)}">${x.statusText}</span></td> <td>${x.browser}</td><td class="mono">${x.dir}</td><td class="mono">${x.port || '—'}</td><td><span class="status ${statusClass(x.status)}">${x.statusText}</span></td>
<td><div class="row-actions"><button data-action="start" aria-label="启动 ${x.name}">启动</button></div></td>`; <td><div class="row-actions"><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : '启动'}</button></div></td>`;
tr.addEventListener('click', e => { if (!e.target.closest('button')) openDetails(x.id); }); tr.addEventListener('click', e => { if (!e.target.closest('button')) openDetails(x.id); });
tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetails(x.id); } }); tr.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetails(x.id); } });
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectedId = x.id; if (b.dataset.action === 'details') openDetails(x.id); else if (b.dataset.action === 'stop') openConfirm(x.id, false); else openConfirm(x.id, 'start'); })); tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectedId = x.id; if (b.dataset.action === 'details') openDetails(x.id); else if (b.dataset.action === 'stop') openConfirm(x.id, false); else openConfirm(x.id, 'start'); }));
@@ -295,7 +296,7 @@
}); });
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`; $('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
} }
function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id); $('drawer').innerHTML = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' ? 'disabled' : ''}>关闭实例</button></div></div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); } function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id), externallyAssociated = x.status === 'associated'; $('drawer').innerHTML = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>调试端口</span><span class="mono">${x.port || '—'}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' || externallyAssociated ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' || externallyAssociated ? 'disabled' : ''}>关闭实例</button></div>${externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : ''}</div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); }
function openLaunch() { $('drawer').innerHTML = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 User Data Dir。'; data.parentElement.appendChild(msg); return; } hideDrawer(); setTimeout(() => showToast('启动请求已提交,实例进入 STARTING 状态。'), 80); }; } function openLaunch() { $('drawer').innerHTML = `<div class="drawer-head"><div><h2>新建浏览器实例</h2><p>配置完成后,Chub 会在本机启动一个隔离环境。</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭新建实例">×</button></div><form class="drawer-body" id="launchForm"><div class="form-section"><h3>浏览器配置</h3><div class="field"><span>浏览器类型 <span>*</span></span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="kind" value="chrome" checked> Chrome</label><label><input type="radio" name="kind" value="edge"> Edge</label></div></div><div class="field"><label for="exe">可执行文件</label><input id="exe" value="自动发现" aria-describedby="exeHelp"><small id="exeHelp">留空时按浏览器类型搜索标准安装路径。</small></div></div><div class="form-section"><h3>运行环境</h3><div class="field"><label for="dataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="dataDir" placeholder="例如:D:\\Chub\\profiles\\new-instance" required><button type="button" class="secondary" id="chooseInstanceDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="profile">profile-directory</label><input id="profile" value="Default"></div><div class="field"><label for="url">启动 URL(可选)</label><input id="url" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="proxy">代理服务器</label><input id="proxy" placeholder="可选,例如 http://127.0.0.1:8080"><small>仅支持无认证代理,认证信息不会被保存。</small></div></div><div class="form-section"><h3>启动选项</h3><label class="check-line"><input type="checkbox" id="headless"> <span><b>无头模式</b><br><small class="help">启动后台浏览器,不显示窗口。</small></span></label><label class="check-line"><input type="checkbox" id="remember" checked> <span><b>保存此配置</b><br><small class="help">只保存路径和非敏感参数。</small></span></label></div><div class="drawer-actions"><button type="button" class="secondary" id="cancelLaunch">取消</button><button type="submit" class="primary">启动实例</button></div></form>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const data = $('dataDir'); if (!data.value.trim()) { data.focus(); data.parentElement.classList.add('error'); const msg = document.createElement('div'); msg.className = 'field-error'; msg.textContent = '请填写 User Data Dir。'; data.parentElement.appendChild(msg); return; } hideDrawer(); setTimeout(() => showToast('启动请求已提交,实例进入 STARTING 状态。'), 80); }; }
function showDrawer() { $('drawer').classList.add('open'); $('backdrop').classList.add('open'); $('drawer').setAttribute('aria-hidden', 'false'); setTimeout(() => $('drawer').querySelector('button, input, select')?.focus(), 60); } function showDrawer() { $('drawer').classList.add('open'); $('backdrop').classList.add('open'); $('drawer').setAttribute('aria-hidden', 'false'); setTimeout(() => $('drawer').querySelector('button, input, select')?.focus(), 60); }
function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); } function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); }