docs: define proxy and managed stop tasks

This commit is contained in:
QiuSW
2026-07-25 16:53:44 +08:00
parent be9292c376
commit f13e06500c
10 changed files with 121 additions and 13 deletions
+4
View File
@@ -15,6 +15,8 @@
| BR-011 | Chub 启动的 Chrome/Edge 必须启用仅限 loopback 的本地 CDP;端口从设置的起始端口分配,空值默认 9666。 | P1 |
| BR-012 | 同一 user data dir 被外部浏览器占用时,只能在其 CDP 端点可验证时关联为外部实例;不得接管生命周期。 | P1 |
| BR-013 | 用户可以刷新已保存实例的状态,并编辑未运行实例的名称、浏览器类型、user data dir 和启动 URL;刷新和编辑不得扩大到未授权的浏览器进程。 | P1 |
| BR-014 | 用户可以维护无认证代理配置,并在创建或编辑实例时选择代理;代理认证信息不得保存、记录或进入启动参数。 | P1 |
| BR-015 | GUI 实例主操作在 Chub 托管运行态提供优雅停止、在可启动状态提供启动;外部关联或未知外部实例不得获得生命周期控制。 | P1 |
## 验收标准
@@ -30,6 +32,8 @@
- UI 覆盖加载、空、占用、启动失败、关闭中、成功和权限错误。
- 刷新仅检查 Chub 已保存的实例;任务执行中不可重复触发,过期结果不得覆盖已经删除或编辑后的记录。
- 实例编辑支持保存、取消和 Escape;有未保存修改时必须先确认,运行中或外部关联实例不能修改浏览器类型或 user data dir。
- 代理仅接受无认证的 `scheme://host:port` 端点。删除仍被实例引用的代理必须被拒绝;实例保存后按稳定代理 ID 解析最新端点。
- 受管运行实例点击主操作后进入关闭中,成功退出后恢复启动入口;停止失败不自动强制终止,也绝不影响其他 Chrome/Edge。
## 不做
+2
View File
@@ -39,6 +39,8 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层
- App shutdown:取消监控,不默认终止用户浏览器。
- Refresh:以 UI 传入的保存实例快照为范围,在后台读取 Chub registry、对应 profile 占用证据与已知 loopback CDP 端点,返回状态 DTO。刷新不按浏览器进程名或端口范围枚举;结果以实例 ID 与快照版本关联,UI 丢弃已删除或已编辑行的过期结果。
- Edit:UI 只提交可持久化的实例配置 DTO;运行时 PID、关联端口、占用来源和状态由刷新/启动结果返回,不作为编辑表单保存字段。编辑时保留未公开的非敏感启动选项,活跃或外部关联实例的身份约束字段保持只读。
- Proxy:本地配置保存稳定 `proxy_id` 指向的代理库项(名称与无认证端点);启动 adapter 在后台解析为当前 `LaunchSpec.ProxyServer` 快照。代理不能含 userinfo,删除被引用项会被拒绝;修改代理只影响后续启动,不影响已运行实例。
- GUI stop:主操作只对 Chub registry 中可验证的托管实例调用 `StopProfile(userDataDir, false)`,随后有限等待 registry 释放。外部关联、占用和未知实例永远不调用停止接口;超时或失败不自动强制关闭。
Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。CDP 端点只绑定 `127.0.0.1`;端口号和 `/json/version` 是诊断数据,WebSocket URL 不进入持久化、事件或普通 UI 文案。
+2
View File
@@ -39,6 +39,8 @@
| T-301 | 接入 GUI 启动操作与实例删除确认 | T-204,T-208 | DONE |
| T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE |
| T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE |
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DOING |
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | TODO |
## Backlog
+10
View File
@@ -10,6 +10,8 @@
| US-006 | 用户可以通过 CLI/API 查询、启动和关闭实例。 | P1 |
| US-007 | 用户可以看到实例关联的本地调试端口;同目录外部浏览器已启用调试时,Chub 只关联并展示状态。 | P1 |
| US-008 | 用户可以刷新已保存实例的运行状态,并通过对话框编辑非运行实例的基本配置而不影响其他浏览器。 | P1 |
| US-009 | 用户可以在设置中维护无认证代理,并在新建或编辑实例时从下拉选择器选择代理。 | P1 |
| US-010 | 用户可以通过实例行的同一主操作启动或优雅停止 Chub 托管的浏览器,而不会关闭外部浏览器。 | P1 |
## 验收场景
@@ -32,3 +34,11 @@ CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配
### US-008 刷新与编辑实例
给定已保存的实例,用户点击刷新或按 F5 后,Chub 只检查这些实例并更新运行状态、PID、端口与占用来源。用户双击行、按 Enter 或点击编辑图标可以打开编辑对话框;保存名称、浏览器类型、User Data Dir 和启动 URL 后更新本地配置。实际调试端口只读显示。若实例正在运行、启动中或为外部关联,浏览器类型和 User Data Dir 保持不可编辑。Escape 在无修改时关闭对话框,有修改时要求用户选择保存、不保存或继续编辑。
### US-009 代理选择
给定用户在设置中保存的无认证代理,创建和编辑表单可从“无代理”或已命名代理中选择。保存后实例仅关联代理 ID;启动时使用该 ID 的最新端点。含用户名、密码或 URL 路径的端点被拒绝,仍被引用的代理不能删除。
### US-010 受管实例启停
给定 Chub 启动且仍可在 registry 中验证的实例,运行态主操作显示停止图标。点击后先显示停止中,再请求浏览器正常退出;成功后恢复启动图标。给定外部已关联或未知占用的同目录浏览器,Chub 不显示停止入口,也不发送关闭请求。
+4
View File
@@ -9,6 +9,8 @@
| IX-005 | 设置 | 默认路径、日志、数据目录和远程调试起始端口 | 保存成功、路径无效、端口无效、权限拒绝 | P1 |
| IX-006 | 本地 CDP 启动 | 从起始端口分配、校验端点、展示实际端口 | 分配中、已关联、端口冲突、端点失效、外部不接管 | P1 |
| IX-007 | 实例刷新与编辑 | F5/刷新、双击/Enter/编辑图标、目录选择、保存/取消/Escape | 空闲、刷新中、部分失败、字段错误、脏表单、只读运行态、保存成功 | P1 |
| IX-008 | 代理设置与选择器 | 新增、编辑、删除代理;创建/编辑实例选择代理 | 空列表、无代理、格式错误、重复名称、被引用删除、保存成功 | P1 |
| IX-009 | 实例主启停操作 | 启动图标、停止图标、外部重新检测 | 已退出、启动中、托管运行、停止中、失败恢复、外部关联 | P1 |
## 通用规则
@@ -21,3 +23,5 @@
- 实例列表使用外层边框、文字状态和图标区分归属;端口为空时显示“—”,外部关联时操作区不显示关闭或重启。
- 刷新和编辑是独立命令:刷新为只读状态检查,编辑只保存配置;两者均不接管外部实例。F5 与刷新按钮共用一个去重命令。
- 双击不是唯一入口:单击只选择行,Enter 和编辑图标打开对话框。打开后焦点位于名称,关闭后返回调用行;有未保存编辑时 Escape 先请求确认。
- 代理选择器必须提供“无代理”和命名项,能用键盘选择;代理端点只展示已校验的无认证 `scheme://host:port`,删除有引用的项时保持当前表单与实例选择不变。
- 实例主操作根据归属和状态切换启动、停止、重新检测或禁用。停止中禁止重复请求;停止失败恢复原状态并保留焦点。外部关联不显示停止入口。
+3 -1
View File
@@ -8,7 +8,7 @@ MVP 首先提供本地 Go application service 和 CLI;是否增加 loopback HT
`LaunchSpec` 使用已分配的 `RemoteDebugPort`;端口分配不属于 UI,也不允许由 `ExtraArgs` 覆盖。`InstanceView` 返回实际关联端口、PID、占用来源和归属。外部关联只公开经 `DevToolsActivePort` 与 `/json/version` 验证的端口,不公开 CDP WebSocket URL。
UI 编辑使用独立的 `EditableInstance` DTO(`ID`、名称、浏览器类型、User Data Dir、启动 URL),不将运行时 PID、状态、来源或实际远程调试端口写回配置。状态刷新使用 `RefreshInstanceStatus(ctx, []InstanceSnapshot) ([]InstanceStatusResult, error)` 形状:每个结果以 `ID` 和快照版本对应,错误可按行返回;实现只能检查调用方提供的实例范围。
UI 编辑使用独立的 `EditableInstance` DTO(`ID`、名称、浏览器类型、User Data Dir、启动 URL、`ProxyID`),不将运行时 PID、状态、来源或实际远程调试端口写回配置。`ProxyProfile` DTO 只包含 `ID`、名称和已校验的无认证端点;不提供认证字段。状态刷新使用 `RefreshInstanceStatus(ctx, []InstanceSnapshot) ([]InstanceStatusResult, error)` 形状:每个结果以 `ID` 和快照版本对应,错误可按行返回;实现只能检查调用方提供的实例范围。
## Application service
@@ -26,6 +26,8 @@ type BrowserManager interface {
平台实现通过实例级 `StopProfile(userDataDir, force)` 执行关闭;force 只对已注册并经过身份绑定的实例生效。
GUI 将优雅停止封装为异步 `InstanceStopper(ctx, InstanceRow) error` adapter;adapter 只接受当前配置行并执行 `force=false`,不暴露平台句柄或允许调用方传递任意 PID。
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。CDP 端点检测和端口分配均通过 platform port 执行,UI 只消费 DTO 结果。
状态刷新和目录选择必须通过后台 adapter 回到 UI 结果通道;UI frame 不直接做进程、文件或 CDP I/O。刷新不构成外部实例接管授权。
+2 -2
View File
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-303、T-302、T-301 已完成;T-201 至 T-208 已完成)
- 阶段:Phase 3 真实实例操作(T-304 进行中;T-303、T-302、T-301 已完成;T-201 至 T-208 已完成)
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、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 至 T-303 已完成
- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL 和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一步可接入关闭、重启、退出状态回传和 CLI BrowserManager adapter。
- blocker:无;当前优先完成代理配置库、实例代理选择与安全启动参数传递(T-304),再接入受管实例的 GUI 优雅停止与启停状态回传(T-305)。
## 当前目录
+38
View File
@@ -0,0 +1,38 @@
---
id: T-304
title: 代理配置库、实例选择与安全启动参数传递
phase: 3
deps: [T-303]
status: DOING
created: 2026-07-25
owner: codex
---
## 需求与背景
用户需要在“设置”中维护可复用的代理,并在创建或编辑实例时选择其中一项。代理必须在实际启动时转换为 Chromium 的 `--proxy-server` 参数,同时继续满足 Chub 的本地、安全和无秘密持久化边界。
## 方案与边界
- 配置文件新增稳定 ID 的代理集合;每项仅有名称和无认证代理端点。实例只保存 `proxy_id`,原有非敏感 `LaunchSpec.ProxyServer` 仅用于兼容旧配置。
- 接受 `http`、`https`、`socks4`、`socks5` 的 `scheme://host:port` 端点;拒绝 userinfo、路径、query、fragment 与空 host/port。代理用户名、密码或其他认证秘密不得保存、记录或进入命令行。
- 设置页以紧凑的代理列表和就地表单完成新增、修改、删除。删除被实例引用的代理必须被拒绝并说明引用数量,不能隐式清空实例的选择。
- 新建抽屉与编辑对话框提供“代理”选择器,含“无代理”选项;选择器只暴露名称与端点,不要求用户重复输入代理地址。编辑活动实例时仍可修改后续启动使用的代理选择。
- 启动 adapter 在后台按 `proxy_id` 从当前配置解析端点,生成一次性的 `LaunchSpec.ProxyServer`;找不到代理时拒绝启动并保留稳定、无秘密的错误说明。UI frame 不做配置或进程 I/O。
- 代理配置更新后,下次启动立即使用最新端点;不修改已运行浏览器,也不尝试通过 CDP 接管外部实例。
## 验收要点
- 设置页可新增、编辑和删除未被引用的无认证代理;格式错误、重复名称和删除已引用代理均有就地恢复提示。
- 新建和编辑实例可在“无代理”和已保存代理间选择;保存、重启后选择保持不变,旧的无 `proxy_id` 配置仍能加载。
- 选择代理后,Chrome/Edge 启动参数包含一次且仅一次正确的 `--proxy-server=<endpoint>`;无代理时不输出该参数。
- 配置、事件、错误和测试输出均不包含代理认证信息;含 userinfo 的输入被拒绝,不能写入配置或命令行。
- 覆盖配置序列化、端点校验、引用保护、实例保存、启动参数解析、选择器键盘操作和 UI 异步边界。
- `gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1` 与 `scripts/smoke-windows.ps1` 通过。
## 执行记录
- 状态:DOING
- 变更:待实现。
- 验证:待执行。
- 阻塞:无。
+35
View File
@@ -0,0 +1,35 @@
---
id: T-305
title: GUI 受管实例优雅停止与启停状态机
phase: 3
deps: [T-304]
status: TODO
created: 2026-07-25
owner: codex
---
## 需求与背景
实例列表当前能异步启动已保存实例,但无法从同一入口关闭 Chub 启动的浏览器。用户需要启动按钮在可控运行态切换为停止图标,再次点击时请求浏览器优雅退出,成功后恢复启动图标。
## 方案与边界
- 复用实例行的主操作位:已退出、失败或可启动状态显示启动图标;Chub 托管运行/调试不可用状态显示停止图标;停止中显示不可重复触发的停止中状态;外部已关联继续显示重新检测,绝不显示生命周期控制。
- 停止请求通过 UI 持有的异步 `InstanceStopper` adapter 发出。adapter 只调用平台 `StopProfile(userDataDir, false)`,并依赖 Chub registry 的 PID、可执行文件和 user data dir 身份校验;绝不按 Chrome/Edge 进程名批量终止,也不对外部关联实例调用 Stop。
- 平台成功发出优雅关闭后等待有限时间观察注册实例释放。超时、身份不匹配或权限错误回到原运行状态并显示恢复建议;本任务不自动升级为强制关闭。
- 成功关闭后清空运行时 PID/端口、显示“已退出”、恢复启动图标并将键盘焦点留在同一行。操作状态同时使用图标、文字和禁用态,不只依赖颜色。
## 验收要点
- 受管运行实例的主操作为停止图标;点击后立即显示“停止中”,重复点击不会发出第二个停止请求;成功后显示“已退出”和启动图标。
- 外部已关联、外部占用和未知占用实例不能获得关闭入口;其主操作仅可重新检测或不可用。
- 停止 adapter 仅接收当前已保存实例行,校验失败、无注册记录、超时和权限失败均不会影响其他浏览器进程,且 UI 恢复到可理解状态。
- 覆盖成功关闭、失败恢复、重复点击、启动中、外部关联、调试不可用、刷新与停止结果竞争、键盘/触控访问和焦点恢复。
- `gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1` 与 `scripts/smoke-windows.ps1` 通过。
## 执行记录
- 状态:TODO
- 变更:待实现。
- 验证:待执行。
- 阻塞:无。
+21 -10
View File
@@ -262,6 +262,7 @@
<div class="field"><label for="defaultDataDir">默认 user data dir 根目录</label><div class="path-field"><input id="defaultDataDir" value="D:\\Chub\\profiles"><button type="button" class="secondary browse-setting" data-target="defaultDataDir">选择目录</button><button type="button" class="secondary search-setting" data-target="defaultDataDir">搜索</button></div><small>新建实例时作为默认建议路径;不会移动或删除已有 profile。</small></div>
<div class="field"><label for="logDir">日志目录</label><div class="path-field"><input id="logDir" value="D:\\Chub\\logs"><button type="button" class="secondary browse-setting" data-target="logDir">选择目录</button><button type="button" class="secondary search-setting" data-target="logDir">搜索</button></div><small>日志仅保存必要诊断信息,并过滤敏感参数。</small></div>
</div>
<div class="form-section"><h3>代理</h3><p class="help">仅保存无认证代理;名称可在实例表单中选择。</p><div class="field"><label for="proxyName">代理名称</label><input id="proxyName" placeholder="例如:新加坡出口"></div><div class="field"><label for="proxyServer">代理地址</label><input id="proxyServer" placeholder="例如:http://127.0.0.1:8080"><small>支持 http、https、socks4、socks5 的 <span class="mono">scheme://host:port</span>;不允许用户名、密码或路径。</small></div><div class="dialog-actions"><button type="button" class="secondary" id="proxyCancel" hidden>取消编辑</button><button type="button" class="secondary" id="proxySave">添加代理</button></div><div class="setting-list" id="proxyList" aria-label="已保存代理"></div></div>
<div class="settings-actions"><button type="button" class="secondary" id="discardSettings">取消</button><button type="submit" class="primary">保存设置</button></div>
</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><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>
@@ -273,13 +274,17 @@
<div class="drawer-backdrop" id="backdrop"></div>
<aside class="drawer" id="drawer" aria-label="实例详情和配置" aria-hidden="true"></aside>
<dialog id="confirmDialog" aria-labelledby="dialogTitle"><div class="dialog-inner"><h2 id="dialogTitle">关闭浏览器实例?</h2><p id="dialogText">将请求浏览器正常退出。未保存的网页内容由浏览器自行处理。</p><div class="dialog-warning" id="dialogWarning" hidden>强制终止可能造成 profile 数据损坏。仅在浏览器无响应时使用。</div><div class="dialog-actions"><button class="secondary" id="dialogCancel">取消</button><button class="primary" id="dialogConfirm">优雅关闭</button></div></div></dialog>
<dialog class="edit-dialog" id="editDialog" aria-labelledby="editTitle"><form class="dialog-inner edit-form" id="editForm"><div><h2 id="editTitle">编辑实例</h2><p id="editSubtitle">修改下次启动使用的保存配置。</p></div><div class="field"><label for="editName">实例名称 <span>*</span></label><input id="editName" required><small>用于在实例列表中识别此浏览器环境。</small></div><div class="field"><span>浏览器类型</span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="editKind" value="chrome" id="editChrome"> Chrome</label><label><input type="radio" name="editKind" value="edge" id="editEdge"> Edge</label></div></div><div class="field"><label for="editDataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="editDataDir" required><button type="button" class="secondary" id="chooseEditDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="editURL">启动 URL(可选)</label><input id="editURL" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="editPort">当前调试端口</label><input class="readonly-value" id="editPort" readonly><small>实际端口由全局起始端口在启动时分配,不能在此固定。</small></div><div class="form-note" id="editReadOnlyNote" hidden>该实例正在运行或为外部关联。为避免配置与运行身份不一致,浏览器类型和 User Data Dir 已锁定。</div><div class="dialog-actions"><button type="button" class="secondary" id="editCancel">取消</button><button type="submit" class="primary" id="editSave">保存更改</button></div></form></dialog>
<dialog class="edit-dialog" id="editDialog" aria-labelledby="editTitle"><form class="dialog-inner edit-form" id="editForm"><div><h2 id="editTitle">编辑实例</h2><p id="editSubtitle">修改下次启动使用的保存配置。</p></div><div class="field"><label for="editName">实例名称 <span>*</span></label><input id="editName" required><small>用于在实例列表中识别此浏览器环境。</small></div><div class="field"><span>浏览器类型</span><div class="radio-row" role="radiogroup" aria-label="浏览器类型"><label><input type="radio" name="editKind" value="chrome" id="editChrome"> Chrome</label><label><input type="radio" name="editKind" value="edge" id="editEdge"> Edge</label></div></div><div class="field"><label for="editDataDir">User Data Dir <span>*</span></label><div class="path-field"><input id="editDataDir" required><button type="button" class="secondary" id="chooseEditDir">选择目录</button></div><small>必须是绝对路径;同一目录不能被多个实例同时使用。</small></div><div class="field"><label for="editURL">启动 URL(可选)</label><input id="editURL" type="url" placeholder="https://example.com"><small>仅支持 http/https;留空时启动浏览器默认页。</small></div><div class="field"><label for="editProxy">代理</label><select id="editProxy"><option value="">无代理</option></select><small>选择已保存的无认证代理;修改后下次启动生效。</small></div><div class="field"><label for="editPort">当前调试端口</label><input class="readonly-value" id="editPort" readonly><small>实际端口由全局起始端口在启动时分配,不能在此固定。</small></div><div class="form-note" id="editReadOnlyNote" hidden>该实例正在运行或为外部关联。为避免配置与运行身份不一致,浏览器类型和 User Data Dir 已锁定。</div><div class="dialog-actions"><button type="button" class="secondary" id="editCancel">取消</button><button type="submit" class="primary" id="editSave">保存更改</button></div></form></dialog>
<dialog id="discardDialog" aria-labelledby="discardTitle"><div class="dialog-inner"><h2 id="discardTitle">放弃未保存的更改?</h2><p>可以先保存配置,或放弃本次编辑并关闭对话框。</p><div class="dialog-actions"><button class="secondary" id="keepEditing">继续编辑</button><button class="secondary" id="discardChanges">不保存</button><button class="primary" id="saveChanges">保存</button></div></div></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const proxies = [
{ id: 'proxy-sg', name: '新加坡出口', server: 'http://127.0.0.1:8080' },
{ id: 'proxy-test', name: '测试 SOCKS5', server: 'socks5://127.0.0.1:1080' }
];
const instances = [
{ 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-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/', proxyId: 'proxy-sg', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.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: '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: '—', 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' }
@@ -288,8 +293,12 @@
let modalAction = 'stop';
let editingID = '';
let editSnapshot = null;
let editingProxyID = '';
const $ = (id) => document.getElementById(id);
const statusClass = (s) => s;
const proxyLabel = (id) => proxies.find(p => p.id === id)?.name || '无代理';
function renderProxyOptions(select, selected = '') { select.innerHTML = '<option value="">无代理</option>' + proxies.map(p => `<option value="${p.id}">${p.name} · ${p.server}</option>`).join(''); select.value = selected; }
function renderProxies() { const list = $('proxyList'); list.innerHTML = proxies.map(p => { const uses = instances.filter(i => i.proxyId === p.id).length; return `<div class="detail-line"><span><b>${p.name}</b><br><small class="mono">${p.server}</small></span><span><small>${uses ? `已被 ${uses} 个实例使用` : '未使用'}</small> <button type="button" class="secondary" data-proxy-edit="${p.id}">编辑</button><button type="button" class="secondary" data-proxy-delete="${p.id}">删除</button></span></div>`; }).join('') || '<small class="help">尚无代理。实例将以无代理方式启动。</small>'; list.querySelectorAll('[data-proxy-edit]').forEach(b => b.onclick = () => { const p = proxies.find(item => item.id === b.dataset.proxyEdit); editingProxyID = p.id; $('proxyName').value = p.name; $('proxyServer').value = p.server; $('proxySave').textContent = '保存代理'; $('proxyCancel').hidden = false; $('proxyName').focus(); }); list.querySelectorAll('[data-proxy-delete]').forEach(b => b.onclick = () => { const p = proxies.find(item => item.id === b.dataset.proxyDelete), uses = instances.filter(i => i.proxyId === p.id).length; if (uses) return showToast(`“${p.name}”仍被 ${uses} 个实例使用,不能删除。`); proxies.splice(proxies.indexOf(p), 1); renderProxies(); showToast(`已删除代理“${p.name}”。`); }); }
function filtered() {
const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value;
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));
@@ -303,11 +312,11 @@
rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.title = '双击或按 Enter 编辑实例'; 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>${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="edit" aria-label="编辑 ${x.name}">编辑</button><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : '启动'}</button><button data-action="delete" aria-label="删除 ${x.name}">删除</button></div></td>`;
<td><div class="row-actions"><button data-action="edit" aria-label="编辑 ${x.name}">编辑</button><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '停止' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '■ 停止' : '▶ 启动'}</button><button data-action="delete" aria-label="删除 ${x.name}">删除</button></div></td>`;
tr.addEventListener('click', e => { if (!e.target.closest('button')) selectRow(x.id, body); });
tr.addEventListener('dblclick', e => { if (!e.target.closest('button')) openEdit(x.id); });
tr.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); openEdit(x.id); } else if (e.key === ' ') { e.preventDefault(); selectRow(x.id, body); } });
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectRow(x.id, body); if (b.dataset.action === 'edit') openEdit(x.id); else if (b.dataset.action === 'delete') showToast(`原型演示:将确认删除“${x.name}”的 Chub 配置,不会删除 User Data Dir。`); else openConfirm(x.id, 'start'); }));
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectRow(x.id, body); if (b.dataset.action === 'edit') openEdit(x.id); else if (b.dataset.action === 'delete') showToast(`原型演示:将确认删除“${x.name}”的 Chub 配置,不会删除 User Data Dir。`); else if (x.status === 'running') { x.status = 'stopping'; x.statusText = '停止中'; renderRows(); setTimeout(() => { x.status = 'exited'; x.statusText = '已退出'; x.pid = '—'; x.port = '—'; renderRows(); showToast(`“${x.name}”已优雅退出。`); }, 700); } else openConfirm(x.id, 'start'); }));
body.appendChild(tr);
});
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
@@ -316,14 +325,14 @@
function editIsLocked(x) { return ['running', 'starting', 'associated'].includes(x.status); }
function editIsDirty() {
if (!editingID || !editSnapshot) return false;
return $('editName').value !== editSnapshot.name || $('editDataDir').value !== editSnapshot.dir || $('editURL').value !== editSnapshot.url || document.querySelector('input[name="editKind"]:checked')?.value !== editSnapshot.kind;
return $('editName').value !== editSnapshot.name || $('editDataDir').value !== editSnapshot.dir || $('editURL').value !== editSnapshot.url || $('editProxy').value !== editSnapshot.proxyId || document.querySelector('input[name="editKind"]:checked')?.value !== editSnapshot.kind;
}
function focusEditedRow(id) { requestAnimationFrame(() => document.querySelector(`tr[data-id="${id}"]`)?.focus()); }
function finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); }
function openEdit(id) {
const x = instances.find(i => i.id === id); if (!x) return;
selectedId = id; editingID = id; editSnapshot = { name: x.name, dir: x.dir, url: x.url || '', kind: x.kind };
const locked = editIsLocked(x); $('editName').value = x.name; $('editDataDir').value = x.dir; $('editURL').value = x.url || ''; $('editPort').value = x.port || '—'; $('editChrome').checked = x.kind === 'chrome'; $('editEdge').checked = x.kind === 'edge';
selectedId = id; editingID = id; editSnapshot = { name: x.name, dir: x.dir, url: x.url || '', kind: x.kind, proxyId: x.proxyId || '' };
const locked = editIsLocked(x); $('editName').value = x.name; $('editDataDir').value = x.dir; $('editURL').value = x.url || ''; renderProxyOptions($('editProxy'), x.proxyId || ''); $('editPort').value = x.port || '—'; $('editChrome').checked = x.kind === 'chrome'; $('editEdge').checked = x.kind === 'edge';
$('editSubtitle').textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。';
$('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editReadOnlyNote').hidden = !locked;
$('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0);
@@ -332,12 +341,12 @@
const x = instances.find(i => i.id === editingID); if (!x) return finishEdit();
const name = $('editName').value.trim(), dir = $('editDataDir').value.trim(), url = $('editURL').value.trim();
if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; }
const locked = editIsLocked(x); x.name = name; x.url = url;
const locked = editIsLocked(x); x.name = name; x.url = url; x.proxyId = $('editProxy').value;
if (!locked) { x.dir = dir; x.kind = document.querySelector('input[name="editKind"]:checked')?.value || x.kind; x.browser = x.kind === 'edge' ? 'Edge' : 'Chrome'; }
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
}
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); }
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="launchProxy">代理</label><select id="launchProxy"><option value="">无代理</option></select><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(); renderProxyOptions($('launchProxy')); $('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; } const proxy = proxyLabel($('launchProxy').value); hideDrawer(); setTimeout(() => showToast(`启动请求已提交(${proxy}),实例进入 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 hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); }
function openConfirm(id, action) { const x = instances.find(i => i.id === id); modalAction = action; $('dialogTitle').textContent = action === 'restart' ? '重启浏览器实例?' : action === 'start' ? '启动浏览器实例?' : '关闭浏览器实例?'; $('dialogText').textContent = action === 'restart' ? `将先请求“${x.name}”正常退出,再使用相同配置重新启动。` : action === 'start' ? `将使用已保存配置启动“${x.name}”。` : `将请求“${x.name}”正常退出。未保存的网页内容由浏览器自行处理。`; $('dialogWarning').hidden = action !== false; $('dialogConfirm').textContent = action === false ? '优雅关闭' : action === 'restart' ? '确认重启' : '启动实例'; $('dialogConfirm').className = action === false ? 'danger-button' : 'primary'; $('confirmDialog').showModal(); }
@@ -351,9 +360,11 @@
document.querySelectorAll('[data-nav]').forEach(b => b.onclick = () => { document.querySelectorAll('[data-nav]').forEach(n => n.removeAttribute('aria-current')); b.setAttribute('aria-current', 'page'); setPage(b.dataset.nav); });
document.querySelectorAll('.browse-setting').forEach(b => b.onclick = () => showToast(`原型演示:将打开${b.dataset.target.includes('Path') ? '文件' : '目录'}选择器。`));
document.querySelectorAll('.search-setting').forEach(b => b.onclick = () => { if (b.dataset.timer) { clearTimeout(Number(b.dataset.timer)); delete b.dataset.timer; b.textContent = '搜索'; showToast('已取消当前搜索。'); return; } b.textContent = '取消'; showToast('正在搜索可用路径…'); b.dataset.timer = String(setTimeout(() => { delete b.dataset.timer; b.textContent = '搜索'; showToast('搜索完成:已更新当前字段。'); }, 1600)); });
$('proxySave').onclick = () => { const name = $('proxyName').value.trim(), server = $('proxyServer').value.trim(); if (!name || !server || !/^(https?|socks[45]):\/\/[^\/@?#:]+(?::\d+)$/.test(server)) { showToast('请填写名称和无认证的 scheme://host:port 代理地址。'); return; } if (proxies.some(p => p.name === name && p.id !== editingProxyID)) { showToast('代理名称已存在,请使用不同名称。'); return; } if (editingProxyID) { const p = proxies.find(item => item.id === editingProxyID); p.name = name; p.server = server; } else proxies.push({ id: `proxy-${Date.now()}`, name, server }); editingProxyID = ''; $('proxyName').value = ''; $('proxyServer').value = ''; $('proxySave').textContent = '添加代理'; $('proxyCancel').hidden = true; renderProxies(); showToast('代理已保存;后续启动将使用最新端点。'); };
$('proxyCancel').onclick = () => { editingProxyID = ''; $('proxyName').value = ''; $('proxyServer').value = ''; $('proxySave').textContent = '添加代理'; $('proxyCancel').hidden = true; };
$('settingsForm').addEventListener('submit', e => { e.preventDefault(); $('settingsStatus').textContent = '设置已保存 · 最后更新:刚刚'; $('settingsStatus').classList.remove('warning'); showToast('设置已保存,后续新建实例将使用新的默认值。'); });
$('discardSettings').onclick = () => { $('settingsForm').reset(); $('settingsStatus').textContent = '已取消本次修改,恢复到上次保存值。'; $('settingsStatus').classList.add('warning'); showToast('已取消设置修改。'); };
renderRows();
renderRows(); renderProxies();
</script>
</body>
</html>