feat: add read-only CDP target viewing
This commit is contained in:
@@ -130,6 +130,7 @@ func runWindow(logger *slog.Logger) {
|
||||
externalProfiles: externalProfiles,
|
||||
remoteDebug: cdp,
|
||||
}.Refresh, window.Invalidate)
|
||||
shell.OnInspectTabs(instanceTabsInspector{remoteDebug: cdp}.Inspect, window.Invalidate)
|
||||
shell.OnPathSearch(func(ctx context.Context, field ui.PathField, current string) (string, error) {
|
||||
switch field {
|
||||
case ui.PathChromeExecutable:
|
||||
@@ -362,6 +363,37 @@ type instanceStatusRefresher struct {
|
||||
remoteDebug browser.RemoteDebugEndpointInspector
|
||||
}
|
||||
|
||||
// instanceTabsInspector is a read-only adapter. It accepts only a UI row that
|
||||
// already has a verified runtime port and maps platform targets into UI DTOs.
|
||||
type instanceTabsInspector struct {
|
||||
remoteDebug browser.RemoteDebugTargetInspector
|
||||
}
|
||||
|
||||
func (i instanceTabsInspector) Inspect(ctx context.Context, row ui.InstanceRow) ([]ui.CDPTarget, error) {
|
||||
if i.remoteDebug == nil {
|
||||
return nil, errors.New("CDP 页面目标服务尚未准备好")
|
||||
}
|
||||
if row.Status != "运行中" && row.Status != "外部已关联" {
|
||||
return nil, errors.New("实例需要处于已验证的运行或外部关联状态")
|
||||
}
|
||||
if !domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
|
||||
return nil, errors.New("实例没有可验证的本地调试端口")
|
||||
}
|
||||
kind, err := browserKind(row.Browser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets, err := i.remoteDebug.ListRemoteDebugTargets(ctx, kind, row.RemoteDebugPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CDP 页面目标失败:%w", err)
|
||||
}
|
||||
result := make([]ui.CDPTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
result = append(result, ui.CDPTarget{ID: target.ID, Type: target.Type, Title: target.Title, URL: target.URL})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s instanceStatusRefresher) Refresh(ctx context.Context, rows []ui.InstanceRow) ([]ui.InstanceRefreshResult, error) {
|
||||
results := make([]ui.InstanceRefreshResult, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
|
||||
@@ -248,6 +248,24 @@ func TestInstanceStatusRefresherAssociatesExternalWithoutLifecycleControl(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceTabsInspectorReadsOnlyVerifiedRuntimeRows(t *testing.T) {
|
||||
remote := &fakeRemoteDebugInspector{targets: []browser.RemoteDebugTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}}
|
||||
inspector := instanceTabsInspector{remoteDebug: remote}
|
||||
targets, err := inspector.Inspect(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", Status: "运行中", RemoteDebugPort: 9666})
|
||||
if err != nil || len(targets) != 1 || targets[0].ID != "page-1" || targets[0].URL != "https://example.com/orders" {
|
||||
t.Fatalf("Inspect() = %#v, %v", targets, err)
|
||||
}
|
||||
if remote.targetCalls != 1 || remote.targetKind != domain.BrowserChrome || remote.targetPort != 9666 {
|
||||
t.Fatalf("target inspector invocation = %#v", remote)
|
||||
}
|
||||
if _, err := inspector.Inspect(context.Background(), ui.InstanceRow{ID: "stopped", Browser: "Chrome", Status: "已退出", RemoteDebugPort: 9666}); err == nil {
|
||||
t.Fatal("Inspect() accepted a non-running instance")
|
||||
}
|
||||
if remote.targetCalls != 1 {
|
||||
t.Fatalf("non-running instance reached CDP target inspector %d times", remote.targetCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeExecutableResolver struct {
|
||||
path string
|
||||
kind domain.BrowserKind
|
||||
@@ -371,6 +389,11 @@ type fakeRemoteDebugInspector struct {
|
||||
port int
|
||||
allocatedPorts []int
|
||||
allocationStarts []int
|
||||
targets []browser.RemoteDebugTarget
|
||||
targetErr error
|
||||
targetKind domain.BrowserKind
|
||||
targetPort int
|
||||
targetCalls int
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) {
|
||||
@@ -401,3 +424,10 @@ func (i *fakeRemoteDebugInspector) FindAvailableRemoteDebugPort(_ context.Contex
|
||||
}
|
||||
return start, nil
|
||||
}
|
||||
|
||||
func (i *fakeRemoteDebugInspector) ListRemoteDebugTargets(_ context.Context, kind domain.BrowserKind, port int) ([]browser.RemoteDebugTarget, error) {
|
||||
i.targetCalls++
|
||||
i.targetKind = kind
|
||||
i.targetPort = port
|
||||
return append([]browser.RemoteDebugTarget(nil), i.targets...), i.targetErr
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
|
||||
| ID | 任务 | 依赖 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-401 | 已验证实例的 CDP 页面目标只读查看 | T-302,T-303,T-310 | DOING |
|
||||
| T-401 | 已验证实例的 CDP 页面目标只读查看 | T-302,T-303,T-310 | DONE |
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
## 快照
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 阶段:Phase 4 CDP 只读观察(T-301 至 T-310、T-201 至 T-208 已完成;T-401 进行中)
|
||||
- 代码:已建立 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-309 已完成
|
||||
- 阶段:Phase 4 CDP 只读观察(T-301 至 T-310、T-201 至 T-208、T-401 已完成)
|
||||
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 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-310、T-401 已完成
|
||||
- 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
|
||||
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
|
||||
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
|
||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||
- T-309:实例保存 `PreferredRemoteDebugPort`,旧 JSON 读取时按稳定顺序补齐;配置拒绝无效或重复端口。推荐仅基于保存实例快照,启动后台跳过其他实例的逻辑预留和当前实际端口;成功的本地端口回退会保存为新的首选值。实际 CDP 端点仍是运行时端口唯一事实来源。
|
||||
- T-310:实例新建页使用外层工作区、字段组件和操作区三级边框;编辑弹层使用可辨外框,并为字段、运行态信息、反馈和操作区添加一致描边。紧凑窗口下沿用原有路径/操作重排,字段状态和键盘交互未改变。
|
||||
- blocker:无;T-401 正在实现已验证实例的只读 CDP 页面目标查看。
|
||||
- T-401:仅在运行中或外部已关联实例已有有效实际端口时,编辑弹层可打开“标签页(CDP 页面目标)”只读模态层。后台先复核 loopback `/json/version` 的浏览器类型,再读取有限的 `/json/list`;只显示 ID、类型、标题和移除 userinfo/query/fragment 的 URL,不暴露 WebSocket 地址、不建立 WebSocket、也不执行浏览器控制。读取、错误、空列表和端口/状态变化都有反馈,Escape/关闭后焦点回到入口。
|
||||
- blocker:无。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ id: T-401
|
||||
title: 已验证实例的 CDP 页面目标只读查看
|
||||
phase: 4
|
||||
deps: [T-302, T-303, T-310]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-27
|
||||
owner: codex
|
||||
---
|
||||
@@ -32,7 +32,7 @@ owner: codex
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 状态:DOING
|
||||
- 变更:待实施。
|
||||
- 验证:待执行。
|
||||
- 状态:DONE
|
||||
- 变更:新增受限 `RemoteDebugTargetInspector`,先以 `/json/version` 复核 browser kind,再只读获取 `127.0.0.1:{actualPort}/json/list`;HTTP transport 禁用代理、拒绝重定向,响应限制为 1 MiB / 200 个目标,DTO 仅含 ID、类型、标题与脱敏 URL。主程序 adapter 仅接受运行中或外部已关联实例。Gio 编辑弹层新增“查看标签页”入口与只读目标模态层,后台异步读取并按实例 ID、状态/端口快照、请求代次拒绝过期结果;Escape 或关闭按钮会返回入口焦点。HTML 原型同步展示读取中、已读取、只读边界和关闭反馈。
|
||||
- 验证:已执行 `gofmt -w internal/platform/browser/cdp.go internal/platform/browser/cdp_test.go cmd/chub/main.go cmd/chub/main_test.go internal/ui/shell.go internal/ui/shell_test.go`、`go test ./...`、`go test -race ./cmd/chub ./internal/ui`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、Node HTML T-401 原型检查和 `powershell -ExecutionPolicy Bypass -File scripts/smoke-windows.ps1`,均通过。
|
||||
- 阻塞:无。
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
6. 使用搜索、浏览器筛选、状态筛选和刷新状态。
|
||||
7. 切换深色主题,并在窄窗口查看导航和表单重排。
|
||||
8. 点击“设置”:检查四个路径的 Label、选择/搜索按钮;搜索期间按钮显示“取消”,再次点击取消当前搜索,切换页面后状态保持。
|
||||
9. 双击“运行中”或“外部已关联”实例,点击“查看标签页”:检查读取中、目标列表、只读边界文案与 Escape/关闭后回到入口。未启动或没有实际端口的实例应禁用入口。
|
||||
|
||||
## 当前原型假设
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
- 设置作为独立顶级页面,采用显式提交;取消只恢复本次未保存的表单修改。
|
||||
- 路径搜索不进行默认全盘扫描:浏览器搜索标准安装路径,目录搜索验证当前输入;新建实例的 User Data Dir 已接入 Windows 原生选择器。
|
||||
- 外部实例显示占用证据,但不提供接管或强制关闭入口。
|
||||
- “标签页(CDP 页面目标)”只读取已验证 loopback CDP 端点的展示 DTO;不会显示 WebSocket 调试地址,不提供任何页面或浏览器控制。它不是物理窗口管理视图。
|
||||
- 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。
|
||||
|
||||
## Gio 交接方向
|
||||
@@ -38,4 +40,5 @@
|
||||
| 路径搜索 | 独立任务 ID、取消函数与结果通道;迟到结果按任务 ID 丢弃 |
|
||||
| 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 |
|
||||
| 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 |
|
||||
| CDP 页面目标 | 异步只读 DTO、ID/端口/请求快照防过期、Escape 关闭后返回入口焦点 |
|
||||
| Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 |
|
||||
|
||||
@@ -160,7 +160,8 @@
|
||||
.activity-item { display: grid; grid-template-columns: 9px 1fr; gap: 9px; font-size: 12px; color: var(--muted); }
|
||||
.activity-item i { width: 8px; height: 8px; background: var(--accent); border-radius: 50%; margin-top: 4px; }
|
||||
dialog { border: 0; border-radius: var(--radius-dialog); padding: 0; width: min(430px, calc(100vw - 32px)); color: var(--text); background: var(--surface); box-shadow: var(--shadow); }
|
||||
dialog.edit-dialog { width: min(620px, calc(100vw - 32px)); padding: 10px; border: 1px solid var(--line-strong); background: var(--surface-2); }
|
||||
dialog.edit-dialog, dialog.tabs-dialog { width: min(620px, calc(100vw - 32px)); padding: 10px; border: 1px solid var(--line-strong); background: var(--surface-2); }
|
||||
dialog.tabs-dialog { width: min(720px, calc(100vw - 32px)); }
|
||||
dialog::backdrop { background: rgba(16, 24, 40, .42); }
|
||||
.dialog-inner { padding: 24px; display: grid; gap: 17px; }
|
||||
.dialog-inner h2 { margin: 0; font-size: 19px; }
|
||||
@@ -175,6 +176,12 @@
|
||||
.edit-form .form-note { border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--line)); }
|
||||
.edit-form .dialog-actions { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
||||
.edit-form .path-field { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.tabs-meta { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.tabs-state { min-height: 42px; padding: 11px 12px; border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); border-radius: 9px; background: var(--accent-soft); color: var(--accent-strong); font-size: 12px; line-height: 1.45; }
|
||||
.tabs-target-list { max-height: min(300px, 42vh); overflow: auto; display: grid; gap: 7px; }
|
||||
.tabs-target { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); display: grid; gap: 3px; }
|
||||
.tabs-target small { color: var(--muted); }
|
||||
.tabs-target strong, .tabs-target code { overflow-wrap: anywhere; }
|
||||
.readonly-value { background: var(--surface-3) !important; color: var(--muted) !important; cursor: default; }
|
||||
.form-note { padding: 10px 12px; border-radius: 9px; background: var(--warning-soft); color: var(--warning); font-size: 12px; line-height: 1.5; }
|
||||
.empty-state { display: none; text-align: center; padding: 48px 20px; color: var(--muted); }
|
||||
@@ -291,7 +298,8 @@
|
||||
<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="editPreferredPort">首选调试端口</label><input id="editPreferredPort" type="number" min="1024" max="65535" inputmode="numeric"><small>下次启动优先使用;与其他 Chub 实例重复时会提示修改。</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 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="editPreferredPort">首选调试端口</label><input id="editPreferredPort" type="number" min="1024" max="65535" inputmode="numeric"><small>下次启动优先使用;与其他 Chub 实例重复时会提示修改。</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="editTabs">查看标签页</button><button type="button" class="secondary" id="editCancel">取消</button><button type="submit" class="primary" id="editSave">保存更改</button></div></form></dialog>
|
||||
<dialog class="tabs-dialog" id="tabsDialog" aria-labelledby="tabsTitle" aria-describedby="tabsMeta"><div class="dialog-inner"><div><h2 id="tabsTitle">标签页(CDP 页面目标)</h2><p id="tabsSubtitle">只读查看经验证的本机 CDP 页面目标。</p></div><div class="tabs-meta" id="tabsMeta">不会显示 WebSocket 地址,也不会导航、刷新、关闭页面或执行脚本。</div><div class="tabs-state" id="tabsState" role="status" aria-live="polite">正在读取已验证的本地 CDP 页面目标…</div><div class="tabs-target-list" id="tabsTargetList" hidden></div><div class="dialog-actions"><button class="primary" id="tabsClose">关闭</button></div></div></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>
|
||||
@@ -315,6 +323,7 @@
|
||||
let selectedProxyID = '';
|
||||
let unexpectedExitIDs = [];
|
||||
let unexpectedExitFocusID = '';
|
||||
let tabsRequest = 0;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const statusClass = (s) => s;
|
||||
const proxyLabel = (id) => proxies.find(p => p.id === id)?.server || '无代理';
|
||||
@@ -350,13 +359,14 @@
|
||||
return $('editName').value !== editSnapshot.name || $('editDataDir').value !== editSnapshot.dir || $('editURL').value !== editSnapshot.url || $('editPreferredPort').value !== String(editSnapshot.preferredPort) || $('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 finishEdit() { if ($('tabsDialog').open) $('tabsDialog').close(); 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, preferredPort: x.preferredPort || '', proxyId: x.proxyId || '' };
|
||||
const locked = editIsLocked(x); $('editName').value = x.name; $('editDataDir').value = x.dir; $('editURL').value = x.url || ''; $('editPreferredPort').value = x.preferredPort || ''; 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; $('editPreferredPort').disabled = locked; $('editReadOnlyNote').hidden = !locked;
|
||||
const canInspectTabs = ['running', 'associated'].includes(x.status) && Number(x.port) > 0;
|
||||
$('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editPreferredPort').disabled = locked; $('editTabs').disabled = !canInspectTabs; $('editTabs').title = canInspectTabs ? '只读查看已验证的 CDP 页面目标' : '查看标签页需要已验证的运行或外部关联状态和实际调试端口'; $('editReadOnlyNote').hidden = !locked;
|
||||
$('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0);
|
||||
}
|
||||
function saveEdit() {
|
||||
@@ -368,6 +378,25 @@
|
||||
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
|
||||
}
|
||||
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); }
|
||||
function closeTabs() { tabsRequest++; if ($('tabsDialog').open) $('tabsDialog').close(); if ($('editDialog').open) setTimeout(() => $('editTabs').focus(), 0); }
|
||||
function openTabs() {
|
||||
const x = instances.find(i => i.id === editingID);
|
||||
if (!x || !['running', 'associated'].includes(x.status) || !Number(x.port)) { showToast('查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。'); return; }
|
||||
const request = ++tabsRequest;
|
||||
$('tabsTitle').textContent = '标签页(CDP 页面目标)';
|
||||
$('tabsSubtitle').textContent = `${x.name} · 实际端口 ${x.port} · 只读查看`;
|
||||
$('tabsState').hidden = false; $('tabsState').textContent = '正在读取已验证的本地 CDP 页面目标…'; $('tabsTargetList').hidden = true; $('tabsTargetList').innerHTML = '';
|
||||
$('tabsDialog').showModal(); setTimeout(() => $('tabsClose').focus(), 0);
|
||||
setTimeout(() => {
|
||||
if (request !== tabsRequest || !$('tabsDialog').open || editingID !== x.id) return;
|
||||
const targets = x.kind === 'edge'
|
||||
? [{ type: 'page', title: '素材审核', url: 'https://example.com/review' }, { type: 'service_worker', title: '(无标题)', url: 'chrome-extension://example/worker.js' }]
|
||||
: [{ type: 'page', title: '运营后台', url: 'https://seller.shopee.sg/' }, { type: 'page', title: '订单中心', url: 'https://seller.shopee.sg/orders' }];
|
||||
$('tabsState').textContent = targets.length ? '已读取只读 CDP 页面目标。URL 已移除用户信息、查询参数和片段。' : '未发现可显示的 CDP 页面目标。';
|
||||
$('tabsTargetList').innerHTML = targets.map(target => `<article class="tabs-target"><small>${target.type}</small><strong>${target.title}</strong><code>${target.url}</code></article>`).join('');
|
||||
$('tabsTargetList').hidden = !targets.length;
|
||||
}, 380);
|
||||
}
|
||||
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 instance-form-workspace" 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="launchPreferredPort">首选调试端口</label><input id="launchPreferredPort" type="number" min="1024" max="65535" value="9670" inputmode="numeric"><small>系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。</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'), port = Number($('launchPreferredPort').value); 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; } if (!Number.isInteger(port) || port < 1024 || port > 65535) { $('launchPreferredPort').focus(); showToast('请填写有效的首选调试端口。'); return; } const proxy = proxyLabel($('launchProxy').value); hideDrawer(); setTimeout(() => showToast(`启动请求已提交(首选端口 ${port};${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'); }
|
||||
@@ -407,8 +436,8 @@
|
||||
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.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(); } });
|
||||
$('editCancel').onclick = requestEditClose; $('editTabs').onclick = openTabs; $('tabsClose').onclick = closeTabs; $('tabsDialog').addEventListener('cancel', e => { e.preventDefault(); closeTabs(); }); $('tabsDialog').addEventListener('close', () => { if ($('editDialog').open) setTimeout(() => $('editTabs').focus(), 0); }); $('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.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') { e.preventDefault(); queueUnexpectedExit('inst-chrome-shop'); return; } if (e.key === 'F5' && !$('editDialog').open && !$('tabsDialog').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)); });
|
||||
|
||||
@@ -5,18 +5,26 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
const remoteDebugPortAttempts = 100
|
||||
const (
|
||||
remoteDebugPortAttempts = 100
|
||||
remoteDebugTargetsMaxBytes = 1 << 20
|
||||
remoteDebugTargetsMaxCount = 200
|
||||
remoteDebugTargetTextMax = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
||||
@@ -28,6 +36,16 @@ type RemoteDebugEndpoint struct {
|
||||
Browser string
|
||||
}
|
||||
|
||||
// RemoteDebugTarget is a read-only summary of one target returned by CDP
|
||||
// /json/list. URL is safe to display but must never be treated as a control
|
||||
// endpoint; its userinfo, query, and fragment have been removed.
|
||||
type RemoteDebugTarget struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
type RemoteDebugEndpointInspector interface {
|
||||
InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
@@ -39,6 +57,12 @@ type RemoteDebugPortAllocator interface {
|
||||
FindAvailableRemoteDebugPort(context.Context, int) (int, error)
|
||||
}
|
||||
|
||||
// RemoteDebugTargetInspector reads CDP targets only from a verified loopback
|
||||
// endpoint. It intentionally exposes no WebSocket URL or browser-control API.
|
||||
type RemoteDebugTargetInspector interface {
|
||||
ListRemoteDebugTargets(context.Context, domain.BrowserKind, int) ([]RemoteDebugTarget, error)
|
||||
}
|
||||
|
||||
type CDPInspector struct {
|
||||
readFile func(string) ([]byte, error)
|
||||
client *http.Client
|
||||
@@ -47,7 +71,10 @@ type CDPInspector struct {
|
||||
func NewCDPInspector() *CDPInspector {
|
||||
return &CDPInspector{
|
||||
readFile: os.ReadFile,
|
||||
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +143,66 @@ func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.B
|
||||
})
|
||||
}
|
||||
|
||||
// ListRemoteDebugTargets first validates the browser kind at the supplied
|
||||
// loopback port, then reads its bounded /json/list response. It never follows
|
||||
// redirects or exposes the response's webSocketDebuggerUrl field.
|
||||
func (i *CDPInspector) ListRemoteDebugTargets(ctx context.Context, kind domain.BrowserKind, port int) ([]RemoteDebugTarget, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) {
|
||||
return nil, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
if _, err := i.InspectRemoteDebugPort(ctx, kind, port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/list", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build target request", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
response, err := i.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: query targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: target status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, remoteDebugTargetsMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(data) > remoteDebugTargetsMaxBytes {
|
||||
return nil, fmt.Errorf("%w: target response exceeds %d bytes", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxBytes)
|
||||
}
|
||||
var rawTargets []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rawTargets); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(rawTargets) > remoteDebugTargetsMaxCount {
|
||||
return nil, fmt.Errorf("%w: target count exceeds %d", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxCount)
|
||||
}
|
||||
targets := make([]RemoteDebugTarget, 0, len(rawTargets))
|
||||
for _, raw := range rawTargets {
|
||||
id := strings.TrimSpace(raw.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, RemoteDebugTarget{
|
||||
ID: truncateRemoteDebugTargetText(id),
|
||||
Type: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Type)),
|
||||
Title: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Title)),
|
||||
URL: sanitizeRemoteDebugTargetURL(raw.URL),
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) {
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
for {
|
||||
@@ -180,3 +267,26 @@ func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeRemoteDebugTargetURL(value string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed == nil {
|
||||
return ""
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
return truncateRemoteDebugTargetText(parsed.String())
|
||||
}
|
||||
|
||||
func truncateRemoteDebugTargetText(value string) string {
|
||||
if len(value) <= remoteDebugTargetTextMax {
|
||||
return value
|
||||
}
|
||||
cut := remoteDebugTargetTextMax
|
||||
for cut > 0 && !utf8.RuneStart(value[cut]) {
|
||||
cut--
|
||||
}
|
||||
return value[:cut]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chub/internal/domain"
|
||||
@@ -93,3 +94,76 @@ func TestCDPInspectorFindsNextAvailablePort(t *testing.T) {
|
||||
t.Fatalf("port = %d, want %d", port, start+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorListsVerifiedTargetsWithoutControlEndpoints(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/json/version":
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
case "/json/list":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"page-1","type":"page","title":"运营后台","url":"https://user:secret@example.com/orders?token=hidden#section","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/page/page-1"},
|
||||
{"id":"worker-1","type":"service_worker","title":"","url":"chrome-extension://example/worker.js?key=hidden"}
|
||||
]`))
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
|
||||
targets, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("ListRemoteDebugTargets() = %#v, %v", targets, err)
|
||||
}
|
||||
if targets[0].ID != "page-1" || targets[0].Type != "page" || targets[0].Title != "运营后台" || targets[0].URL != "https://example.com/orders" {
|
||||
t.Fatalf("first target = %#v", targets[0])
|
||||
}
|
||||
if targets[1].URL != "chrome-extension://example/worker.js" {
|
||||
t.Fatalf("second target URL = %q", targets[1].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorRejectsMalformedOrOversizedTargetList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
}{
|
||||
{name: "malformed JSON", status: http.StatusOK, response: `not-json`},
|
||||
{name: "too many targets", status: http.StatusOK, response: `[` + strings.Repeat(`{"id":"page","type":"page"},`, remoteDebugTargetsMaxCount) + `{"id":"last","type":"page"}]`},
|
||||
{name: "non-success status", status: http.StatusServiceUnavailable, response: `temporarily unavailable`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/json/version" {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(test.status)
|
||||
_, _ = w.Write([]byte(test.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
if _, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("ListRemoteDebugTargets() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+296
-2
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
"gioui.org/io/key"
|
||||
@@ -113,6 +114,19 @@ type InstanceRefreshResult struct {
|
||||
// browser processes.
|
||||
type InstanceRefresher func(context.Context, []InstanceRow) ([]InstanceRefreshResult, error)
|
||||
|
||||
// CDPTarget contains the safe, read-only fields displayed for one Chromium
|
||||
// target. URL must already be stripped of userinfo, query, and fragment.
|
||||
type CDPTarget struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
// InstanceTabInspector performs a bounded, read-only target lookup for one
|
||||
// current instance snapshot. It must not send browser-control commands.
|
||||
type InstanceTabInspector func(context.Context, InstanceRow) ([]CDPTarget, error)
|
||||
|
||||
type instanceStartState struct {
|
||||
request uint64
|
||||
running bool
|
||||
@@ -149,6 +163,14 @@ type instanceRefreshResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type instanceTabsResult struct {
|
||||
request uint64
|
||||
instanceID string
|
||||
fingerprint string
|
||||
targets []CDPTarget
|
||||
err error
|
||||
}
|
||||
|
||||
type managedExitKey struct {
|
||||
instanceID string
|
||||
launchGeneration uint64
|
||||
@@ -280,6 +302,7 @@ type Shell struct {
|
||||
editPort widget.Editor
|
||||
editBrowserKind widget.Enum
|
||||
editDirPick widget.Clickable
|
||||
editTabs widget.Clickable
|
||||
editSave widget.Clickable
|
||||
editCancel widget.Clickable
|
||||
editBlocker widget.Clickable
|
||||
@@ -311,6 +334,7 @@ type Shell struct {
|
||||
|
||||
list widget.List
|
||||
settingsList widget.List
|
||||
tabsList widget.List
|
||||
rows []InstanceRow
|
||||
selectedInstanceID string
|
||||
rowClicks map[string]*widget.Clickable
|
||||
@@ -326,6 +350,7 @@ type Shell struct {
|
||||
stopResults chan instanceStopResult
|
||||
refreshClickState instanceRefreshState
|
||||
refreshResults chan instanceRefreshResult
|
||||
tabsResults chan instanceTabsResult
|
||||
managedExitMu sync.Mutex
|
||||
managedExitResults []ManagedInstanceExit
|
||||
pendingManagedExit map[managedExitKey]ManagedInstanceExit
|
||||
@@ -344,12 +369,25 @@ type Shell struct {
|
||||
editFocusPending bool
|
||||
focusRestoreID string
|
||||
focusStartID string
|
||||
tabsOpen bool
|
||||
tabsLoading bool
|
||||
tabsRequest uint64
|
||||
tabsInstanceID string
|
||||
tabsFingerprint string
|
||||
tabsTargets []CDPTarget
|
||||
tabsFeedback string
|
||||
tabsCancel context.CancelFunc
|
||||
tabsClose widget.Clickable
|
||||
tabsBlocker widget.Clickable
|
||||
tabsFocusPending bool
|
||||
tabsReturnFocus bool
|
||||
onSave func(SettingsState)
|
||||
onInstancesChanged func([]InstanceRow)
|
||||
onProxiesChanged func([]ProxyOption)
|
||||
instanceStarter InstanceStarter
|
||||
instanceStopper InstanceStopper
|
||||
instanceRefresher InstanceRefresher
|
||||
instanceTabInspector InstanceTabInspector
|
||||
pathSearcher PathSearcher
|
||||
invalidate func()
|
||||
searches map[PathField]*pathSearchState
|
||||
@@ -370,7 +408,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
PathEdgeExecutable: {},
|
||||
PathDefaultUserData: {},
|
||||
PathLogDirectory: {},
|
||||
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
|
||||
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
|
||||
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
@@ -383,6 +421,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
s.editPort.ReadOnly = true
|
||||
s.list.Axis = layout.Vertical
|
||||
s.settingsList.Axis = layout.Vertical
|
||||
s.tabsList.Axis = layout.Vertical
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -471,6 +510,11 @@ func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func(
|
||||
s.invalidate = invalidate
|
||||
}
|
||||
|
||||
func (s *Shell) OnInspectTabs(inspector InstanceTabInspector, invalidate func()) {
|
||||
s.instanceTabInspector = inspector
|
||||
s.invalidate = invalidate
|
||||
}
|
||||
|
||||
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
|
||||
s.pathSearcher = searcher
|
||||
s.invalidate = invalidate
|
||||
@@ -487,6 +531,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
s.consumeStartResults()
|
||||
s.consumeStopResults()
|
||||
s.consumeRefreshResults()
|
||||
s.consumeTabsResults()
|
||||
s.consumeManagedExitResults()
|
||||
s.presentUnexpectedExitIfReady()
|
||||
s.consumeKeyboard(gtx)
|
||||
@@ -510,7 +555,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
)
|
||||
}
|
||||
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
|
||||
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open && !s.tabsOpen {
|
||||
return mainLayout(gtx)
|
||||
}
|
||||
if s.unexpectedExitOpen {
|
||||
@@ -539,6 +584,13 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
if s.editingID != "" {
|
||||
if s.tabsOpen {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(mainLayout),
|
||||
layout.Stacked(s.editDialog),
|
||||
layout.Stacked(s.tabsDialog),
|
||||
)
|
||||
}
|
||||
if !s.proxyPicker.open {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(mainLayout),
|
||||
@@ -628,6 +680,8 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
|
||||
s.cancelProxyDelete()
|
||||
} else if s.proxyPicker.open {
|
||||
s.proxyPicker.open = false
|
||||
} else if s.tabsOpen {
|
||||
s.closeTabs(true)
|
||||
} else if s.pendingEditDiscard {
|
||||
s.pendingEditDiscard = false
|
||||
} else if s.editingID != "" {
|
||||
@@ -663,6 +717,14 @@ func (s *Shell) consumeRowKeyboard(gtx layout.Context, id string) {
|
||||
func (s *Shell) consumeEditControls(gtx layout.Context) {
|
||||
for s.editBlocker.Clicked(gtx) {
|
||||
}
|
||||
if s.tabsOpen {
|
||||
for s.tabsBlocker.Clicked(gtx) {
|
||||
}
|
||||
for s.tabsClose.Clicked(gtx) {
|
||||
s.closeTabs(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.pendingEditDiscard {
|
||||
for s.editDiscardBlocker.Clicked(gtx) {
|
||||
}
|
||||
@@ -683,12 +745,16 @@ func (s *Shell) consumeEditControls(gtx layout.Context) {
|
||||
for s.editCancel.Clicked(gtx) {
|
||||
s.cancelEdit()
|
||||
}
|
||||
for s.editTabs.Clicked(gtx) {
|
||||
s.requestTabs()
|
||||
}
|
||||
for s.editSave.Clicked(gtx) {
|
||||
s.saveEdit()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) beginEdit(id string) {
|
||||
s.closeTabs(false)
|
||||
row, ok := s.instanceRow(id)
|
||||
if !ok {
|
||||
s.instanceFeedback = "找不到要编辑的实例。"
|
||||
@@ -810,6 +876,7 @@ func normalizeTargetURL(value string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *Shell) closeEdit(feedback string) {
|
||||
s.closeTabs(false)
|
||||
id := s.editingID
|
||||
s.editingID = ""
|
||||
s.pendingEditDiscard = false
|
||||
@@ -1223,6 +1290,10 @@ func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.D
|
||||
|
||||
func (s *Shell) editFeedbackMessage(locked bool) string {
|
||||
label := s.editFeedback
|
||||
row, exists := s.instanceRow(s.editingID)
|
||||
if label == "" && (!exists || !s.canInspectTabs(row)) {
|
||||
label = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
||||
}
|
||||
if label == "" && locked {
|
||||
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
|
||||
}
|
||||
@@ -1230,7 +1301,25 @@ func (s *Shell) editFeedbackMessage(locked bool) string {
|
||||
}
|
||||
|
||||
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
||||
row, canInspect := s.instanceRow(s.editingID)
|
||||
canInspect = canInspect && s.canInspectTabs(row) && !s.tabsLoading
|
||||
tabsGtx := gtx
|
||||
if !canInspect {
|
||||
tabsGtx = gtx.Disabled()
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.editTabs, "查看标签页")
|
||||
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
|
||||
style.Color = s.theme.Palette.Fg
|
||||
dims := style.Layout(tabsGtx)
|
||||
if s.tabsReturnFocus {
|
||||
gtx.Execute(key.FocusCmd{Tag: &s.editTabs})
|
||||
s.tabsReturnFocus = false
|
||||
}
|
||||
return dims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.editCancel, "取消")
|
||||
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
|
||||
@@ -1242,6 +1331,211 @@ func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) canInspectTabs(row InstanceRow) bool {
|
||||
return domain.ValidRemoteDebugPort(row.RemoteDebugPort) && (row.Status == "运行中" || row.Status == "外部已关联")
|
||||
}
|
||||
|
||||
func tabsSnapshotFingerprint(row InstanceRow) string {
|
||||
return strings.Join([]string{row.ID, row.Browser, row.Status, strconv.Itoa(row.RemoteDebugPort)}, "\x00")
|
||||
}
|
||||
|
||||
func (s *Shell) requestTabs() {
|
||||
row, exists := s.instanceRow(s.editingID)
|
||||
if !exists || !s.canInspectTabs(row) {
|
||||
s.editFeedback = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
||||
return
|
||||
}
|
||||
if s.tabsLoading {
|
||||
return
|
||||
}
|
||||
s.tabsRequest++
|
||||
request := s.tabsRequest
|
||||
fingerprint := tabsSnapshotFingerprint(row)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
s.tabsCancel = cancel
|
||||
s.tabsOpen = true
|
||||
s.tabsLoading = true
|
||||
s.tabsInstanceID = row.ID
|
||||
s.tabsFingerprint = fingerprint
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = ""
|
||||
s.tabsFocusPending = true
|
||||
if s.instanceTabInspector == nil {
|
||||
cancel()
|
||||
s.tabsCancel = nil
|
||||
s.tabsLoading = false
|
||||
s.tabsFeedback = "CDP 页面目标服务尚未准备好。"
|
||||
return
|
||||
}
|
||||
inspector := s.instanceTabInspector
|
||||
invalidate := s.invalidate
|
||||
go func() {
|
||||
targets, err := inspector(ctx, row)
|
||||
cancel()
|
||||
s.tabsResults <- instanceTabsResult{request: request, instanceID: row.ID, fingerprint: fingerprint, targets: targets, err: err}
|
||||
if invalidate != nil {
|
||||
invalidate()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Shell) consumeTabsResults() {
|
||||
for {
|
||||
select {
|
||||
case result := <-s.tabsResults:
|
||||
if !s.tabsOpen || result.request != s.tabsRequest || result.instanceID != s.tabsInstanceID || result.fingerprint != s.tabsFingerprint {
|
||||
continue
|
||||
}
|
||||
s.tabsLoading = false
|
||||
s.tabsCancel = nil
|
||||
row, exists := s.instanceRow(result.instanceID)
|
||||
if !exists || !s.canInspectTabs(row) || tabsSnapshotFingerprint(row) != result.fingerprint {
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = "实例状态或调试端口已经变化;请关闭后重新打开标签页。"
|
||||
continue
|
||||
}
|
||||
if result.err != nil {
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = fmt.Sprintf("无法读取标签页:%v", result.err)
|
||||
continue
|
||||
}
|
||||
s.tabsTargets = append([]CDPTarget(nil), result.targets...)
|
||||
s.tabsFeedback = ""
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) closeTabs(returnFocus bool) {
|
||||
if s.tabsCancel != nil {
|
||||
s.tabsCancel()
|
||||
s.tabsCancel = nil
|
||||
}
|
||||
s.tabsRequest++
|
||||
s.tabsOpen = false
|
||||
s.tabsLoading = false
|
||||
s.tabsInstanceID = ""
|
||||
s.tabsFingerprint = ""
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = ""
|
||||
s.tabsFocusPending = false
|
||||
if returnFocus && s.editingID != "" {
|
||||
s.tabsReturnFocus = true
|
||||
} else if !returnFocus {
|
||||
s.tabsReturnFocus = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) tabsDialog(gtx layout.Context) layout.Dimensions {
|
||||
if !s.tabsOpen {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return s.tabsBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 110})
|
||||
return layout.Center.Layout(gtx, s.tabsDialogCard)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) tabsDialogCard(gtx layout.Context) layout.Dimensions {
|
||||
if maxWidth := gtx.Dp(720); gtx.Constraints.Max.X > maxWidth {
|
||||
gtx.Constraints.Max.X = maxWidth
|
||||
}
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rect := image.Rectangle{Max: gtx.Constraints.Min}
|
||||
if rect.Empty() {
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, instanceFormWorkspaceBorder, clip.UniformRRect(rect, gtx.Dp(10)).Op(gtx.Ops))
|
||||
inner := rect.Inset(gtx.Dp(1))
|
||||
if !inner.Empty() {
|
||||
paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(9)).Op(gtx.Ops))
|
||||
}
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
row, _ := s.instanceRow(s.tabsInstanceID)
|
||||
caption := "只读查看经验证的 loopback CDP 页面目标。"
|
||||
if row.Name != "" {
|
||||
caption = fmt.Sprintf("%s · 实际端口 %d · 只读查看", row.Name, row.RemoteDebugPort)
|
||||
}
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(material.H6(s.theme, "标签页(CDP 页面目标)").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Caption(s.theme, caption).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
}
|
||||
if s.tabsLoading {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback("正在读取已验证的本地 CDP 页面目标…")))
|
||||
} else if s.tabsFeedback != "" {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback(s.tabsFeedback)))
|
||||
} else if len(s.tabsTargets) == 0 {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback("未发现可显示的 CDP 页面目标。")))
|
||||
} else {
|
||||
children = append(children, layout.Rigid(s.tabsTargetList))
|
||||
}
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.tabsClose, "关闭")
|
||||
dims := style.Layout(gtx)
|
||||
if s.tabsFocusPending {
|
||||
gtx.Execute(key.FocusCmd{Tag: &s.tabsClose})
|
||||
s.tabsFocusPending = false
|
||||
}
|
||||
return dims
|
||||
})
|
||||
}),
|
||||
)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) tabsTargetList(gtx layout.Context) layout.Dimensions {
|
||||
height := gtx.Dp(280)
|
||||
if gtx.Constraints.Max.Y < height {
|
||||
height = gtx.Constraints.Max.Y
|
||||
}
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return material.List(s.theme, &s.tabsList).Layout(gtx, len(s.tabsTargets), func(gtx layout.Context, index int) layout.Dimensions {
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, s.tabsTargetRow(s.tabsTargets[index]))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) tabsTargetRow(target CDPTarget) layout.Widget {
|
||||
return s.settingsSurface(instanceFormComponentBorder, unit.Dp(6), unit.Dp(8), func(gtx layout.Context) layout.Dimensions {
|
||||
targetType := strings.TrimSpace(target.Type)
|
||||
if targetType == "" {
|
||||
targetType = "page"
|
||||
}
|
||||
title := strings.TrimSpace(target.Title)
|
||||
if title == "" {
|
||||
title = "(无标题)"
|
||||
}
|
||||
url := strings.TrimSpace(target.URL)
|
||||
if url == "" {
|
||||
url = "—"
|
||||
}
|
||||
titleStyle := material.Body1(s.theme, title)
|
||||
titleStyle.MaxLines = 1
|
||||
urlStyle := material.Body2(s.theme, url)
|
||||
urlStyle.MaxLines = 2
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Caption(s.theme, targetType).Layout),
|
||||
layout.Rigid(titleStyle.Layout),
|
||||
layout.Rigid(urlStyle.Layout),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) editDiscardConfirmation(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return s.editDiscardBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
|
||||
@@ -580,6 +580,83 @@ func TestShellIgnoresRefreshResultAfterEditableFieldsChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellReadsTabsAsynchronouslyAndRestoresEditFocus(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
seen := make(chan InstanceRow, 1)
|
||||
shell.OnInspectTabs(func(_ context.Context, row InstanceRow) ([]CDPTarget, error) {
|
||||
seen <- row
|
||||
return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil
|
||||
}, nil)
|
||||
|
||||
shell.beginEdit(target.ID)
|
||||
shell.requestTabs()
|
||||
if !shell.tabsOpen || !shell.tabsLoading || shell.tabsInstanceID != target.ID {
|
||||
t.Fatalf("tabs state after request = open %v loading %v instance %q", shell.tabsOpen, shell.tabsLoading, shell.tabsInstanceID)
|
||||
}
|
||||
select {
|
||||
case row := <-seen:
|
||||
if row.ID != target.ID || row.RemoteDebugPort != target.RemoteDebugPort {
|
||||
t.Fatalf("tabs snapshot = %#v", row)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector was not invoked")
|
||||
}
|
||||
var result instanceTabsResult
|
||||
select {
|
||||
case result = <-shell.tabsResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector did not produce a result")
|
||||
}
|
||||
shell.tabsResults <- result
|
||||
shell.consumeTabsResults()
|
||||
if shell.tabsLoading || shell.tabsFeedback != "" || len(shell.tabsTargets) != 1 || shell.tabsTargets[0].Title != "运营后台" {
|
||||
t.Fatalf("tabs result state = loading %v feedback %q targets %#v", shell.tabsLoading, shell.tabsFeedback, shell.tabsTargets)
|
||||
}
|
||||
shell.closeTabs(true)
|
||||
if shell.tabsOpen || !shell.tabsReturnFocus {
|
||||
t.Fatalf("tabs close state = open %v return focus %v", shell.tabsOpen, shell.tabsReturnFocus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRejectsStaleTabsResultWhenRuntimeChanges(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
shell.OnInspectTabs(func(_ context.Context, _ InstanceRow) ([]CDPTarget, error) {
|
||||
return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil
|
||||
}, nil)
|
||||
shell.beginEdit(target.ID)
|
||||
shell.requestTabs()
|
||||
var result instanceTabsResult
|
||||
select {
|
||||
case result = <-shell.tabsResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector did not produce a result")
|
||||
}
|
||||
shell.rows[0].RemoteDebugPort++
|
||||
shell.tabsResults <- result
|
||||
shell.consumeTabsResults()
|
||||
if len(shell.tabsTargets) != 0 || !strings.Contains(shell.tabsFeedback, "状态或调试端口已经变化") {
|
||||
t.Fatalf("stale tabs result state = targets %#v feedback %q", shell.tabsTargets, shell.tabsFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellAllowsTabInspectionOnlyForVerifiedRuntimeRows(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
if !shell.canInspectTabs(shell.rows[0]) || !shell.canInspectTabs(shell.rows[2]) {
|
||||
t.Fatal("verified running and external rows should allow read-only tabs inspection")
|
||||
}
|
||||
for _, row := range []InstanceRow{
|
||||
{Status: "启动中", RemoteDebugPort: 9666},
|
||||
{Status: "已退出", RemoteDebugPort: 9666},
|
||||
{Status: "运行中", RemoteDebugPort: 0},
|
||||
} {
|
||||
if shell.canInspectTabs(row) {
|
||||
t.Fatalf("row unexpectedly allows tab inspection: %#v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellEditsExitedInstanceAndPreservesRuntimePort(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[3]
|
||||
|
||||
Reference in New Issue
Block a user