feat: add preferred debug port planning

This commit is contained in:
QiuSW
2026-07-27 09:29:10 +08:00
parent 3f382a431d
commit 79630e0d48
10 changed files with 518 additions and 74 deletions
+39 -2
View File
@@ -164,7 +164,7 @@ func runWindow(logger *slog.Logger) {
shell.SetProxies(proxyOptions(saved.Proxies)) shell.SetProxies(proxyOptions(saved.Proxies))
rows := make([]ui.InstanceRow, 0, len(saved.Instances)) rows := make([]ui.InstanceRow, 0, len(saved.Instances))
for _, item := range saved.Instances { for _, item := range saved.Instances {
rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, ProxyID: item.ProxyID, Status: "已退出"}) rows = append(rows, ui.InstanceRow{ID: item.ID, Name: item.Name, Browser: browserLabel(item.Launch.Kind), UserDataDir: item.Launch.UserDataDir, TargetURL: item.Launch.TargetURL, ProxyID: item.ProxyID, PreferredRemoteDebugPort: item.PreferredRemoteDebugPort, Status: "已退出"})
} }
shell.SetInstances(rows) shell.SetInstances(rows)
shell.OnSave(func(value ui.SettingsState) { shell.OnSave(func(value ui.SettingsState) {
@@ -493,7 +493,11 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
if err != nil { if err != nil {
return ui.InstanceStartOutcome{}, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err) return ui.InstanceStartOutcome{}, fmt.Errorf("无法找到%s可执行文件:%w", browserLabel(kind), err)
} }
port, err := s.portAllocator.FindAvailableRemoteDebugPort(ctx, settings.RemoteDebugStartPort) startPort := row.PreferredRemoteDebugPort
if !domain.ValidRemoteDebugPort(startPort) {
startPort = settings.RemoteDebugStartPort
}
port, err := findAvailableUnreservedRemoteDebugPort(ctx, s.portAllocator, startPort, settings.ReservedRemoteDebugPorts)
if err != nil { if err != nil {
return ui.InstanceStartOutcome{}, fmt.Errorf("无法分配本地调试端口:%w", err) return ui.InstanceStartOutcome{}, fmt.Errorf("无法分配本地调试端口:%w", err)
} }
@@ -556,6 +560,7 @@ func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []co
item.Launch.UserDataDir = row.UserDataDir item.Launch.UserDataDir = row.UserDataDir
item.Launch.TargetURL = row.TargetURL item.Launch.TargetURL = row.TargetURL
item.ProxyID = row.ProxyID item.ProxyID = row.ProxyID
item.PreferredRemoteDebugPort = row.PreferredRemoteDebugPort
if item.ProxyID != "" { if item.ProxyID != "" {
item.Launch.ProxyServer = "" item.Launch.ProxyServer = ""
} }
@@ -565,6 +570,38 @@ func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []co
return updated return updated
} }
func findAvailableUnreservedRemoteDebugPort(ctx context.Context, allocator browser.RemoteDebugPortAllocator, start int, reserved []int) (int, error) {
if allocator == nil {
return 0, errors.New("remote debug port allocator is required")
}
if !domain.ValidRemoteDebugPort(start) {
return 0, fmt.Errorf("invalid remote debug start port %d", start)
}
reservedSet := make(map[int]struct{}, len(reserved))
for _, port := range reserved {
if domain.ValidRemoteDebugPort(port) {
reservedSet[port] = struct{}{}
}
}
for candidate := start; candidate <= domain.MaxRemoteDebugPort; {
port, err := allocator.FindAvailableRemoteDebugPort(ctx, candidate)
if err != nil {
return 0, err
}
if !domain.ValidRemoteDebugPort(port) || port < candidate {
return 0, fmt.Errorf("port allocator returned invalid port %d", port)
}
if _, reservedByOtherInstance := reservedSet[port]; !reservedByOtherInstance {
return port, nil
}
if port == domain.MaxRemoteDebugPort {
break
}
candidate = port + 1
}
return 0, errors.New("no unreserved local remote debug port is available")
}
func mergeProxyConfig(existing []config.ProxyProfile, options []ui.ProxyOption) []config.ProxyProfile { func mergeProxyConfig(existing []config.ProxyProfile, options []ui.ProxyOption) []config.ProxyProfile {
byID := make(map[string]config.ProxyProfile, len(existing)) byID := make(map[string]config.ProxyProfile, len(existing))
for _, profile := range existing { for _, profile := range existing {
+36 -4
View File
@@ -38,6 +38,30 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
} }
} }
func TestInstanceStarterUsesPreferredPortAndSkipsOtherInstancesReservations(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
remote := &fakeRemoteDebugInspector{
endpoint: browser.RemoteDebugEndpoint{Port: 9668},
inspectErr: browser.ErrRemoteDebugEndpointUnavailable,
allocatedPorts: []int{9667, 9668},
}
starter := instanceStarter{
launcher: launcher,
resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`},
managedProfiles: fakeManagedProfileInspector{},
externalProfiles: fakeExternalProfileInspector{},
remoteDebug: remote,
portAllocator: remote,
}
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, PreferredRemoteDebugPort: 9666}, ui.SettingsState{RemoteDebugStartPort: 9777, ReservedRemoteDebugPorts: []int{9667}}, 1)
if err != nil || outcome.RemoteDebugPort != 9668 || launcher.spec.RemoteDebugPort != 9668 {
t.Fatalf("Start() = %#v, launch = %#v, error = %v", outcome, launcher.spec, err)
}
if len(remote.allocationStarts) != 2 || remote.allocationStarts[0] != 9666 || remote.allocationStarts[1] != 9668 {
t.Fatalf("allocator starts = %#v", remote.allocationStarts)
}
}
func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) { func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
resolver := &fakeExecutableResolver{path: `C:\Browser\chrome.exe`} resolver := &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}
@@ -341,10 +365,12 @@ func (i fakeExternalProfileInspector) InspectProfile(context.Context, domain.Bro
} }
type fakeRemoteDebugInspector struct { type fakeRemoteDebugInspector struct {
endpoint browser.RemoteDebugEndpoint endpoint browser.RemoteDebugEndpoint
inspectErr error inspectErr error
waitErr error waitErr error
port int port int
allocatedPorts []int
allocationStarts []int
} }
func (i *fakeRemoteDebugInspector) InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) { func (i *fakeRemoteDebugInspector) InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (browser.RemoteDebugEndpoint, error) {
@@ -364,6 +390,12 @@ func (i *fakeRemoteDebugInspector) WaitForRemoteDebugPort(context.Context, domai
} }
func (i *fakeRemoteDebugInspector) FindAvailableRemoteDebugPort(_ context.Context, start int) (int, error) { func (i *fakeRemoteDebugInspector) FindAvailableRemoteDebugPort(_ context.Context, start int) (int, error) {
i.allocationStarts = append(i.allocationStarts, start)
if len(i.allocatedPorts) > 0 {
port := i.allocatedPorts[0]
i.allocatedPorts = i.allocatedPorts[1:]
return port, nil
}
if i.port != 0 { if i.port != 0 {
return i.port, nil return i.port, nil
} }
+1 -1
View File
@@ -44,7 +44,7 @@
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE | | T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE |
| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DONE | | T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DONE |
| T-308 | 设置页分区与控件边框层级 | T-306 | DONE | | T-308 | 设置页分区与控件边框层级 | T-306 | DONE |
| T-309 | 实例首选调试端口与安全推荐分配 | T-302,T-303,T-308 | DOING | | T-309 | 实例首选调试端口与安全推荐分配 | T-302,T-303,T-308 | DONE |
## Backlog ## Backlog
+5 -4
View File
@@ -3,13 +3,14 @@
## 快照 ## 快照
- 日期:2026-07-27 - 日期:2026-07-27
- 阶段:Phase 3 真实实例操作(T-301 至 T-308、T-201 至 T-208 已完成;T-309 进行中) - 阶段:Phase 3 真实实例操作(T-301 至 T-309、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-307 已完成 - 代码:已建立 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 已完成
- 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 - 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-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。 - T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;T-309 正在为实例增加持久首选调试端口、列表建议状态与后台安全回退分配。 - T-309:实例保存 `PreferredRemoteDebugPort`,旧 JSON 读取时按稳定顺序补齐;配置拒绝无效或重复端口。推荐仅基于保存实例快照,启动后台跳过其他实例的逻辑预留和当前实际端口;成功的本地端口回退会保存为新的首选值。实际 CDP 端点仍是运行时端口唯一事实来源。
- blocker:无。
## 当前目录 ## 当前目录
+4 -4
View File
@@ -3,7 +3,7 @@ id: T-309
title: 实例首选调试端口与安全推荐分配 title: 实例首选调试端口与安全推荐分配
phase: 3 phase: 3
deps: [T-302, T-303, T-308] deps: [T-302, T-303, T-308]
status: DOING status: DONE
created: 2026-07-27 created: 2026-07-27
owner: codex owner: codex
--- ---
@@ -33,7 +33,7 @@ owner: codex
## 执行记录 ## 执行记录
- 状态:DOING - 状态:DONE
- 变更:待实施。 - 变更:新增实例持久首选调试端口;配置恢复为旧记录按稳定顺序补齐端口并拒绝无效/重复端口。Gio 创建/编辑表单可查看或修改该值,列表以“建议 / 准备 / 实际 / 外部”表达端口语义;后台启动从实例首选端口开始,避开其他实例预留或正在使用的端口,成功回退后回写首选端口。HTML 原型同步该交互。
- 验证:待执行。 - 验证:`gofmt -w cmd/chub/main.go cmd/chub/main_test.go internal/platform/config/store.go internal/platform/config/store_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 内联脚本语法检查、`powershell -ExecutionPolicy Bypass -File scripts/smoke-windows.ps1` 通过。
- 阻塞:无。 - 阻塞:无。
+18 -17
View File
@@ -282,7 +282,7 @@
<div class="drawer-backdrop" id="backdrop"></div> <div class="drawer-backdrop" id="backdrop"></div>
<aside class="drawer" id="drawer" aria-label="实例详情和配置" aria-hidden="true"></aside> <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 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="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="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="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="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> <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>
@@ -294,10 +294,10 @@
{ id: 'proxy-test', name: '测试 SOCKS5', server: 'socks5://127.0.0.1:1080' } { id: 'proxy-test', name: '测试 SOCKS5', server: 'socks5://127.0.0.1:1080' }
]; ];
const instances = [ 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/', proxyId: 'proxy-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, preferredPort: 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-edge-review', name: '素材审核 · Edge', kind: 'edge', browser: 'Edge', status: 'starting', statusText: '启动中', pid: '—', port: 0, preferredPort: 9667, 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-chrome-ph', name: 'Shopee · PH 备用', kind: 'chrome', browser: 'Chrome', status: 'associated', statusText: '外部已关联', pid: '16108', port: 9668, preferredPort: 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' } { id: 'inst-edge-test', name: '测试环境 · Edge', kind: 'edge', browser: 'Edge', status: 'exited', statusText: '已退出', pid: '—', port: 0, preferredPort: 9669, dir: 'D:\\Chub\\profiles\\edge-test', started: '昨天 16:04', url: 'https://localhost:3000', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', exit: '退出码 0' }
]; ];
let selectedId = 'inst-chrome-shop'; let selectedId = 'inst-chrome-shop';
let modalAction = 'stop'; let modalAction = 'stop';
@@ -309,11 +309,12 @@
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const statusClass = (s) => s; const statusClass = (s) => s;
const proxyLabel = (id) => proxies.find(p => p.id === id)?.server || '无代理'; const proxyLabel = (id) => proxies.find(p => p.id === id)?.server || '无代理';
const portLabel = (x) => x.status === 'associated' ? (x.port ? `外部 ${x.port}` : '—') : x.status === 'starting' ? (x.preferredPort ? `准备 ${x.preferredPort}` : '—') : x.port ? `实际 ${x.port}` : x.status === 'exited' ? (x.preferredPort ? `建议 ${x.preferredPort}` : '—') : '—';
function renderProxyOptions(select, selected = '') { select.innerHTML = '<option value="">无代理</option>' + proxies.map(p => `<option value="${p.id}">${p.server}</option>`).join(''); select.value = selected; } function renderProxyOptions(select, selected = '') { select.innerHTML = '<option value="">无代理</option>' + proxies.map(p => `<option value="${p.id}">${p.server}</option>`).join(''); select.value = selected; }
function renderProxies() { const select = $('proxySelect'), input = $('proxyServer'), status = $('proxyStatus'), remove = $('proxyDelete'); select.innerHTML = '<option value="">选择代理(新建)</option>' + proxies.map(p => `<option value="${p.id}">${p.server}</option>`).join(''); if (!proxies.some(p => p.id === selectedProxyID)) selectedProxyID = ''; select.value = selectedProxyID; const selected = proxies.find(p => p.id === selectedProxyID); input.value = selected?.server || ''; remove.disabled = !selected; status.textContent = selected ? `正在编辑已保存代理;已被 ${instances.filter(i => i.proxyId === selected.id).length} 个实例使用。` : '新建代理:填写地址后保存。'; } function renderProxies() { const select = $('proxySelect'), input = $('proxyServer'), status = $('proxyStatus'), remove = $('proxyDelete'); select.innerHTML = '<option value="">选择代理(新建)</option>' + proxies.map(p => `<option value="${p.id}">${p.server}</option>`).join(''); if (!proxies.some(p => p.id === selectedProxyID)) selectedProxyID = ''; select.value = selectedProxyID; const selected = proxies.find(p => p.id === selectedProxyID); input.value = selected?.server || ''; remove.disabled = !selected; status.textContent = selected ? `正在编辑已保存代理;已被 ${instances.filter(i => i.proxyId === selected.id).length} 个实例使用。` : '新建代理:填写地址后保存。'; }
function filtered() { function filtered() {
const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value; const q = $('search').value.trim().toLowerCase(), browser = $('browserFilter').value, status = $('statusFilter').value;
return instances.filter(x => (!q || [x.name, x.browser, x.pid, x.port, x.dir].join(' ').toLowerCase().includes(q)) && (browser === 'all' || x.kind === browser) && (status === 'all' || x.status === status)); return instances.filter(x => (!q || [x.name, x.browser, x.pid, portLabel(x), x.dir].join(' ').toLowerCase().includes(q)) && (browser === 'all' || x.kind === browser) && (status === 'all' || x.status === status));
} }
function selectRow(id, body) { function selectRow(id, body) {
selectedId = id; selectedId = id;
@@ -323,42 +324,42 @@
const rows = filtered(), body = $('instanceRows'); body.innerHTML = ''; const rows = filtered(), body = $('instanceRows'); body.innerHTML = '';
rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.title = '双击或按 Enter 编辑实例'; tr.innerHTML = ` rows.forEach(x => { const tr = document.createElement('tr'); tr.className = x.id === selectedId ? 'selected' : ''; tr.tabIndex = 0; tr.dataset.id = x.id; tr.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><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>${x.browser}</td><td class="mono">${x.dir}</td><td class="mono">${portLabel(x)}</td><td><span class="status ${statusClass(x.status)}">${x.statusText}</span></td>
<td><div class="row-actions"><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '停止' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '■ 停止' : '▶ 启动'}</button><button data-action="edit" aria-label="编辑 ${x.name}">编辑</button><button data-action="delete" aria-label="删除 ${x.name}">删除</button></div></td>`; <td><div class="row-actions"><button data-action="start" aria-label="${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '停止' : '启动'} ${x.name}">${x.status === 'associated' ? '重新检测' : x.status === 'running' ? '■ 停止' : '▶ 启动'}</button><button data-action="edit" aria-label="编辑 ${x.name}">编辑</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('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('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.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 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'); })); 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 = 0; renderRows(); showToast(`“${x.name}”已优雅退出。`); }, 700); } else openConfirm(x.id, 'start'); }));
body.appendChild(tr); body.appendChild(tr);
}); });
$('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`; $('emptyState').style.display = rows.length ? 'none' : 'block'; $('resultCount').textContent = `显示 ${rows.length} 个实例`;
} }
function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id), externallyAssociated = x.status === 'associated'; $('drawer').innerHTML = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>调试端口</span><span class="mono">${x.port || '—'}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' || externallyAssociated ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' || externallyAssociated ? 'disabled' : ''}>关闭实例</button></div>${externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : ''}</div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); } function openDetails(id) { selectedId = id; const x = instances.find(i => i.id === id), externallyAssociated = x.status === 'associated'; $('drawer').innerHTML = `<div class="drawer-head"><div><h2>${x.name}</h2><p>${x.browser} · ${x.id}</p></div><button class="icon-button" id="closeDrawer" aria-label="关闭详情">×</button></div><div class="drawer-body"><div><span class="status ${x.status}">${x.statusText}</span></div><div class="detail-card"><div class="detail-line"><span>进程 PID</span><span class="mono">${x.pid}</span></div><div class="detail-line"><span>调试端口</span><span class="mono">${portLabel(x)}</span></div><div class="detail-line"><span>浏览器</span><span>${x.browser}</span></div><div class="detail-line"><span>user data dir</span><span class="mono">${x.dir}</span></div><div class="detail-line"><span>启动地址</span><span class="mono">${x.url}</span></div><div class="detail-line"><span>可执行文件</span><span class="mono">${x.executable}</span></div>${x.exit ? `<div class="detail-line"><span>退出结果</span><span>${x.exit}</span></div>` : ''}</div><div class="form-section"><h3>实例操作</h3><div class="drawer-actions" style="border-top:0;padding-top:0;justify-content:flex-start"><button class="secondary" id="restartBtn" ${x.status === 'starting' || externallyAssociated ? 'disabled' : ''}>↻ 重启</button><button class="danger-button" id="stopBtn" ${x.status === 'exited' || externallyAssociated ? 'disabled' : ''}>关闭实例</button></div>${externallyAssociated ? '<small class="help">外部关联实例仅供观察,Chub 不会接管其生命周期。</small>' : ''}</div><div class="form-section"><h3>最近活动</h3><div class="activity"><div class="activity-item"><i></i><span>状态已同步 · ${x.started}</span></div><div class="activity-item"><i></i><span>${externallyAssociated ? '已验证本地 CDP 端点;未接管外部浏览器。' : x.status === 'occupied' ? '检测到外部进程占用该 user data dir' : '实例配置已通过身份校验'}</span></div></div></div></div>`; showDrawer(); $('closeDrawer').onclick = hideDrawer; $('stopBtn').onclick = () => openConfirm(x.id, false); $('restartBtn').onclick = () => openConfirm(x.id, 'restart'); }
function editIsLocked(x) { return ['running', 'starting', 'associated'].includes(x.status); } function editIsLocked(x) { return ['running', 'starting', 'associated'].includes(x.status); }
function editIsDirty() { function editIsDirty() {
if (!editingID || !editSnapshot) return false; if (!editingID || !editSnapshot) return false;
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; 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 focusEditedRow(id) { requestAnimationFrame(() => document.querySelector(`tr[data-id="${id}"]`)?.focus()); }
function finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); } function finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); }
function openEdit(id) { function openEdit(id) {
const x = instances.find(i => i.id === id); if (!x) return; 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, proxyId: x.proxyId || '' }; 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 || ''; renderProxyOptions($('editProxy'), x.proxyId || ''); $('editPort').value = x.port || '—'; $('editChrome').checked = x.kind === 'chrome'; $('editEdge').checked = x.kind === 'edge'; 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 ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。'; $('editSubtitle').textContent = locked ? '当前运行状态下可查看配置,但身份约束字段不可修改。' : '修改下次启动使用的保存配置。';
$('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editReadOnlyNote').hidden = !locked; $('editChrome').disabled = locked; $('editEdge').disabled = locked; $('editDataDir').disabled = locked; $('chooseEditDir').disabled = locked; $('editPreferredPort').disabled = locked; $('editReadOnlyNote').hidden = !locked;
$('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0); $('editDialog').showModal(); setTimeout(() => $('editName').focus(), 0);
} }
function saveEdit() { function saveEdit() {
const x = instances.find(i => i.id === editingID); if (!x) return finishEdit(); 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(); const name = $('editName').value.trim(), dir = $('editDataDir').value.trim(), url = $('editURL').value.trim(), preferredPort = Number($('editPreferredPort').value);
if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; } if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; }
const locked = editIsLocked(x); x.name = name; x.url = url; x.proxyId = $('editProxy').value; 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 (!locked) { if (!Number.isInteger(preferredPort) || preferredPort < 1024 || preferredPort > 65535) { showToast('请填写有效的首选调试端口。'); $('editPreferredPort').focus(); return; } x.dir = dir; x.kind = document.querySelector('input[name="editKind"]:checked')?.value || x.kind; x.browser = x.kind === 'edge' ? 'Edge' : 'Chrome'; x.preferredPort = preferredPort; }
if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`); if ($('discardDialog').open) $('discardDialog').close(); renderRows(); const savedName = x.name; finishEdit(); showToast(`已保存“${savedName}”的实例配置。`);
} }
function requestEditClose() { if (editIsDirty()) { $('editDialog').close(); $('discardDialog').showModal(); return; } finishEdit(); } 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="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 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="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 showDrawer() { $('drawer').classList.add('open'); $('backdrop').classList.add('open'); $('drawer').setAttribute('aria-hidden', 'false'); setTimeout(() => $('drawer').querySelector('button, input, select')?.focus(), 60); }
function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); } function hideDrawer() { $('drawer').classList.remove('open'); $('backdrop').classList.remove('open'); $('drawer').setAttribute('aria-hidden', 'true'); }
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 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(); }
@@ -367,7 +368,7 @@
function queueUnexpectedExit(id) { function queueUnexpectedExit(id) {
const instance = instances.find(x => x.id === id); const instance = instances.find(x => x.id === id);
if (!instance || instance.status !== 'running') return; if (!instance || instance.status !== 'running') return;
instance.status = 'exited'; instance.statusText = '已退出'; instance.pid = '—'; instance.port = '—'; instance.exit = '已检测到进程退出'; instance.status = 'exited'; instance.statusText = '已退出'; instance.pid = '—'; instance.port = 0; instance.exit = '已检测到进程退出';
if (!unexpectedExitIDs.includes(id)) unexpectedExitIDs.push(id); if (!unexpectedExitIDs.includes(id)) unexpectedExitIDs.push(id);
selectedId = id; selectedId = id;
renderRows(); renderRows();
+60 -5
View File
@@ -25,11 +25,12 @@ type Settings struct {
} }
type Instance struct { type Instance struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ProxyID string `json:"proxyId,omitempty"` ProxyID string `json:"proxyId,omitempty"`
Launch domain.LaunchSpec `json:"launch"` PreferredRemoteDebugPort int `json:"preferredRemoteDebugPort,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` Launch domain.LaunchSpec `json:"launch"`
UpdatedAt time.Time `json:"updatedAt"`
} }
// ProxyProfile is deliberately limited to a display name and a validated, // ProxyProfile is deliberately limited to a display name and a validated,
@@ -88,6 +89,9 @@ func (s *Store) Load() (File, error) {
if result.Settings.RemoteDebugStartPort == 0 { if result.Settings.RemoteDebugStartPort == 0 {
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
} }
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
return File{}, err
}
if err := normalizeProxyConfig(&result); err != nil { if err := normalizeProxyConfig(&result); err != nil {
return File{}, err return File{}, err
} }
@@ -107,12 +111,18 @@ func DefaultSettings() Settings {
func (s *Store) Save(value File) error { func (s *Store) Save(value File) error {
value.Version = currentVersion value.Version = currentVersion
if value.Settings.RemoteDebugStartPort == 0 {
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
}
if value.Instances == nil { if value.Instances == nil {
value.Instances = []Instance{} value.Instances = []Instance{}
} }
if value.Proxies == nil { if value.Proxies == nil {
value.Proxies = []ProxyProfile{} value.Proxies = []ProxyProfile{}
} }
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
return err
}
if err := normalizeProxyConfig(&value); err != nil { if err := normalizeProxyConfig(&value); err != nil {
return err return err
} }
@@ -150,6 +160,51 @@ func (s *Store) Save(value File) error {
return nil return nil
} }
func normalizeInstanceRemoteDebugPorts(value *File) error {
if value == nil {
return errors.New("config is required")
}
start := value.Settings.RemoteDebugStartPort
if !domain.ValidRemoteDebugPort(start) {
return fmt.Errorf("invalid remote debug start port %d", start)
}
used := make(map[int]struct{}, len(value.Instances))
for _, instance := range value.Instances {
port := instance.PreferredRemoteDebugPort
if port == 0 {
continue
}
if !domain.ValidRemoteDebugPort(port) {
return fmt.Errorf("invalid preferred remote debug port %d", port)
}
if _, exists := used[port]; exists {
return fmt.Errorf("duplicate preferred remote debug port %d", port)
}
used[port] = struct{}{}
}
for index := range value.Instances {
if value.Instances[index].PreferredRemoteDebugPort != 0 {
continue
}
port, err := nextPreferredRemoteDebugPort(start, used)
if err != nil {
return err
}
value.Instances[index].PreferredRemoteDebugPort = port
used[port] = struct{}{}
}
return nil
}
func nextPreferredRemoteDebugPort(start int, used map[int]struct{}) (int, error) {
for port := start; port <= domain.MaxRemoteDebugPort; port++ {
if _, exists := used[port]; !exists {
return port, nil
}
}
return 0, errors.New("no preferred remote debug port is available")
}
func normalizeProxyConfig(value *File) error { func normalizeProxyConfig(value *File) error {
if value == nil { if value == nil {
return errors.New("config is required") return errors.New("config is required")
+44
View File
@@ -42,6 +42,50 @@ func TestStoreAddsDefaultRemoteDebugPortForExistingConfig(t *testing.T) {
} }
} }
func TestStoreAssignsPreferredPortsForLegacyInstancesInStableOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
contents := `{"version":1,"settings":{"remoteDebugStartPort":9777},"instances":[{"id":"first","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\first"}},{"id":"second","launch":{"Kind":"edge","UserDataDir":"C:\\profiles\\second"}}]}`
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
store, err := New(path)
if err != nil {
t.Fatal(err)
}
got, err := store.Load()
if err != nil {
t.Fatal(err)
}
if got.Instances[0].PreferredRemoteDebugPort != 9777 || got.Instances[1].PreferredRemoteDebugPort != 9778 {
t.Fatalf("legacy preferred ports = %#v", got.Instances)
}
if err := store.Save(got); err != nil {
t.Fatal(err)
}
reloaded, err := store.Load()
if err != nil || reloaded.Instances[0].PreferredRemoteDebugPort != 9777 || reloaded.Instances[1].PreferredRemoteDebugPort != 9778 {
t.Fatalf("persisted preferred ports = %#v, error = %v", reloaded.Instances, err)
}
}
func TestStoreRejectsInvalidOrDuplicatePreferredPorts(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil {
t.Fatal(err)
}
duplicate := File{Instances: []Instance{
{ID: "one", PreferredRemoteDebugPort: 9666},
{ID: "two", PreferredRemoteDebugPort: 9666},
}}
if err := store.Save(duplicate); err == nil {
t.Fatal("duplicate preferred port was accepted")
}
invalid := File{Instances: []Instance{{ID: "one", PreferredRemoteDebugPort: domain.MinRemoteDebugPort - 1}}}
if err := store.Save(invalid); err == nil {
t.Fatal("invalid preferred port was accepted")
}
}
func TestStoreMissingFileReturnsDefaults(t *testing.T) { func TestStoreMissingFileReturnsDefaults(t *testing.T) {
store, err := New(filepath.Join(t.TempDir(), "config.json")) store, err := New(filepath.Join(t.TempDir(), "config.json"))
if err != nil { if err != nil {
+226 -37
View File
@@ -205,27 +205,30 @@ type proxyPickerState struct {
} }
// InstanceRow is the Gio view model for one configured browser instance. // InstanceRow is the Gio view model for one configured browser instance.
// Runtime fields are refreshed independently and are never saved by the edit form. // PreferredRemoteDebugPort is saved configuration; runtime fields are refreshed
// independently and are never saved by the edit form.
type InstanceRow struct { type InstanceRow struct {
ID string ID string
Name string Name string
Browser string Browser string
UserDataDir string UserDataDir string
TargetURL string TargetURL string
ProxyID string ProxyID string
PID int PreferredRemoteDebugPort int
RemoteDebugPort int PID int
OccupancySource string RemoteDebugPort int
Status string OccupancySource string
Status string
} }
type SettingsState struct { type SettingsState struct {
ChromePath string ChromePath string
EdgePath string EdgePath string
DefaultDir string DefaultDir string
LogDir string LogDir string
RemoteDebugStartPort int RemoteDebugStartPort int
CloseOnExit bool ReservedRemoteDebugPorts []int
CloseOnExit bool
} }
// Shell owns every interactive Gio widget. Keeping this state outside Layout // Shell owns every interactive Gio widget. Keeping this state outside Layout
@@ -262,6 +265,7 @@ type Shell struct {
instanceName widget.Editor instanceName widget.Editor
instanceDir widget.Editor instanceDir widget.Editor
instanceURL widget.Editor instanceURL widget.Editor
instancePort widget.Editor
createProxyPick widget.Clickable createProxyPick widget.Clickable
editProxyPick widget.Clickable editProxyPick widget.Clickable
settingsProxyPick widget.Clickable settingsProxyPick widget.Clickable
@@ -269,6 +273,7 @@ type Shell struct {
editName widget.Editor editName widget.Editor
editDir widget.Editor editDir widget.Editor
editURL widget.Editor editURL widget.Editor
editPreferredPort widget.Editor
editPort widget.Editor editPort widget.Editor
editBrowserKind widget.Enum editBrowserKind widget.Enum
editDirPick widget.Clickable editDirPick widget.Clickable
@@ -353,10 +358,10 @@ type Shell struct {
func NewShell(theme *material.Theme) *Shell { func NewShell(theme *material.Theme) *Shell {
s := &Shell{theme: theme, rows: []InstanceRow{ s := &Shell{theme: theme, rows: []InstanceRow{
{ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"}, {ID: "demo-operations", Name: "运营主账号", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\operations`, PreferredRemoteDebugPort: 9666, PID: 18420, RemoteDebugPort: 9666, Status: "运行中"},
{ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, Status: "启动中"}, {ID: "demo-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, PreferredRemoteDebugPort: 9667, Status: "启动中"},
{ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"}, {ID: "demo-assets", Name: "素材采集", Browser: "Chrome", UserDataDir: `C:\Users\Public\chub\profiles\assets`, PreferredRemoteDebugPort: 9668, PID: 16108, RemoteDebugPort: 9668, OccupancySource: "browser_message_window", Status: "外部已关联"},
{ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, Status: "已退出"}, {ID: "demo-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, PreferredRemoteDebugPort: 9669, Status: "已退出"},
}, searches: map[PathField]*pathSearchState{ }, searches: map[PathField]*pathSearchState{
PathChromeExecutable: {}, PathChromeExecutable: {},
PathEdgeExecutable: {}, PathEdgeExecutable: {},
@@ -368,6 +373,7 @@ func NewShell(theme *material.Theme) *Shell {
s.dataDir.SetText(`C:\Users\Public\chub\profiles`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
s.logDir.SetText(`C:\Users\Public\chub\logs`) s.logDir.SetText(`C:\Users\Public\chub\logs`)
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort)) s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
s.closeOnExit.Value = true s.closeOnExit.Value = true
s.browserKind.Value = "chrome" s.browserKind.Value = "chrome"
s.editBrowserKind.Value = "chrome" s.editBrowserKind.Value = "chrome"
@@ -385,6 +391,7 @@ func (s *Shell) SetInstances(rows []InstanceRow) {
s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance) s.rows[i].ID = fmt.Sprintf("restored-%d", s.nextInstance)
} }
} }
s.assignPreferredRemoteDebugPorts()
if s.selectedInstanceID == "" && len(s.rows) > 0 { if s.selectedInstanceID == "" && len(s.rows) > 0 {
s.selectedInstanceID = s.rows[0].ID s.selectedInstanceID = s.rows[0].ID
} }
@@ -555,7 +562,7 @@ func (s *Shell) consumeControls(gtx layout.Context) {
s.page = pageSettings s.page = pageSettings
} }
for s.newClick.Clicked(gtx) { for s.newClick.Clicked(gtx) {
s.page = pageCreate s.beginCreate()
} }
for s.refreshClick.Clicked(gtx) { for s.refreshClick.Clicked(gtx) {
s.requestRefresh() s.requestRefresh()
@@ -591,6 +598,12 @@ func (s *Shell) consumeControls(gtx layout.Context) {
} }
} }
func (s *Shell) beginCreate() {
s.page = pageCreate
s.formFeedback = ""
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
}
func (s *Shell) consumeKeyboard(gtx layout.Context) { func (s *Shell) consumeKeyboard(gtx layout.Context) {
for { for {
event, ok := gtx.Event( event, ok := gtx.Event(
@@ -684,7 +697,8 @@ func (s *Shell) beginEdit(id string) {
s.editName.SetText(row.Name) s.editName.SetText(row.Name)
s.editDir.SetText(row.UserDataDir) s.editDir.SetText(row.UserDataDir)
s.editURL.SetText(row.TargetURL) s.editURL.SetText(row.TargetURL)
s.editPort.SetText(remoteDebugPortText(row.RemoteDebugPort)) s.editPreferredPort.SetText(preferredRemoteDebugPortText(row.PreferredRemoteDebugPort))
s.editPort.SetText(actualRemoteDebugPortText(row.RemoteDebugPort))
s.editProxyID = row.ProxyID s.editProxyID = row.ProxyID
if strings.EqualFold(row.Browser, "Edge") { if strings.EqualFold(row.Browser, "Edge") {
s.editBrowserKind.Value = "edge" s.editBrowserKind.Value = "edge"
@@ -715,7 +729,7 @@ func (s *Shell) editIsDirty() bool {
if s.editLocked(s.editOriginal) { if s.editLocked(s.editOriginal) {
return false return false
} }
return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser return strings.TrimSpace(s.editDir.Text()) != s.editOriginal.UserDataDir || browserDisplay(s.editBrowserKind.Value) != s.editOriginal.Browser || strings.TrimSpace(s.editPreferredPort.Text()) != preferredRemoteDebugPortText(s.editOriginal.PreferredRemoteDebugPort)
} }
func (s *Shell) cancelEdit() { func (s *Shell) cancelEdit() {
@@ -752,8 +766,18 @@ func (s *Shell) saveEdit() {
s.editFeedback = "User Data Dir 必须是绝对路径。" s.editFeedback = "User Data Dir 必须是绝对路径。"
return return
} }
preferredPort, err := normalizePreferredRemoteDebugPort(s.editPreferredPort.Text())
if err != nil {
s.editFeedback = err.Error()
return
}
if s.remoteDebugPortReservedByOther(preferredPort, row.ID) {
s.editFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
return
}
row.UserDataDir = filepath.Clean(userDataDir) row.UserDataDir = filepath.Clean(userDataDir)
row.Browser = browserDisplay(s.editBrowserKind.Value) row.Browser = browserDisplay(s.editBrowserKind.Value)
row.PreferredRemoteDebugPort = preferredPort
} }
row.Name = name row.Name = name
row.TargetURL = targetURL row.TargetURL = targetURL
@@ -916,7 +940,7 @@ func (s *Shell) instanceListRow(row InstanceRow) layout.Widget {
layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout), layout.Flexed(instanceNameColumnWeight, material.Body1(s.theme, row.Name).Layout),
layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout), layout.Flexed(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)), layout.Flexed(instanceDirectoryColumnWeight, pathCell(s.theme, row.UserDataDir)),
layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row.RemoteDebugPort)), layout.Flexed(instancePortColumnWeight, remoteDebugPortCell(s.theme, row)),
layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)), layout.Flexed(instanceStatusColumnWeight, statusLabel(s.theme, row.Status)),
) )
}) })
@@ -966,17 +990,47 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
return style.Layout return style.Layout
} }
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget { func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget {
return material.Body2(theme, remoteDebugPortText(port)).Layout style := material.Body2(theme, instancePortLabel(row))
style.MaxLines = 1
return style.Layout
} }
func remoteDebugPortText(port int) string { func actualRemoteDebugPortText(port int) string {
if port > 0 { if port > 0 {
return strconv.Itoa(port) return strconv.Itoa(port)
} }
return "—" return "—"
} }
func preferredRemoteDebugPortText(port int) string {
if domain.ValidRemoteDebugPort(port) {
return strconv.Itoa(port)
}
return ""
}
func instancePortLabel(row InstanceRow) string {
if row.Status == "外部已关联" {
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
return fmt.Sprintf("外部 %d", row.RemoteDebugPort)
}
return "—"
}
if row.Status == "启动中" && domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
return fmt.Sprintf("准备 %d", row.PreferredRemoteDebugPort)
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
return fmt.Sprintf("实际 %d", row.RemoteDebugPort)
}
if row.Status == "已退出" || row.Status == "启动失败" {
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
return fmt.Sprintf("建议 %d", row.PreferredRemoteDebugPort)
}
}
return "—"
}
func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -1080,9 +1134,11 @@ func (s *Shell) editDialogCard(row InstanceRow) layout.Widget {
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)), layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.editPreferredPortField(gtx, locked) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerEdit) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.formField("当前调试端口", "运行时由全局起始端口分配,此处只读", &s.editPort)), layout.Rigid(s.formField("实际调试端口", "仅在已验证的运行或外部关联状态显示;此处只读", &s.editPort)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(statusLabel(s.theme, row.Status)), layout.Rigid(statusLabel(s.theme, row.Status)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
@@ -1142,6 +1198,16 @@ func (s *Shell) editDirectoryField(gtx layout.Context, locked bool) layout.Dimen
) )
} }
func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.Dimensions {
s.editPreferredPort.ReadOnly = locked
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(material.Body2(s.theme, "首选调试端口").Layout),
layout.Rigid(material.Caption(s.theme, "下次启动优先使用;启动时仍会在后台复检 loopback 可用性").Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(material.Editor(s.theme, &s.editPreferredPort, "例如:9666").Layout),
)
}
func (s *Shell) editFeedbackLabel(locked bool) layout.Widget { func (s *Shell) editFeedbackLabel(locked bool) layout.Widget {
label := s.editFeedback label := s.editFeedback
if label == "" && locked { if label == "" && locked {
@@ -1667,6 +1733,8 @@ func (s *Shell) createInstance(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)), layout.Rigid(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(s.formField("首选调试端口", "已按现有实例推荐;启动时会在后台复检 loopback 可用性", &s.instancePort)),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return s.proxyPickerField(gtx, proxyPickerCreate) }),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout), layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
@@ -1893,15 +1961,25 @@ func (s *Shell) createInstanceFromForm() {
s.formFeedback = "请选择或输入 User Data Dir 后再创建。" s.formFeedback = "请选择或输入 User Data Dir 后再创建。"
return return
} }
preferredPort, err := normalizePreferredRemoteDebugPort(s.instancePort.Text())
if err != nil {
s.formFeedback = err.Error()
return
}
if s.remoteDebugPortReservedByOther(preferredPort, "") {
s.formFeedback = fmt.Sprintf("端口 %d 已被其他 Chub 实例预留或使用。", preferredPort)
return
}
s.nextInstance++ s.nextInstance++
s.rows = append(s.rows, InstanceRow{ s.rows = append(s.rows, InstanceRow{
ID: fmt.Sprintf("instance-%d", s.nextInstance), ID: fmt.Sprintf("instance-%d", s.nextInstance),
Name: name, Name: name,
Browser: browserDisplay(s.browserKind.Value), Browser: browserDisplay(s.browserKind.Value),
UserDataDir: userDataDir, UserDataDir: userDataDir,
TargetURL: strings.TrimSpace(s.instanceURL.Text()), TargetURL: strings.TrimSpace(s.instanceURL.Text()),
ProxyID: s.createProxyID, ProxyID: s.createProxyID,
Status: "已退出", PreferredRemoteDebugPort: preferredPort,
Status: "已退出",
}) })
s.page = pageInstances s.page = pageInstances
s.formFeedback = "" s.formFeedback = ""
@@ -2247,6 +2325,7 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = err.Error() s.instanceFeedback = err.Error()
return return
} }
settings.ReservedRemoteDebugPorts = s.reservedRemoteDebugPorts(id)
if state == nil { if state == nil {
state = &instanceStartState{} state = &instanceStartState{}
s.startStates[id] = state s.startStates[id] = state
@@ -2333,11 +2412,21 @@ func (s *Shell) consumeStartResults() {
status = "运行中(调试不可用)" status = "运行中(调试不可用)"
} }
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry") s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry")
fallbackPort := result.outcome.Warning == "" && domain.ValidRemoteDebugPort(result.outcome.RemoteDebugPort) && row.PreferredRemoteDebugPort != result.outcome.RemoteDebugPort
if fallbackPort {
if updated, exists := s.instanceRow(result.id); exists {
updated.PreferredRemoteDebugPort = result.outcome.RemoteDebugPort
s.replaceInstanceRow(updated)
}
s.notifyInstancesChanged()
}
if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) { if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) {
continue continue
} }
if result.outcome.Warning != "" { if result.outcome.Warning != "" {
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning) s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
} else if fallbackPort {
s.instanceFeedback = fmt.Sprintf("%s 的首选端口已被占用,已改用并保存端口 %d。", row.Name, result.outcome.RemoteDebugPort)
} else { } else {
s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort) s.instanceFeedback = fmt.Sprintf("已启动 %s(PID %d,端口 %d)。", row.Name, result.outcome.PID, result.outcome.RemoteDebugPort)
} }
@@ -2482,7 +2571,7 @@ func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool {
} }
func instanceRefreshFingerprint(row InstanceRow) string { func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00") return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID, strconv.Itoa(row.PreferredRemoteDebugPort)}, "\x00")
} }
func (s *Shell) requestDelete(id string) { func (s *Shell) requestDelete(id string) {
@@ -2653,6 +2742,106 @@ func normalizeRemoteDebugStartPort(value string) (int, error) {
return port, nil return port, nil
} }
func normalizePreferredRemoteDebugPort(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("请输入首选调试端口(%d 到 %d)。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
}
port, err := strconv.Atoi(value)
if err != nil || !domain.ValidRemoteDebugPort(port) {
return 0, fmt.Errorf("首选调试端口必须在 %d 到 %d 之间。", domain.MinRemoteDebugPort, domain.MaxRemoteDebugPort)
}
return port, nil
}
func (s *Shell) assignPreferredRemoteDebugPorts() {
used := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
used[row.PreferredRemoteDebugPort] = struct{}{}
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
used[row.RemoteDebugPort] = struct{}{}
}
}
for index := range s.rows {
if domain.ValidRemoteDebugPort(s.rows[index].PreferredRemoteDebugPort) {
continue
}
port := s.nextUnreservedRemoteDebugPort(used)
if port == 0 {
continue
}
s.rows[index].PreferredRemoteDebugPort = port
used[port] = struct{}{}
}
}
func (s *Shell) recommendRemoteDebugPort(excludeID string) int {
used := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
if domain.ValidRemoteDebugPort(row.PreferredRemoteDebugPort) {
used[row.PreferredRemoteDebugPort] = struct{}{}
}
if domain.ValidRemoteDebugPort(row.RemoteDebugPort) {
used[row.RemoteDebugPort] = struct{}{}
}
}
return s.nextUnreservedRemoteDebugPort(used)
}
func (s *Shell) nextUnreservedRemoteDebugPort(used map[int]struct{}) int {
start, err := normalizeRemoteDebugStartPort(s.remoteDebugPort.Text())
if err != nil {
start = domain.DefaultRemoteDebugPort
}
for port := start; port <= domain.MaxRemoteDebugPort; port++ {
if _, exists := used[port]; !exists {
return port
}
}
return 0
}
func (s *Shell) remoteDebugPortReservedByOther(port int, excludeID string) bool {
if !domain.ValidRemoteDebugPort(port) {
return false
}
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
if row.PreferredRemoteDebugPort == port || row.RemoteDebugPort == port {
return true
}
}
return false
}
func (s *Shell) reservedRemoteDebugPorts(excludeID string) []int {
reserved := make([]int, 0, len(s.rows)*2)
seen := make(map[int]struct{}, len(s.rows)*2)
for _, row := range s.rows {
if row.ID == excludeID {
continue
}
for _, port := range []int{row.PreferredRemoteDebugPort, row.RemoteDebugPort} {
if !domain.ValidRemoteDebugPort(port) {
continue
}
if _, exists := seen[port]; exists {
continue
}
seen[port] = struct{}{}
reserved = append(reserved, port)
}
}
return reserved
}
func (s *Shell) notifyInstancesChanged() { func (s *Shell) notifyInstancesChanged() {
if s.onInstancesChanged != nil { if s.onInstancesChanged != nil {
s.onInstancesChanged(append([]InstanceRow(nil), s.rows...)) s.onInstancesChanged(append([]InstanceRow(nil), s.rows...))
+85
View File
@@ -159,6 +159,91 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
} }
} }
func TestShellAssignsAndRecommendsUnreservedPreferredPorts(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetSettings(SettingsState{RemoteDebugStartPort: 9777})
shell.SetInstances([]InstanceRow{
{ID: "preferred", PreferredRemoteDebugPort: 9777},
{ID: "runtime", RemoteDebugPort: 9778},
{ID: "legacy"},
{ID: "legacy-second"},
})
if shell.rows[1].PreferredRemoteDebugPort != 9779 || shell.rows[2].PreferredRemoteDebugPort != 9780 || shell.rows[3].PreferredRemoteDebugPort != 9781 {
t.Fatalf("assigned preferred ports = %#v", shell.rows)
}
if got := shell.recommendRemoteDebugPort(""); got != 9782 {
t.Fatalf("recommended port = %d, want 9782", got)
}
reserved := shell.reservedRemoteDebugPorts("legacy")
if len(reserved) != 4 || reserved[0] != 9777 || reserved[1] != 9779 || reserved[2] != 9778 || reserved[3] != 9781 {
t.Fatalf("reserved ports = %#v", reserved)
}
}
func TestInstancePortLabelDistinguishesSuggestedAndActualPorts(t *testing.T) {
cases := []struct {
row InstanceRow
want string
}{
{InstanceRow{Status: "已退出", PreferredRemoteDebugPort: 9666}, "建议 9666"},
{InstanceRow{Status: "启动中", PreferredRemoteDebugPort: 9667}, "准备 9667"},
{InstanceRow{Status: "运行中", PreferredRemoteDebugPort: 9666, RemoteDebugPort: 9668}, "实际 9668"},
{InstanceRow{Status: "外部已关联", RemoteDebugPort: 9669}, "外部 9669"},
}
for _, test := range cases {
if got := instancePortLabel(test.row); got != test.want {
t.Fatalf("instancePortLabel(%#v) = %q, want %q", test.row, got, test.want)
}
}
}
func TestShellPersistsFallbackPortAfterSuccessfulManagedStart(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetInstances([]InstanceRow{{ID: "instance", Name: "实例", Status: "启动中", PreferredRemoteDebugPort: 9666}})
changes := 0
shell.OnInstancesChanged(func([]InstanceRow) { changes++ })
shell.startStates["instance"] = &instanceStartState{request: 1, running: true}
shell.startResults <- instanceStartResult{
id: "instance",
request: 1,
outcome: InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9668, Source: "chub_registry"},
}
shell.consumeStartResults()
row, ok := shell.instanceRow("instance")
if !ok || row.Status != "运行中" || row.RemoteDebugPort != 9668 || row.PreferredRemoteDebugPort != 9668 {
t.Fatalf("fallback result = %#v", row)
}
if changes != 1 {
t.Fatalf("instance change notifications = %d, want 1", changes)
}
}
func TestShellRejectsReservedPreferredPortWhenCreatingOrEditing(t *testing.T) {
shell := NewShell(material.NewTheme())
shell.SetInstances([]InstanceRow{
{ID: "one", Name: "一号", Browser: "Chrome", UserDataDir: `C:\profiles\one`, Status: "已退出", PreferredRemoteDebugPort: 9666},
{ID: "two", Name: "二号", Browser: "Edge", UserDataDir: `C:\profiles\two`, Status: "已退出", PreferredRemoteDebugPort: 9667},
})
shell.instanceName.SetText("三号")
shell.instanceDir.SetText(`C:\profiles\three`)
shell.instancePort.SetText("9666")
shell.createInstanceFromForm()
if len(shell.rows) != 2 || !strings.Contains(shell.formFeedback, "端口 9666") {
t.Fatalf("create duplicate port = rows %#v, feedback %q", shell.rows, shell.formFeedback)
}
shell.beginEdit("one")
shell.editPreferredPort.SetText("9667")
shell.saveEdit()
row, _ := shell.instanceRow("one")
if row.PreferredRemoteDebugPort != 9666 || !strings.Contains(shell.editFeedback, "端口 9667") {
t.Fatalf("edit duplicate port = row %#v, feedback %q", row, shell.editFeedback)
}
}
func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) { func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
target := shell.rows[0] target := shell.rows[0]