docs: define managed browser exit monitoring

This commit is contained in:
QiuSW
2026-07-25 18:07:57 +08:00
parent 826bc5ce39
commit f5b5e95223
9 changed files with 90 additions and 7 deletions
+3 -1
View File
@@ -7,7 +7,7 @@
| BR-003 | 启动前检测目标 user data dir 是否占用,禁止同一环境重复启动。 | P0 |
| BR-004 | 实例列表显示浏览器类型、PID、user data dir、启动时间和状态。 | P0 |
| BR-005 | 支持优雅关闭、强制关闭和重启,并明确操作影响。 | P0 |
| BR-006 | 监控启动失败、异常退出、退出码和进程消失。 | P0 |
| BR-006 | 监控 Chub 注册实例的启动失败、异常退出、退出码和进程消失;受管进程意外退出时及时更新 GUI,外部实例只允许刷新检测。 | P0 |
| BR-007 | 启动参数按数组传递,不通过 shell 拼接。 | P0 |
| BR-008 | 只允许管理工具注册且经过身份校验的 PID。 | P0 |
| BR-009 | 支持本地配置持久化,绝不保存浏览器凭证。 | P1 |
@@ -35,6 +35,8 @@
- 代理仅接受无认证的 `scheme://host:port` 端点。删除仍被实例引用的代理必须被拒绝;实例保存后按稳定代理 ID 解析最新端点。
- 设置页默认代理输入为空;选择下拉项后回填完整地址,保存更新当前项、未选择时新建。删除未引用项须经确认,确认取消或引用保护不能改变任何实例选择。
- 受管运行实例点击主操作后进入关闭中,成功退出后恢复启动入口;停止失败不自动强制终止,也绝不影响其他 Chrome/Edge。
- Chub 在当前应用会话中启动的 Chrome/Edge 根进程意外退出后,实例状态立即变为“已退出”,清空运行时 PID/端口,并显示包含实例名称和浏览器类型的可关闭提示;配置与 User Data Dir 保留,关闭提示后启动入口可用。
- 用户从 Chub 发起优雅停止时,不显示“浏览器已退出”意外退出提示;外部已关联、外部占用、未知占用以及应用关闭后无法继续监控的实例均不弹出此提示。
## 不做
+1
View File
@@ -41,6 +41,7 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层
- Edit:UI 只提交可持久化的实例配置 DTO;运行时 PID、关联端口、占用来源和状态由刷新/启动结果返回,不作为编辑表单保存字段。编辑时保留未公开的非敏感启动选项,活跃或外部关联实例的身份约束字段保持只读。
- Proxy:本地配置保存稳定 `proxy_id` 指向的代理库项(名称与无认证端点);启动 adapter 在后台解析为当前 `LaunchSpec.ProxyServer` 快照。代理不能含 userinfo,删除被引用项会被拒绝;修改代理只影响后续启动,不影响已运行实例。
- GUI stop:主操作只对 Chub registry 中可验证的托管实例调用 `StopProfile(userDataDir, false)`,随后有限等待 registry 释放。外部关联、占用和未知实例永远不调用停止接口;超时或失败不自动强制关闭。
- 受管退出监控:仅为当前会话由 Chub 启动并获得进程句柄的实例建立一个 `Wait` 监控任务。退出事件携带实例 ID、启动代次和 PID,UI 仅在三者仍匹配且当前未处于 Chub 请求的停止中时,将运行时状态改为“已退出”并排队显示提示;过期事件、外部实例和应用退出后的监控取消均不得生成意外退出提示。
Chrome 和 Edge 共用大部分 Chromium 参数,但 executable 默认路径、进程名称和安装方式不同,通过 `BrowserDefinition` 封装差异。CDP 端点只绑定 `127.0.0.1`;端口号和 `/json/version` 是诊断数据,WebSocket URL 不进入持久化、事件或普通 UI 文案。
+1
View File
@@ -42,6 +42,7 @@
| T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE |
| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DOING |
## Backlog
+5
View File
@@ -12,6 +12,7 @@
| US-008 | 用户可以刷新已保存实例的运行状态,并通过对话框编辑非运行实例的基本配置而不影响其他浏览器。 | P1 |
| US-009 | 用户可以在设置中通过完整代理地址下拉框维护无认证代理,并在新建或编辑实例时从下拉选择器选择代理。 | P1 |
| US-010 | 用户可以通过实例行的同一主操作启动或优雅停止 Chub 托管的浏览器,而不会关闭外部浏览器。 | P1 |
| US-011 | 用户手动关闭 Chub 启动的 Chrome/Edge 后,可以立即获知该实例已退出,并重新启动该实例。 | P1 |
## 验收场景
@@ -42,3 +43,7 @@ CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配
### US-010 受管实例启停
给定 Chub 启动且仍可在 registry 中验证的实例,运行态主操作显示停止图标。点击后先显示停止中,再请求浏览器正常退出;成功后恢复启动图标。给定外部已关联或未知占用的同目录浏览器,Chub 不显示停止入口,也不发送关闭请求。
### US-011 受管实例意外退出
给定 Chub 在当前会话中启动且处于运行态的 Chrome/Edge,用户关闭最后一个浏览器窗口、浏览器崩溃或进程被外部结束时,Chub 等待该受管根进程句柄并把匹配实例更新为“已退出”。Chub 显示“检测到‘实例名’的 Chrome/Edge 已退出”的可关闭信息提示,说明配置和 User Data Dir 未被删除;关闭提示后焦点回到该实例的启动入口。给定 Chub 已请求优雅停止、外部关联/占用实例、过期启动代次或应用已关闭,Chub 不显示意外退出提示。
+2
View File
@@ -11,6 +11,7 @@
| IX-007 | 实例刷新与编辑 | F5/刷新、双击/Enter/编辑图标、目录选择、保存/取消/Escape | 空闲、刷新中、部分失败、字段错误、脏表单、只读运行态、保存成功 | P1 |
| IX-008 | 代理设置与选择器 | 完整地址下拉选择、新增/更新、删除确认;创建/编辑实例选择代理 | 空列表、无代理、格式错误、重复地址、删除取消、被引用删除、保存成功 | P1 |
| IX-009 | 实例主启停操作 | 主操作、编辑、删除的固定顺序与间距;启动图标、停止图标、外部重新检测 | 已退出、启动中、托管运行、停止中、失败恢复、外部关联 | P1 |
| IX-010 | 受管浏览器退出提示 | 受管根进程退出后的状态恢复、提示队列和焦点回归 | 意外退出、Chub 请求停止、多个同时退出、过期事件、编辑/确认弹层打开、应用关闭 | P1 |
## 通用规则
@@ -25,3 +26,4 @@
- 双击不是唯一入口:单击只选择行,Enter 和编辑图标打开对话框。打开后焦点位于名称,关闭后返回调用行;有未保存编辑时 Escape 先请求确认。
- 代理选择器必须提供“无代理”和完整地址项,能用键盘选择;设置页默认输入为空,选择项回填地址。删除前先确认完整地址;有引用时保持当前选择、输入与实例选择不变。
- 实例主操作根据归属和状态切换启动、停止、重新检测或禁用,并固定排在编辑和删除之前。停止中禁止重复请求;停止失败恢复原状态并保留焦点。外部关联不显示停止入口;行内图标间距与命中区必须避免误操作。
- 受管浏览器意外退出是当前会话的异步状态提示,不是用户确认决策:状态先更新为“已退出”,再显示可关闭信息弹层;弹层只含“知道了”,Escape/Enter 均可关闭,关闭后焦点回到对应启动入口。多个退出合并到一个提示;已有编辑、未保存确认或删除确认弹层时排队,不能丢弃输入或叠加模态层。Chub 发起停止、外部实例、代次/PID 不匹配和应用退出取消监控时均不提示。
+2 -2
View File
@@ -30,7 +30,7 @@ GUI 将优雅停止封装为异步 `InstanceStopper(ctx, InstanceRow) error` ada
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。CDP 端点检测和端口分配均通过 platform port 执行,UI 只消费 DTO 结果。
状态刷新和目录选择必须通过后台 adapter 回到 UI 结果通道;UI frame 不直接做进程、文件或 CDP I/O。刷新不构成外部实例接管授权。
状态刷新、受管进程退出和目录选择必须通过后台 adapter 回到 UI 结果通道;UI frame 不直接做进程、文件或 CDP I/O。受管退出结果至少包含实例 ID、启动代次、受管 PID、退出码和是否为 Chub 请求停止;UI 以实例 ID、代次和 PID 丢弃过期结果。刷新不构成外部实例接管授权。
## CLI 当前合约
@@ -52,7 +52,7 @@ chub events
| --- | --- | --- |
| `browser.started` | 新实例完成启动 | InstanceView |
| `browser.status.changed` | 状态变化 | instance_id、status、pid、remote_debug_port、ownership、error_code |
| `browser.exited` | 进程退出 | instance_id、exit_code |
| `browser.exited` | Chub 当前会话受管根进程退出 | instance_id、launch_generation、pid、exit_code、expected_stop |
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
+2 -2
View File
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-301 至 T-306、T-201 至 T-208 已完成)
- 阶段:Phase 3 真实实例操作(T-307 进行中;T-301 至 T-306、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 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、完整地址代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、地址输入及保存/删除操作;选择会回填地址,保存按稳定 `proxyId` 新增或更新,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `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:无;T-306 已完成,下一项从 Backlog 或后续需求确定。
- blocker:无;当前执行 T-307 的受管浏览器意外退出监控与提示。
## 当前目录
+37
View File
@@ -0,0 +1,37 @@
---
id: T-307
title: 受管浏览器意外退出监控与状态提示
phase: 3
deps: [T-101, T-305, T-306]
status: DOING
created: 2026-07-25
owner: codex
---
## 需求与背景
当前刷新操作可以检查已保存实例的状态,但 Chub 已启动的浏览器被用户手动关闭、崩溃或被外部结束后,GUI 不会立即反馈。用户需要知道对应 Chrome/Edge 已退出,并能安全地重新启动它;这一能力不得变成扫描或接管外部浏览器的通道。
## 方案与边界
- 仅在当前 Chub 应用会话中,为成功启动并注册的 Chrome/Edge 根进程建立一个后台 `Wait` 监控;每个实例最多一个监控 goroutine。外部已关联、外部占用、未知占用和应用启动前已存在的进程均不监控、不提示。
- 退出结果必须携带稳定实例 ID、启动代次、受管 PID、退出码和 `expectedStop` 标记。UI 只接受仍与当前运行时记录完全匹配的结果,忽略删除、重启或再次启动后的迟到结果。
- 监控到意外退出时,先将实例运行时状态改为“已退出”并清空 PID、端口和归属,再排队显示信息提示。提示使用事实性文案“检测到‘实例名’的 Chrome/Edge 已退出”,不声称能区分手动关闭、崩溃或任务管理器结束;说明配置与 User Data Dir 未被删除。
- 提示只有“知道了”操作,Enter/Escape 都可关闭;关闭后焦点返回对应实例的启动按钮。多个实例同时退出时合并显示,已有编辑、未保存确认或删除确认弹层时延后显示,不能叠加或夺走用户输入。
- Chub 请求的优雅停止属于预期退出:停止结果与退出监控只产生一次最终状态,不显示意外退出提示。应用关闭时取消 UI 订阅/提示发布,但不能杀死浏览器;`Wait` 取消也不能隐式关闭进程。
- 运行时 PID、端口、退出码、启动代次和提示队列都不写入实例配置;日志和事件不含代理认证或浏览器私密数据。
## 验收要点
- 成功启动的受管 Chrome/Edge 进程意外结束后,不经 F5 就在合理时间内将对应实例改为“已退出”,启动操作恢复可用;外部状态与其他实例不变。
- 退出提示含实例名称、浏览器类型和“配置、User Data Dir 未删除”的恢复说明;关闭、Escape、Enter 后焦点回到该实例启动按钮。多个退出合并为一个提示。
- 主动停止不弹意外退出提示;停止失败、启动失败、外部关联、PID/代次不匹配、已删除实例、重启前一代退出和窗口关闭后的迟到事件均不改变新状态或弹窗。
- 监控、CDP/进程等待和结果发送均在后台;UI Frame 仅消费通道结果。没有按进程名枚举、终止或接管 Chrome/Edge 的逻辑。
- 覆盖受管退出、预期停止、重复/过期退出、启动后快速退出、多个退出、提示关闭/焦点恢复和应用关闭取消;`gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1` 与 `scripts/smoke-windows.ps1` 通过。
## 执行记录
- 状态:DOING
- 变更:待实现。
- 验证:待执行。
- 阻塞:无。
+37 -2
View File
@@ -161,6 +161,8 @@
.dialog-inner h2 { margin: 0; font-size: 19px; }
.dialog-inner p { margin: 0; color: var(--muted); line-height: 1.55; }
.dialog-warning { padding: 11px 12px; border-radius: 9px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.45; }
.dialog-list { margin: -3px 0 0; padding-left: 20px; color: var(--text); display: grid; gap: 5px; }
.dialog-list li { line-height: 1.45; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.edit-form { display: grid; gap: 18px; }
.edit-form .path-field { grid-template-columns: minmax(0, 1fr) auto; }
@@ -218,7 +220,7 @@
</style>
</head>
<body>
<div class="prototype-ribbon">PROTOTYPE · 仅供交互确认,非生产实现</div>
<div class="prototype-ribbon">PROTOTYPE · 仅供交互确认,非生产实现 · Ctrl+Shift+E 模拟受管浏览器退出</div>
<div class="app" id="app">
<aside class="sidebar" aria-label="主导航">
<div class="brand"><div class="brand-mark" aria-hidden="true">C</div><div><strong>Chub</strong><span>Browser Hub</span></div></div>
@@ -277,6 +279,7 @@
<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>
<dialog id="proxyDeleteDialog" aria-labelledby="proxyDeleteTitle"><div class="dialog-inner"><h2 id="proxyDeleteTitle">删除代理?</h2><p id="proxyDeleteText"></p><div class="dialog-actions"><button class="secondary" id="proxyDeleteCancel">取消</button><button class="danger-button" id="proxyDeleteConfirm">删除代理</button></div></div></dialog>
<dialog id="unexpectedExitDialog" aria-labelledby="unexpectedExitTitle" aria-describedby="unexpectedExitText"><div class="dialog-inner"><h2 id="unexpectedExitTitle">浏览器已退出</h2><p id="unexpectedExitText"></p><ul class="dialog-list" id="unexpectedExitList"></ul><div class="form-note">实例配置和 User Data Dir 未被删除。关闭此提示后,可以从实例列表重新启动浏览器。</div><div class="dialog-actions"><button class="primary" id="unexpectedExitAcknowledge">知道了</button></div></div></dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
@@ -295,6 +298,8 @@
let editingID = '';
let editSnapshot = null;
let selectedProxyID = '';
let unexpectedExitIDs = [];
let unexpectedExitFocusID = '';
const $ = (id) => document.getElementById(id);
const statusClass = (s) => s;
const proxyLabel = (id) => proxies.find(p => p.id === id)?.server || '无代理';
@@ -352,12 +357,42 @@
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(); }
function showToast(message) { const toast = $('toast'); toast.textContent = message; toast.classList.add('show'); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 3600); }
function hasOtherModal() { return [...document.querySelectorAll('dialog[open]')].some(dialog => dialog.id !== 'unexpectedExitDialog'); }
function queueUnexpectedExit(id) {
const instance = instances.find(x => x.id === id);
if (!instance || instance.status !== 'running') return;
instance.status = 'exited'; instance.statusText = '已退出'; instance.pid = '—'; instance.port = '—'; instance.exit = '已检测到进程退出';
if (!unexpectedExitIDs.includes(id)) unexpectedExitIDs.push(id);
selectedId = id;
renderRows();
presentUnexpectedExitNotice();
}
function presentUnexpectedExitNotice() {
const dialog = $('unexpectedExitDialog');
const exited = unexpectedExitIDs.map(id => instances.find(x => x.id === id)).filter(Boolean);
if (!exited.length || hasOtherModal()) return;
$('unexpectedExitTitle').textContent = exited.length === 1 ? '浏览器已退出' : `${exited.length} 个浏览器已退出`;
$('unexpectedExitText').textContent = exited.length === 1 ? `检测到“${exited[0].name}”的 ${exited[0].browser} 已退出。` : '检测到以下由 Chub 启动的浏览器已退出。';
$('unexpectedExitList').innerHTML = exited.map(x => `<li><strong>${x.name}</strong> · ${x.browser}</li>`).join('');
unexpectedExitFocusID = exited[0].id;
if (!dialog.open) { dialog.showModal(); setTimeout(() => $('unexpectedExitAcknowledge').focus(), 0); }
}
function dismissUnexpectedExitNotice() {
const focusID = unexpectedExitFocusID;
unexpectedExitIDs = [];
$('unexpectedExitDialog').close();
requestAnimationFrame(() => {
const start = document.querySelector(`tr[data-id="${focusID}"] button[data-action="start"]`);
(start || document.querySelector(`tr[data-id="${focusID}"]`))?.focus();
});
}
$('dialogCancel').onclick = () => $('confirmDialog').close(); $('confirmDialog').addEventListener('cancel', e => { e.preventDefault(); $('confirmDialog').close(); }); $('dialogConfirm').onclick = () => { $('confirmDialog').close(); const message = modalAction === false ? '关闭请求已提交,实例将进入 STOPPING。' : modalAction === 'restart' ? '重启请求已提交,正在等待旧实例退出。' : '启动请求已提交,实例将进入 STARTING。'; setTimeout(() => showToast(message), 80); };
$('unexpectedExitAcknowledge').onclick = dismissUnexpectedExitNotice; $('unexpectedExitDialog').addEventListener('cancel', e => { e.preventDefault(); dismissUnexpectedExitNotice(); }); document.querySelectorAll('dialog').forEach(dialog => dialog.addEventListener('close', () => setTimeout(presentUnexpectedExitNotice, 0)));
function refreshInstances() { const button = $('refresh'); if (button.disabled) return; button.disabled = true; button.textContent = '↻ 刷新中…'; setTimeout(() => { const starting = instances.find(x => x.status === 'starting'); if (starting) { starting.status = 'running'; starting.statusText = '运行中(托管)'; starting.pid = '18468'; starting.port = '9667'; } renderRows(); button.disabled = false; button.textContent = '↻ 刷新状态'; document.querySelector('.table-footer span:last-child').textContent = '最后检查:刚刚'; showToast('已检查保存的实例状态;未扫描其他 Chrome 或 Edge。'); }, 900); }
function setPage(page) { const settings = page === 'settings'; $('instancesPage').classList.toggle('page-hidden', settings); $('settingsPage').classList.toggle('page-hidden', !settings); $('pageEyebrow').textContent = settings ? 'Application settings' : 'Browser instances'; $('pageTitle').textContent = settings ? '设置' : '浏览器实例'; $('pageSubtitle').textContent = settings ? '管理浏览器路径、默认目录和本地运行行为' : '启动、观察并安全管理 Chrome 与 Edge 环境'; $('instanceCommands').classList.toggle('page-hidden', settings); if (settings) $('chromePath').focus(); }
$('newInstance').onclick = openLaunch; $('backdrop').onclick = hideDrawer; $('search').oninput = renderRows; $('browserFilter').onchange = renderRows; $('statusFilter').onchange = renderRows; $('refresh').onclick = refreshInstances; $('themeToggle').onclick = () => { const dark = document.documentElement.dataset.theme === 'dark'; document.documentElement.dataset.theme = dark ? '' : 'dark'; $('themeToggle').textContent = dark ? '☾' : '☀'; $('themeToggle').setAttribute('aria-label', dark ? '切换深色主题' : '切换浅色主题'); };
$('editCancel').onclick = requestEditClose; $('chooseEditDir').onclick = () => showToast('原型演示:将打开目录选择器并回填 User Data Dir。'); $('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); }); $('editDialog').addEventListener('cancel', e => { e.preventDefault(); requestEditClose(); }); $('keepEditing').onclick = () => { $('discardDialog').close(); $('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0); }; $('discardChanges').onclick = () => { $('discardDialog').close(); finishEdit(); }; $('saveChanges').onclick = saveEdit;
document.addEventListener('keydown', e => { if (e.key === 'F5' && !$('editDialog').open && !$('discardDialog').open) { e.preventDefault(); refreshInstances(); } });
document.addEventListener('keydown', e => { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') { e.preventDefault(); queueUnexpectedExit('inst-chrome-shop'); return; } if (e.key === 'F5' && !$('editDialog').open && !$('discardDialog').open) { e.preventDefault(); refreshInstances(); } });
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)); });