feat: add preferred debug port planning
This commit is contained in:
+39
-2
@@ -164,7 +164,7 @@ func runWindow(logger *slog.Logger) {
|
||||
shell.SetProxies(proxyOptions(saved.Proxies))
|
||||
rows := make([]ui.InstanceRow, 0, len(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.OnSave(func(value ui.SettingsState) {
|
||||
@@ -493,7 +493,11 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
|
||||
if err != nil {
|
||||
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 {
|
||||
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.TargetURL = row.TargetURL
|
||||
item.ProxyID = row.ProxyID
|
||||
item.PreferredRemoteDebugPort = row.PreferredRemoteDebugPort
|
||||
if item.ProxyID != "" {
|
||||
item.Launch.ProxyServer = ""
|
||||
}
|
||||
@@ -565,6 +570,38 @@ func mergeInstanceConfig(existing []config.Instance, rows []ui.InstanceRow) []co
|
||||
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 {
|
||||
byID := make(map[string]config.ProxyProfile, len(existing))
|
||||
for _, profile := range existing {
|
||||
|
||||
+36
-4
@@ -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) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
resolver := &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}
|
||||
@@ -341,10 +365,12 @@ func (i fakeExternalProfileInspector) InspectProfile(context.Context, domain.Bro
|
||||
}
|
||||
|
||||
type fakeRemoteDebugInspector struct {
|
||||
endpoint browser.RemoteDebugEndpoint
|
||||
inspectErr error
|
||||
waitErr error
|
||||
port int
|
||||
endpoint browser.RemoteDebugEndpoint
|
||||
inspectErr error
|
||||
waitErr error
|
||||
port int
|
||||
allocatedPorts []int
|
||||
allocationStarts []int
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return i.port, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE |
|
||||
| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,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
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
## 快照
|
||||
|
||||
- 日期:2026-07-27
|
||||
- 阶段:Phase 3 真实实例操作(T-301 至 T-308、T-201 至 T-208 已完成;T-309 进行中)
|
||||
- 代码:已建立 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 已完成
|
||||
- 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
|
||||
- 阶段: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-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
|
||||
- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。
|
||||
- T-308:设置页以可滚动的外层工作区承载浏览器路径、默认目录、运行行为和代理分区;分区、字段、状态提示和保存操作区使用统一的浅色圆角描边。路径字段在紧凑内容区会将输入与“选择/搜索”操作纵向重排,切换页面后滚动、输入与异步搜索状态仍由持久 Gio widget 保留。
|
||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||
- blocker:无;T-309 正在为实例增加持久首选调试端口、列表建议状态与后台安全回退分配。
|
||||
- T-309:实例保存 `PreferredRemoteDebugPort`,旧 JSON 读取时按稳定顺序补齐;配置拒绝无效或重复端口。推荐仅基于保存实例快照,启动后台跳过其他实例的逻辑预留和当前实际端口;成功的本地端口回退会保存为新的首选值。实际 CDP 端点仍是运行时端口唯一事实来源。
|
||||
- blocker:无。
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@ id: T-309
|
||||
title: 实例首选调试端口与安全推荐分配
|
||||
phase: 3
|
||||
deps: [T-302, T-303, T-308]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-07-27
|
||||
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` 通过。
|
||||
- 阻塞:无。
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
<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="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="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>
|
||||
@@ -294,10 +294,10 @@
|
||||
{ id: 'proxy-test', name: '测试 SOCKS5', server: 'socks5://127.0.0.1:1080' }
|
||||
];
|
||||
const instances = [
|
||||
{ id: 'inst-chrome-shop', name: 'Shopee · SG 主店', kind: 'chrome', browser: 'Chrome', status: 'running', statusText: '运行中(托管)', pid: '18420', port: '9666', dir: 'D:\\Chub\\profiles\\shopee-sg', started: '今天 09:42', url: 'https://seller.shopee.sg/', proxyId: 'proxy-sg', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
|
||||
{ id: 'inst-edge-review', name: '素材审核 · Edge', kind: 'edge', browser: 'Edge', status: 'starting', statusText: '启动中', pid: '—', port: '检测中…', dir: 'D:\\Chub\\profiles\\asset-review', started: '正在启动', url: 'https://example.com/review', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
|
||||
{ id: 'inst-chrome-ph', name: 'Shopee · PH 备用', kind: 'chrome', browser: 'Chrome', status: 'associated', statusText: '外部已关联', pid: '16108', port: '9668', dir: 'D:\\Chub\\profiles\\shopee-ph', started: '昨天 18:16', url: 'https://seller.shopee.ph/', executable: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
|
||||
{ id: 'inst-edge-test', name: '测试环境 · Edge', kind: 'edge', browser: 'Edge', status: 'exited', statusText: '已退出', pid: '—', port: '—', dir: 'D:\\Chub\\profiles\\edge-test', started: '昨天 16:04', url: 'https://localhost:3000', executable: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', exit: '退出码 0' }
|
||||
{ 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: 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, 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: 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 modalAction = 'stop';
|
||||
@@ -309,11 +309,12 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const statusClass = (s) => s;
|
||||
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 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() {
|
||||
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) {
|
||||
selectedId = id;
|
||||
@@ -323,42 +324,42 @@
|
||||
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 = `
|
||||
<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>`;
|
||||
tr.addEventListener('click', e => { if (!e.target.closest('button')) selectRow(x.id, body); });
|
||||
tr.addEventListener('dblclick', e => { if (!e.target.closest('button')) openEdit(x.id); });
|
||||
tr.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); openEdit(x.id); } else if (e.key === ' ') { e.preventDefault(); selectRow(x.id, body); } });
|
||||
tr.querySelectorAll('button').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); selectRow(x.id, body); if (b.dataset.action === 'edit') openEdit(x.id); else if (b.dataset.action === 'delete') showToast(`原型演示:将确认删除“${x.name}”的 Chub 配置,不会删除 User Data Dir。`); else 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);
|
||||
});
|
||||
$('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 editIsDirty() {
|
||||
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 finishEdit() { const id = editingID; $('editDialog').close(); editingID = ''; editSnapshot = null; focusEditedRow(id); }
|
||||
function openEdit(id) {
|
||||
const x = instances.find(i => i.id === id); if (!x) return;
|
||||
selectedId = id; editingID = id; editSnapshot = { name: x.name, dir: x.dir, url: x.url || '', kind: x.kind, 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';
|
||||
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; $('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);
|
||||
}
|
||||
function saveEdit() {
|
||||
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; }
|
||||
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}”的实例配置。`);
|
||||
}
|
||||
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 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(); }
|
||||
@@ -367,7 +368,7 @@
|
||||
function queueUnexpectedExit(id) {
|
||||
const instance = instances.find(x => x.id === id);
|
||||
if (!instance || instance.status !== 'running') return;
|
||||
instance.status = 'exited'; instance.statusText = '已退出'; instance.pid = '—'; instance.port = '—'; instance.exit = '已检测到进程退出';
|
||||
instance.status = 'exited'; instance.statusText = '已退出'; instance.pid = '—'; instance.port = 0; instance.exit = '已检测到进程退出';
|
||||
if (!unexpectedExitIDs.includes(id)) unexpectedExitIDs.push(id);
|
||||
selectedId = id;
|
||||
renderRows();
|
||||
|
||||
@@ -25,11 +25,12 @@ type Settings struct {
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProxyID string `json:"proxyId,omitempty"`
|
||||
Launch domain.LaunchSpec `json:"launch"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProxyID string `json:"proxyId,omitempty"`
|
||||
PreferredRemoteDebugPort int `json:"preferredRemoteDebugPort,omitempty"`
|
||||
Launch domain.LaunchSpec `json:"launch"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
|
||||
}
|
||||
if err := normalizeInstanceRemoteDebugPorts(&result); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
if err := normalizeProxyConfig(&result); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
@@ -107,12 +111,18 @@ func DefaultSettings() Settings {
|
||||
|
||||
func (s *Store) Save(value File) error {
|
||||
value.Version = currentVersion
|
||||
if value.Settings.RemoteDebugStartPort == 0 {
|
||||
value.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
|
||||
}
|
||||
if value.Instances == nil {
|
||||
value.Instances = []Instance{}
|
||||
}
|
||||
if value.Proxies == nil {
|
||||
value.Proxies = []ProxyProfile{}
|
||||
}
|
||||
if err := normalizeInstanceRemoteDebugPorts(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := normalizeProxyConfig(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,6 +160,51 @@ func (s *Store) Save(value File) error {
|
||||
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 {
|
||||
if value == nil {
|
||||
return errors.New("config is required")
|
||||
|
||||
@@ -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) {
|
||||
store, err := New(filepath.Join(t.TempDir(), "config.json"))
|
||||
if err != nil {
|
||||
|
||||
+226
-37
@@ -205,27 +205,30 @@ type proxyPickerState struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ID string
|
||||
Name string
|
||||
Browser string
|
||||
UserDataDir string
|
||||
TargetURL string
|
||||
ProxyID string
|
||||
PID int
|
||||
RemoteDebugPort int
|
||||
OccupancySource string
|
||||
Status string
|
||||
ID string
|
||||
Name string
|
||||
Browser string
|
||||
UserDataDir string
|
||||
TargetURL string
|
||||
ProxyID string
|
||||
PreferredRemoteDebugPort int
|
||||
PID int
|
||||
RemoteDebugPort int
|
||||
OccupancySource string
|
||||
Status string
|
||||
}
|
||||
|
||||
type SettingsState struct {
|
||||
ChromePath string
|
||||
EdgePath string
|
||||
DefaultDir string
|
||||
LogDir string
|
||||
RemoteDebugStartPort int
|
||||
CloseOnExit bool
|
||||
ChromePath string
|
||||
EdgePath string
|
||||
DefaultDir string
|
||||
LogDir string
|
||||
RemoteDebugStartPort int
|
||||
ReservedRemoteDebugPorts []int
|
||||
CloseOnExit bool
|
||||
}
|
||||
|
||||
// Shell owns every interactive Gio widget. Keeping this state outside Layout
|
||||
@@ -262,6 +265,7 @@ type Shell struct {
|
||||
instanceName widget.Editor
|
||||
instanceDir widget.Editor
|
||||
instanceURL widget.Editor
|
||||
instancePort widget.Editor
|
||||
createProxyPick widget.Clickable
|
||||
editProxyPick widget.Clickable
|
||||
settingsProxyPick widget.Clickable
|
||||
@@ -269,6 +273,7 @@ type Shell struct {
|
||||
editName widget.Editor
|
||||
editDir widget.Editor
|
||||
editURL widget.Editor
|
||||
editPreferredPort widget.Editor
|
||||
editPort widget.Editor
|
||||
editBrowserKind widget.Enum
|
||||
editDirPick widget.Clickable
|
||||
@@ -353,10 +358,10 @@ type Shell struct {
|
||||
|
||||
func NewShell(theme *material.Theme) *Shell {
|
||||
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-ads", Name: "广告投放", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\ads`, 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-backup", Name: "备用环境", Browser: "Edge", UserDataDir: `C:\Users\Public\chub\profiles\backup`, 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`, PreferredRemoteDebugPort: 9667, 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`, PreferredRemoteDebugPort: 9669, Status: "已退出"},
|
||||
}, searches: map[PathField]*pathSearchState{
|
||||
PathChromeExecutable: {},
|
||||
PathEdgeExecutable: {},
|
||||
@@ -368,6 +373,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
s.logDir.SetText(`C:\Users\Public\chub\logs`)
|
||||
s.remoteDebugPort.SetText(strconv.Itoa(domain.DefaultRemoteDebugPort))
|
||||
s.instancePort.SetText(strconv.Itoa(s.recommendRemoteDebugPort("")))
|
||||
s.closeOnExit.Value = true
|
||||
s.browserKind.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.assignPreferredRemoteDebugPorts()
|
||||
if s.selectedInstanceID == "" && len(s.rows) > 0 {
|
||||
s.selectedInstanceID = s.rows[0].ID
|
||||
}
|
||||
@@ -555,7 +562,7 @@ func (s *Shell) consumeControls(gtx layout.Context) {
|
||||
s.page = pageSettings
|
||||
}
|
||||
for s.newClick.Clicked(gtx) {
|
||||
s.page = pageCreate
|
||||
s.beginCreate()
|
||||
}
|
||||
for s.refreshClick.Clicked(gtx) {
|
||||
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) {
|
||||
for {
|
||||
event, ok := gtx.Event(
|
||||
@@ -684,7 +697,8 @@ func (s *Shell) beginEdit(id string) {
|
||||
s.editName.SetText(row.Name)
|
||||
s.editDir.SetText(row.UserDataDir)
|
||||
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
|
||||
if strings.EqualFold(row.Browser, "Edge") {
|
||||
s.editBrowserKind.Value = "edge"
|
||||
@@ -715,7 +729,7 @@ func (s *Shell) editIsDirty() bool {
|
||||
if s.editLocked(s.editOriginal) {
|
||||
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() {
|
||||
@@ -752,8 +766,18 @@ func (s *Shell) saveEdit() {
|
||||
s.editFeedback = "User Data Dir 必须是绝对路径。"
|
||||
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.Browser = browserDisplay(s.editBrowserKind.Value)
|
||||
row.PreferredRemoteDebugPort = preferredPort
|
||||
}
|
||||
row.Name = name
|
||||
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(instanceBrowserColumnWeight, material.Body2(s.theme, row.Browser).Layout),
|
||||
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)),
|
||||
)
|
||||
})
|
||||
@@ -966,17 +990,47 @@ func pathCell(theme *material.Theme, path string) layout.Widget {
|
||||
return style.Layout
|
||||
}
|
||||
|
||||
func remoteDebugPortCell(theme *material.Theme, port int) layout.Widget {
|
||||
return material.Body2(theme, remoteDebugPortText(port)).Layout
|
||||
func remoteDebugPortCell(theme *material.Theme, row InstanceRow) layout.Widget {
|
||||
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 {
|
||||
return strconv.Itoa(port)
|
||||
}
|
||||
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 {
|
||||
return 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(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.editURL)),
|
||||
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(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(statusLabel(s.theme, row.Status)),
|
||||
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 {
|
||||
label := s.editFeedback
|
||||
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(s.formField("启动 URL(可选)", "仅支持 http/https;留空时启动浏览器默认页", &s.instanceURL)),
|
||||
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(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
layout.Rigid(material.Body2(s.theme, "浏览器类型").Layout),
|
||||
@@ -1893,15 +1961,25 @@ func (s *Shell) createInstanceFromForm() {
|
||||
s.formFeedback = "请选择或输入 User Data Dir 后再创建。"
|
||||
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.rows = append(s.rows, InstanceRow{
|
||||
ID: fmt.Sprintf("instance-%d", s.nextInstance),
|
||||
Name: name,
|
||||
Browser: browserDisplay(s.browserKind.Value),
|
||||
UserDataDir: userDataDir,
|
||||
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
|
||||
ProxyID: s.createProxyID,
|
||||
Status: "已退出",
|
||||
ID: fmt.Sprintf("instance-%d", s.nextInstance),
|
||||
Name: name,
|
||||
Browser: browserDisplay(s.browserKind.Value),
|
||||
UserDataDir: userDataDir,
|
||||
TargetURL: strings.TrimSpace(s.instanceURL.Text()),
|
||||
ProxyID: s.createProxyID,
|
||||
PreferredRemoteDebugPort: preferredPort,
|
||||
Status: "已退出",
|
||||
})
|
||||
s.page = pageInstances
|
||||
s.formFeedback = ""
|
||||
@@ -2247,6 +2325,7 @@ func (s *Shell) requestStart(id string) {
|
||||
s.instanceFeedback = err.Error()
|
||||
return
|
||||
}
|
||||
settings.ReservedRemoteDebugPorts = s.reservedRemoteDebugPorts(id)
|
||||
if state == nil {
|
||||
state = &instanceStartState{}
|
||||
s.startStates[id] = state
|
||||
@@ -2333,11 +2412,21 @@ func (s *Shell) consumeStartResults() {
|
||||
status = "运行中(调试不可用)"
|
||||
}
|
||||
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) {
|
||||
continue
|
||||
}
|
||||
if 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 {
|
||||
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 {
|
||||
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) {
|
||||
@@ -2653,6 +2742,106 @@ func normalizeRemoteDebugStartPort(value string) (int, error) {
|
||||
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() {
|
||||
if s.onInstancesChanged != nil {
|
||||
s.onInstancesChanged(append([]InstanceRow(nil), s.rows...))
|
||||
|
||||
@@ -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) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
|
||||
Reference in New Issue
Block a user