diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 7c9d82a..b8fdbad 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -24,6 +24,7 @@ | BR-020 | 用户可为无认证代理维护必填、唯一的代理名称;新建实例默认使用实际 `chub.exe` 同级 `user_data_dirs\\<稳定实例 ID>`,实例列表在用户数据目录与调试端口之间显示代理名称。 | P1 | | BR-021 | 设置中的 Chrome 与 Edge 可执行文件路径必须支持 Windows 原生 `.exe` 文件选择;标准安装路径搜索必须在对应字段旁可感知地报告搜索、取消、确认当前路径、更新路径或失败,且不丢失已有输入。 | P1 | | BR-022 | 删除实例确认框默认只删除 Chub 实例配置;用户可显式、默认未勾选地选择同时删除该实例的 User Data Dir。数据删除必须在后台重新确认目标目录未被 Chub 或外部浏览器占用,并通过绝对路径、非卷根及非链接边界校验;失败时保留实例配置和目录。 | P1 | +| BR-023 | 默认 User Data Dir 与日志目录以实际 `chub.exe` 所在的程序目录为基准,分别解析为 `user_data_dirs` 与 `logs`;新建实例名称必须唯一,且默认 profile 子目录须由安全化名称与稳定 ID 组成。 | P1 | ## 验收标准 @@ -55,6 +56,9 @@ - 默认配置路径必须由实际可执行文件路径解析,而非当前工作目录;迁移只在新位置不存在且旧配置存在、可读取、可校验时执行。迁移使用现有原子写入并保留旧配置恢复备份;目标目录不可写或旧配置畸形时,不创建半成品配置、不静默回退或混合两个配置来源,并给出可恢复诊断。 - 设置页的 Chrome/Edge“选择”必须打开仅允许现有 `.exe` 的 Windows 原生文件选择器;取消、选择失败或不合规结果必须保留原输入。搜索标准安装路径时,结果即使与当前路径相同,也必须在同一字段旁明确表示“已确认当前路径可用”;搜索、取消、成功和失败不得依赖页面下方的全局反馈。 - 删除实例确认框默认不删除 User Data Dir。只有用户勾选“同时删除 User Data Dir 中的数据”并再次点击具体的“删除实例及数据”后,才允许删除该目录;对运行中、启动/停止中、外部关联、外部占用或未知占用实例,数据删除选项禁用并说明需先停止/刷新。后台须在实际删除前重新检查 Chub registry 与外部占用,拒绝相对路径、卷根、普通文件和符号链接/重解析点。数据删除成功后才移除实例配置;检查、占用或删除失败时实例配置和目录均保留,并给出可恢复提示。 +- “程序目录”始终指实际 `chub.exe` 的父目录,不能使用快捷方式、终端或服务改变的当前工作目录。设置页默认以“跟随程序目录”模式展示 `\\user_data_dirs` 与 `\\logs`;用户选择自定义绝对目录后仅影响后续新建实例或后续日志写入,已有实例目录绝不自动移动。旧配置中的空值或历史内置默认迁移为跟随程序目录,其他非空值保留为自定义目录。 +- 创建和编辑实例均要求名称去首尾空白后在 Windows 不区分大小写语义下唯一;重复名称在名称字段旁提示“实例名称已存在,请更换”,不清除输入。创建时默认目录为 `\\instance-<安全化名称>-<短稳定 ID>`;安全化必须拒绝或替换 Windows 非法字符、保留设备名、尾部空格/句点和路径过长,安全化后冲突也必须拒绝。实例改名不得重命名、复制或删除既有 User Data Dir。 +- 浏览器启动前必须在后台创建并验证目标 User Data Dir 的可写性;目录创建、权限、路径或长度失败时不得启动浏览器,并给出不含敏感信息的可恢复错误。日志目录同样在后台验证;无法写入时保留设置输入并提示用户改为可写目录,不能静默改用当前工作目录或泄露代理、Cookie、Token。 ## 不做 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ac2909b..1dcfba9 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -44,7 +44,7 @@ UI/CLI 不直接操作 `exec.Cmd`、Win32 handle 或数据库。Application 层 - Refresh:以 UI 传入的保存实例快照为范围,在后台读取 Chub registry、对应 profile 占用证据与已知 loopback CDP 端点,返回状态 DTO。刷新不按浏览器进程名或端口范围枚举;结果以实例 ID 与快照版本关联,UI 丢弃已删除或已编辑行的过期结果。 - Edit:UI 只提交可持久化的实例配置 DTO;运行时 PID、关联端口、占用来源和状态由刷新/启动结果返回,不作为编辑表单保存字段。编辑时保留未公开的非敏感启动选项,活跃或外部关联实例的身份约束字段保持只读。 - Proxy:本地配置保存稳定 `proxy_id` 指向的代理库项(必填、大小写无关唯一的显示名称与无认证端点);启动 adapter 在后台解析为当前 `LaunchSpec.ProxyServer` 快照。名称与地址同次校验/保存,`proxy_id` 不因改名或改地址而改变;代理不能含 userinfo,删除被引用项会被拒绝;修改代理只影响后续启动,不影响已运行实例。Gio 列表按当前 `proxy_id` 实时解析名称,不把可变显示名反规范化写入实例配置。 -- New instance default directory:`cmd/chub` 在窗口初始化时通过 platform config 解析实际 `os.Executable()` 的目录,并把 `\\user_data_dirs\\` 作为 Gio 新建表单的默认值;UI 只使用已提供的字符串,不读取文件或检查权限。实例 ID 必须先于默认目录分配,目录在保存/启动路径中按既有绝对路径与权限规则校验。不能使用当前工作目录,也不能将 `user_data_dirs` 根目录直接分给多个实例。 +- Portable defaults and instance naming:platform config 以实际 `os.Executable()` 的父目录(不是当前工作目录)解析 `user_data_dirs`、`logs` 两个跟随程序目录默认值。`Settings` 持久化每个目录的 `portable/custom` 模式:portable 在每次启动重新解析当前 exe 目录,custom 保留用户选择的绝对路径;旧空值及历史内置默认迁移为 portable,其他非空路径保留 custom。Gio 只显示已解析路径和模式,不自行读取/创建目录。新实例先分配稳定 ID,并由 application helper 生成 `\\instance--`;safe-name 独立于显示名称,处理 Windows 非法字符、设备名、尾部句点/空格、长度与安全化冲突。实例名称在 UI 与 config 写入边界按 Windows 不区分大小写规则唯一;编辑改名不移动既有 profile。启动 adapter 在启动浏览器前后台 `MkdirAll` 并验证该实例目录可写,失败不调用 launcher;日志目录初始化同样在 platform/application 后台处理,不能静默改到工作目录。 - GUI stop:主操作只对 Chub registry 中可验证的托管实例调用 `StopProfile(userDataDir, false)`,随后有限等待 registry 释放。外部关联、占用和未知实例永远不调用停止接口;超时或失败不自动强制关闭。 - 受管退出监控:仅为当前会话由 Chub 启动并获得进程句柄的实例建立一个 `Wait` 监控任务。退出事件携带实例 ID、启动代次和 PID,UI 仅在三者仍匹配且当前未处于 Chub 请求的停止中时,将运行时状态改为“已退出”并排队显示提示;过期事件、外部实例和应用退出后的监控取消均不得生成意外退出提示。 - CDP 页面目标查看:UI 仅将当前已保存实例快照交给后台 adapter;adapter 只接受“运行中”或“外部已关联”的有效实际端口,先复核对应 browser kind 的 loopback `/json/version`,再读取同一端口的 `/json/list`。结果 DTO 仅含 target ID、标题、类型和脱敏 URL;UI 以实例 ID、端口和请求代次丢弃过期结果。该链路不建立 WebSocket、不开启页面控制,也不提供物理窗口 ID。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 999d5ca..01acd71 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -57,6 +57,7 @@ | T-404 | 代理名称、独立默认目录与实例列表代理列 | T-304,T-306,T-309,T-403 | DONE | | T-405 | 设置浏览器 executable 选择与可感知搜索反馈 | T-205,T-206,T-207 | DONE | | T-406 | 实例删除确认中的 User Data Dir 显式删除 | T-301,T-303,T-305 | DONE | +| T-407 | 便携默认目录、实例名称唯一与启动目录预检 | T-403,T-404,T-405 | DOING | ## Backlog diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index 7becc65..932b788 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -19,6 +19,7 @@ | US-015 | 用户可以用易读名称维护代理,并在实例列表快速确认每个实例使用的代理;新建实例会得到程序目录内独立的默认 User Data Dir。 | P1 | | US-016 | 用户可以从设置页选择 Chrome 或 Edge 的本机可执行文件,或搜索标准安装位置,并立即知道当前路径是否已确认或被更新。 | P1 | | US-017 | 用户删除实例时默认只移除配置;在浏览器已停止且明确勾选后,可以同时删除该实例的 User Data Dir。 | P1 | +| US-018 | 用户可让新实例数据与日志默认跟随 chub.exe 所在目录,并通过唯一实例名称获得可读、独立且可写的默认 User Data Dir。 | P1 | ## 验收场景 @@ -77,3 +78,7 @@ CLI/API 返回稳定错误码和结构化数据;未知 PID、身份不匹配 ### US-017 删除实例及 User Data Dir 给定用户点击实例删除图标,确认框默认只说明删除 Chub 配置,User Data Dir 保留。用户可以勾选默认未选中的“同时删除 User Data Dir 中的数据”;框内显示对象名称、目录路径和不可恢复提示,确认按钮变为“删除实例及数据”。仅当实例显示为“已退出”或“启动失败”时该选项可用;运行、启动/停止、外部关联、外部占用和未知占用状态明确提示先停止并刷新。确认后 Chub 在后台再次检查该目录的 Chub registry 与外部浏览器占用,并校验目录边界;删除成功才从列表和配置移除实例。用户取消、占用、目录边界、权限或 I/O 失败时,实例配置和 User Data Dir 都保持,用户可关闭浏览器、检查权限后重试。 + +### US-018 便携默认目录与唯一实例名称 + +给定用户在设置页保持“跟随程序目录”,新建实例时默认 User Data Dir 显示为实际 `chub.exe` 所在目录下的 `user_data_dirs\\instance-<安全化实例名>-<短ID>`,日志目录显示为同级 `logs`;快捷方式的工作目录不会影响二者。用户改选自定义目录后,只有之后新建实例与之后的日志写入受影响,已有 profile 不移动。提交新建或编辑实例时,名称去首尾空白并按 Windows 不区分大小写规则验证;若与其他实例重复,名称字段就地提示并保留输入。启动前后台创建并检查 profile 目录可写;若程序目录受保护或路径无效,浏览器不启动,用户可改选可写目录。 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index 0dc60ba..bae90b0 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -19,6 +19,7 @@ | IX-015 | 代理名称、默认目录与列表列 | 代理名称/地址保存、下拉回填、实例默认目录、代理名称展示 | 新建、编辑、重名/重复地址、无代理、长名称、窄窗口、目录权限失败 | P1 | | IX-016 | 设置浏览器 executable 路径 | Chrome/Edge 选择本机 `.exe`、搜索标准安装位置与取消 | 选择中、用户取消、选择失败、非法结果、搜索中、取消、路径更新、确认当前路径、未找到、切换页面 | P1 | | IX-017 | 实例删除及数据删除 | 删除配置确认、显式勾选 User Data Dir、后台占用复核和文件删除 | 默认配置删除、已勾选、运行/外部禁用、删除中、用户取消、占用、路径拒绝、权限/I/O 失败、成功、迟到结果 | P1 | +| IX-018 | 便携目录与实例名称 | 程序目录默认模式、自定义目录、实例名称唯一与默认 profile 名称 | 初始默认、自定义、恢复默认、复制 exe、重名、非法/超长名称、安全化冲突、创建目录/日志目录权限失败、编辑改名 | P1 | ## 通用规则 @@ -42,3 +43,4 @@ - 配置迁移不是用户可编辑表单:应用启动时在 platform 层完成,GUI frame 不读取或写入文件。成功迁移仅以不打扰操作的文本说明来源已更新;失败时保留实例列表输入与当前可见页面,给出“程序目录中的 config.json 无法使用;请检查目录权限或恢复旧配置”的可恢复提示,不能显示完整配置、代理地址或用户路径。无需 HTML 原型,因为不新增可操作控件;需以真实 Windows 文件权限和 CLI/GUI 启动验证。 - 浏览器 executable 路径的“选择”和“搜索”是字段级异步操作:选择中禁止同一字段重复打开,搜索中按钮显示“取消”。每个结果都紧邻其 Chrome/Edge 路径字段显示,不能只写到需滚动才能看见的页面底部。用户取消、系统错误、空结果或不为绝对 `.exe` 的结果均保留原输入;搜索命中当前值时显示明确确认而不是静默完成。两个字段、页面切换和迟到结果彼此隔离;键盘与触控均可触发“选择”和“搜索/取消”。本次是既有控件的局部行为修复,不新增信息架构或视觉模式,因此无需改动 HTML 原型;须以真实 Windows 文件对话框 smoke 验收。 - 实例删除确认框默认焦点在“取消”,Escape/关闭与取消同效。默认“删除实例”只删除配置,并明确 User Data Dir 保留;“同时删除 User Data Dir 中的数据”是默认未选中的业务复选框,不与行选择混淆。勾选后显示完整目录和不可恢复警告,确认按钮使用具体“删除实例及数据”文案;仅“已退出”/“启动失败”可勾选,其他状态禁用且说明原因。数据删除中弹层保持、背景不可用且不提供伪取消;结果通过实例 ID、目录快照和请求代次过滤。成功后焦点回到可预测的相邻实例操作;检查、占用、边界、权限或 I/O 失败时保留当前实例、勾选和弹层,并说明目录与配置均未删除、可先停止浏览器或检查权限后重试。 +- 设置页的“默认 User Data Dir”和“日志目录”默认显示“跟随程序目录(chub.exe 所在目录)”解析后的绝对路径,不能称为或使用当前工作目录;每项提供“选择目录”和“恢复程序目录默认”。自定义目录不会移动已有 profile。新建与编辑名称在提交时就地校验:去首尾空白、大小写无关重复、安全化后目录冲突、Windows 非法/保留/超长名称均定位到名称字段,保留用户输入;成功创建的默认目录使用 `instance-<安全化名称>-<短ID>`,改名不改目录。目录创建/写入预检在后台,提交中禁用重复操作;失败时不启动浏览器、保留表单和路径,并给出“选择可写目录”的恢复路径。验证键盘、中文输入法、200% 缩放、长中文名称、只读程序目录和复制 exe 后的 portable/default-custom 切换。 diff --git a/docs/api.md b/docs/api.md index 0f528ef..55fd545 100644 --- a/docs/api.md +++ b/docs/api.md @@ -97,10 +97,17 @@ type TrayState struct { func DefaultPath() (string, error) // \\config.json func LegacyPath() (string, error) // %APPDATA%\\chub\\config.json,仅用于一次迁移 func OpenDefault() (*Store, error) // 目标优先;必要时校验并原子迁移 legacy -func DefaultInstanceUserDataDir(instanceID string) (string, error) // \\user_data_dirs\\ +type PortableDirectoryDefaults struct { + UserDataDir string // \\user_data_dirs + LogDir string // \\logs +} + +func ResolvePortableDirectoryDefaults() (PortableDirectoryDefaults, error) +func DefaultInstanceUserDataDir(defaultRoot, instanceName, instanceID string) (string, error) // \\instance-- +func PrepareInstanceUserDataDir(ctx context.Context, dir string) error ``` -调用方不得把当前工作目录作为配置来源,也不得自行复制、删除或合并 legacy 文件。`OpenDefault` 的迁移是文件级配置迁移,不迁移 `UserDataDir`、日志目录或浏览器文件;目标文件存在、legacy 缺失时均不产生写入。`DefaultInstanceUserDataDir` 只生成绝对默认字符串,不创建目录;调用方必须先分配稳定、唯一的实例 ID。迁移失败返回稳定的配置错误,调用方不得改用 legacy 继续写入。 +调用方不得把当前工作目录作为配置来源,也不得自行复制、删除或合并 legacy 文件。`OpenDefault` 的迁移是文件级配置迁移,不迁移已有实例 `UserDataDir` 或浏览器文件;目标文件存在、legacy 缺失时均不产生写入。目录模式为 portable 时必须每次通过 `ResolvePortableDirectoryDefaults` 从实际 exe 位置重新解析,custom 时只接受用户显式选择的绝对路径。`DefaultInstanceUserDataDir` 只生成绝对默认字符串,不创建目录;调用方必须先验证实例名称唯一并分配稳定 ID。`PrepareInstanceUserDataDir` 只能在后台调用,负责安全创建/写入预检,失败时不得启动浏览器。迁移失败返回稳定的配置错误,调用方不得改用 legacy 继续写入。 ## CLI 当前合约 diff --git a/docs/current-state.md b/docs/current-state.md index 14455ac..05c40e9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,7 +3,7 @@ ## 快照 - 日期:2026-07-27 -- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-406 已完成;T-402 已规划) +- 阶段:Phase 4 CDP 只读观察与后台入口(T-301 至 T-310、T-201 至 T-208、T-401、T-403 至 T-406 已完成;T-402 已规划;T-407 进行中) - 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、受限 CDP 目标读取、exe 同级 JSON 配置与 legacy 安全迁移、优雅关闭、Job Object、真实 Chrome/Edge smoke、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-310、T-401、T-403 至 T-406 已完成 - UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、代理名称、调试端口、状态和带边框的操作列;代理列只由稳定 `ProxyID` 解析当前名称(无代理时显示“无代理”),长名称省略且不挤压操作列。端口列在未启动/已退出时显示“建议 {端口}”、启动中显示“准备 {端口}”、托管运行显示“实际 {端口}”、外部关联显示“外部 {端口}”,避免将持久首选端口误作监听事实。新建表单从设置的起始端口推荐最低未冲突端口,并从实际 exe 同级 `user_data_dirs\\<实例 ID>` 预填一个独立、可编辑的 User Data Dir;ID 先分配,UI 不做目录 I/O,无法取得 exe 根目录时保留手动输入入口。编辑停止实例可修改首选端口;启动后台优先该端口并避开其他实例的首选/实际端口,成功回退后才保存新的首选值。操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 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,后台会复核 Chub registry、外部占用与安全目录边界,成功删除数据后才移除配置;失败或过期结果保留配置和目录。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;设置页 Chrome/Edge 路径已接入后台 Windows 原生 `.exe` 选择器,选择和搜索结果始终显示在对应字段旁;命中已填路径会明确确认而非静默完成。新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。 @@ -17,7 +17,8 @@ - T-404:已完成代理名称/地址同次显式保存、名称与端点唯一性校验、选择回填、列表代理名称列以及独立默认 profile 目录。新建实例先分配稳定 ID,再由实际 exe 目录生成 `user_data_dirs\\<实例 ID>`;代理名称按 ProxyID 推导而不复制到实例配置,UI 不进行文件 I/O。已通过单元测试、竞态检测、vet、无控制台 GUI 打包和 Windows smoke。 - T-405:已完成 Windows `OpenFileDialog` executable picker、Chrome/Edge 后台结果通道与字段级状态反馈。搜索同路径时明确确认当前路径可用;选择取消或错误保留输入,选择成功只更新目标字段。已通过单元测试、竞态检测、vet、无控制台打包,以及真实原生对话框取消/预选 Chrome `.exe` 返回 smoke。 - T-406:已完成实例删除确认中的 User Data Dir 显式删除。默认仅删除配置;数据删除必须显式勾选且只对已退出/启动失败实例开放。后台在删除前复核 Chub/external profile 占用,目录删除拒绝不安全路径、卷根、文件、链接和 Windows 重解析点;失败与过期结果均保留配置和目录。 -- blocker:无;下一任务为 T-402。 +- T-407:进行中。将把默认 User Data Dir 与日志目录改为实际程序目录下的 portable 默认值,增加自定义模式、实例名称唯一校验、安全化默认 profile 名称以及启动前目录可写预检。 +- blocker:无;当前任务为 T-407,随后恢复 T-402。 ## 当前目录 diff --git a/docs/tasks/T-407.md b/docs/tasks/T-407.md new file mode 100644 index 0000000..c608c3d --- /dev/null +++ b/docs/tasks/T-407.md @@ -0,0 +1,35 @@ +--- +id: T-407 +title: 便携默认目录、实例名称唯一与启动目录预检 +phase: 4 +deps: [T-403, T-404, T-405] +status: DOING +created: 2026-07-27 +owner: codex +--- + +## 需求与背景 + +复制 `chub.exe` 后,设置默认目录与新建实例默认 profile 必须跟随实际程序位置,而非快捷方式或终端当前工作目录。当前新建默认路径使用稳定 ID,缺少用户可读名称、名称唯一约束和启动前目录可写预检;程序目录受保护时 Edge/Chrome 可能在创建 profile 后立即退出,用户只能看到泛化退出提示。 + +## 方案与边界 + +- 定义“程序目录”为 `filepath.Dir(os.Executable())`。portable 默认 User Data Dir 与日志目录分别为 `\\user_data_dirs`、`\\logs`;不得使用 `os.Getwd()`。设置为每个目录持久化 portable/custom 来源:portable 随实际 exe 在下次启动重新解析,custom 为用户显式选择的绝对路径。旧空值及历史内置默认迁移 portable,其他非空值保留 custom;不移动既有实例目录或日志文件。 +- 设置页路径字段展示实际解析路径与“跟随程序目录”说明,保留目录选择,并增加“恢复程序目录默认”。修改默认 User Data Dir 只影响后续新建实例;修改日志目录只影响后续日志写入。UI 不执行目录 I/O。 +- 新建/编辑实例在提交时检查名称:去首尾空白,Windows 大小写无关唯一,并在安全化目录名后再检查冲突。重复或无效名称显示字段级“实例名称已存在,请更换”或对应修复说明,保留输入并聚焦名称。配置存储同样复核,防御手工 JSON 或并发写入。 +- 新实例先分配稳定 ID;默认目录为 `\\instance--`。安全化处理 Windows 非法字符、保留设备名、尾部空格/句点与最大路径长度;编辑改名绝不重命名或复制现有 profile。用户仍可在停止实例时选择其他绝对目录。 +- `InstanceDataPreparer` 在后台启动 adapter 中创建目标目录并验证可写;失败时不调用浏览器 launcher,返回稳定且无敏感信息的错误,UI 保留实例设置并给出“选择可写目录”的恢复提示。日志目录同样由后台/启动协调层创建与验证;不允许静默回退到当前工作目录。 +- 本任务不实现目录迁移、已有实例 profile 重命名、按名称接管外部浏览器、全盘目录扫描或目录权限提升;不记录代理、Cookie、Token 或认证信息。 + +## 验收要点 + +- 自动化:覆盖 portable/custom 目录解析、历史设置迁移、实际 exe 目录而非 cwd、默认实例目录安全化/稳定 ID、Windows 保留名/非法字符/尾部句点/长路径、名称大小写重复和安全化冲突、编辑排除自身、config 边界重复拒绝、后台目录创建/不可写/取消与启动前拒绝。`go test ./...`、`go test -race ./cmd/chub ./internal/ui ./internal/platform/config ./internal/platform/files`、`go vet ./...`、`cmd /c build.bat` 通过。 +- Windows smoke:将 exe 放入可写临时目录后,设置默认路径显示该 exe 同级 `user_data_dirs`/`logs`;快捷方式不同工作目录不改变结果。新建“审核 Edge”生成可读且独立的目录;重名、非法名称和受保护目录均在启动前给出可恢复提示,且 Edge/Chrome 不会被拉起。改名不移动既有 profile;选择自定义目录后仅新建实例使用它。 +- 交互与无障碍:字段 Label、帮助、错误和恢复操作可用 Tab、Space/Enter 完成;错误不只用颜色,提交失败保留表单和输入法状态。窄窗口、200% 缩放、长中文名称、高对比度和浅/深色下路径与按钮重排可读。 + +## 执行记录 + +- 状态:DOING +- 变更:已完成需求、架构、API、用户故事、交互清单和原型验收场景定义;待提交文档后实现。 +- 验证:待执行。 +- 阻塞:无。 diff --git a/docs/ui/README.md b/docs/ui/README.md index 6274e1a..e94851c 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -17,8 +17,10 @@ 7. 切换深色主题,并在窄窗口查看导航和表单重排。 8. 点击“设置”:检查四个路径的 Label、选择/搜索按钮;Chrome/Edge 的“选择”必须打开 Windows 原生 `.exe` 文件选择器,取消后保留输入。搜索期间按钮显示“取消”,再次点击取消当前搜索;搜索命中当前路径时在同一字段旁显示已确认反馈,切换页面后状态保持。 9. 双击“运行中”或“外部已关联”实例,点击“查看标签页”:检查读取中、目标列表、只读边界文案与 Escape/关闭后回到入口。未启动或没有实际端口的实例应禁用入口。 -10. 设置代理:检查代理名称和代理地址均有 Label;选择项会回填两项,保存修改后实例列表仅显示最新代理名称。新建实例应默认填入程序目录内不同的 `user_data_dirs\\<实例 ID>` 子目录;窄窗口下代理列不能挤压操作按钮。 +10. 设置代理:检查代理名称和代理地址均有 Label;选择项会回填两项,保存修改后实例列表仅显示最新代理名称。新建实例应默认填入程序目录内不同的 `user_data_dirs\\instance-<安全化名称>-<短ID>` 子目录;窄窗口下代理列不能挤压操作按钮。 11. 点击实例删除:默认确认只删除 Chub 配置。对于已退出实例,勾选“同时删除 User Data Dir 中的数据”后确认按钮改为“删除实例及数据”,并显示目录和不可恢复警告;运行或外部占用实例必须禁用该选项。取消、占用或失败时实例与目录保留。 +12. 设置默认目录:确认默认 User Data Dir 与日志目录显示“程序目录(chub.exe 所在目录)”下的 `user_data_dirs` 与 `logs`,可选择自定义目录或恢复程序目录默认;已有实例目录不改变。 +13. 新建或编辑实例:名称与现有名称(忽略大小写)重复时,名称字段就地提示且保留输入;默认目录使用 `instance-<安全化名称>-<短ID>`,改名不会重命名已有目录。模拟目录不可写时,保留表单并提示选择可写目录。 ## 当前原型假设 @@ -30,6 +32,7 @@ - 外部实例显示占用证据,但不提供接管或强制关闭入口。 - 实例删除默认只移除配置;数据删除是明确、默认未选中的附加选择,且只能对已退出/启动失败实例发起。实际删除前仍复核占用和目录边界;原型只模拟结果,不执行文件删除。 - “标签页(CDP 页面目标)”只读取已验证 loopback CDP 端点的展示 DTO;不会显示 WebSocket 调试地址,不提供任何页面或浏览器控制。它不是物理窗口管理视图。 +- “程序目录”是实际 `chub.exe` 的父目录,不是启动快捷方式的当前工作目录。设置以 portable/custom 模式保存目录来源;portable 在应用启动时重新解析,custom 只在用户显式选择后生效。 - 原型中的“确认”只模拟事件反馈,不启动真实浏览器或改变文件。 ## Gio 交接方向 @@ -44,5 +47,6 @@ | 详情抽屉 | 自定义辅助面板,关闭后焦点返回实例行 | | 确认对话框 | 模态层限制背景交互,Escape 取消,危险操作不默认聚焦 | | 实例数据删除 | 持久的业务勾选、实例 ID/目录快照/请求代次;后台复核后才允许删除并在成功后持久化配置 | +| 便携目录与名称 | portable/custom 目录模式、稳定实例 ID 与安全化目录名;后台目录创建/可写预检,名称字段级唯一性错误 | | CDP 页面目标 | 异步只读 DTO、ID/端口/请求快照防过期、Escape 关闭后返回入口焦点 | | Toast/状态区 | 非模态状态反馈;异步结果通过统一事件触发重绘 | diff --git a/docs/ui/browser-manager.html b/docs/ui/browser-manager.html index 8c36397..38e182e 100644 --- a/docs/ui/browser-manager.html +++ b/docs/ui/browser-manager.html @@ -283,8 +283,8 @@
Chrome 与 Edge 可分别设置,也可以继续使用自动发现。

默认目录

-
新建实例时作为默认建议路径;不会移动或删除已有 profile。
-
日志仅保存必要诊断信息,并过滤敏感参数。
+
默认跟随程序目录(chub.exe 所在目录)的 user_data_dirs;只影响之后新建的实例,不移动已有 profile。
+
默认跟随程序目录的 logs;日志仅保存必要诊断信息,并过滤敏感参数。

代理

仅保存无认证代理。下拉框、实例创建和编辑均展示完整地址。

选择后会回填地址;选择“新建”会清空输入框。
支持 http、https、socks4、socks5 的 scheme://host:port;不允许用户名、密码或路径。
新建代理:填写地址后保存。
@@ -376,6 +376,7 @@ 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(), preferredPort = Number($('editPreferredPort').value); if (!name || !dir) { showToast('请填写实例名称和 User Data Dir。'); (!name ? $('editName') : $('editDataDir')).focus(); return; } + if (instances.some(item => item.id !== x.id && item.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase())) { showToast('实例名称已存在,请更换。'); $('editName').focus(); return; } const locked = editIsLocked(x); x.name = name; x.url = url; x.proxyId = $('editProxy').value; 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}”的实例配置。`); @@ -400,7 +401,7 @@ $('tabsTargetList').hidden = !targets.length; }, 380); } - function openLaunch() { $('drawer').innerHTML = `

新建浏览器实例

配置完成后,Chub 会在本机启动一个隔离环境。

浏览器配置

浏览器类型 *
留空时按浏览器类型搜索标准安装路径。

运行环境

必须是绝对路径;同一目录不能被多个实例同时使用。
仅支持 http/https;留空时启动浏览器默认页。
系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。
选择设置中保存的无认证代理;端点会在启动时安全传递。

启动选项

`; 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 openLaunch() { $('drawer').innerHTML = `

新建浏览器实例

配置完成后,Chub 会在本机启动一个隔离环境。

浏览器配置

名称必须唯一;目录会使用安全化名称和稳定 ID,之后改名不会移动已有数据。
浏览器类型 *
留空时按浏览器类型搜索标准安装路径。

运行环境

默认跟随程序目录;启动前会验证目录可写,同一目录不能被多个实例同时使用。
仅支持 http/https;留空时启动浏览器默认页。
系统从此值开始建议未被其他 Chub 实例预留的端口;实际端口会在启动时验证。
选择设置中保存的无认证代理;端点会在启动时安全传递。

启动选项

`; showDrawer(); renderProxyOptions($('launchProxy')); $('closeDrawer').onclick = hideDrawer; $('cancelLaunch').onclick = hideDrawer; $('chooseInstanceDir').onclick = () => showToast('原型演示:将打开目录选择器。'); $('launchForm').onsubmit = e => { e.preventDefault(); const name = $('instanceName'), data = $('dataDir'), port = Number($('launchPreferredPort').value); if (!name.value.trim()) { name.focus(); showToast('请输入实例名称。'); return; } if (instances.some(item => item.name.trim().toLocaleLowerCase() === name.value.trim().toLocaleLowerCase())) { name.focus(); showToast('实例名称已存在,请更换。'); return; } 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(); } @@ -454,6 +455,7 @@ document.querySelectorAll('.search-setting').forEach(b => b.onclick = () => { if (b.dataset.timer) { clearTimeout(Number(b.dataset.timer)); delete b.dataset.timer; b.textContent = '搜索'; showToast('已取消当前搜索。'); return; } b.textContent = '取消'; showToast('正在搜索可用路径…'); b.dataset.timer = String(setTimeout(() => { delete b.dataset.timer; b.textContent = '搜索'; showToast('搜索完成:已更新当前字段。'); }, 1600)); }); $('proxySelect').onchange = () => { selectedProxyID = $('proxySelect').value; renderProxies(); $('proxyServer').focus(); }; $('proxySave').onclick = () => { const server = $('proxyServer').value.trim().toLowerCase(); if (!/^(https?|socks[45]):\/\/[^\/@?#:]+(?::\d+)$/.test(server)) { $('proxyStatus').textContent = '请填写无认证的 scheme://host:port 代理地址。'; return; } if (proxies.some(p => p.server === server && p.id !== selectedProxyID)) { $('proxyStatus').textContent = '该代理地址已存在,请从下拉框选择它。'; return; } if (selectedProxyID) { const p = proxies.find(item => item.id === selectedProxyID); p.server = server; p.name = server; } else { selectedProxyID = `proxy-${Date.now()}`; proxies.push({ id: selectedProxyID, name: server, server }); } renderProxies(); showToast('代理已保存;后续启动将使用最新端点。'); }; + document.querySelectorAll('.restore-portable-setting').forEach(button => button.onclick = () => { const target = $(button.dataset.target); target.value = button.dataset.target === 'logDir' ? 'D:\\Tools\\chub\\logs' : 'D:\\Tools\\chub\\user_data_dirs'; showToast('已恢复为程序目录默认值。'); }); $('proxyDelete').onclick = () => { const proxy = proxies.find(p => p.id === selectedProxyID), uses = instances.filter(i => i.proxyId === selectedProxyID).length; if (!proxy) return; if (uses) { $('proxyStatus').textContent = `该代理仍被 ${uses} 个实例使用,不能删除。`; return; } $('proxyDeleteText').textContent = `确定删除代理“${proxy.server}”?此操作不会修改任何实例。`; $('proxyDeleteDialog').showModal(); setTimeout(() => $('proxyDeleteCancel').focus(), 0); }; $('proxyDeleteCancel').onclick = () => $('proxyDeleteDialog').close(); $('proxyDeleteDialog').addEventListener('cancel', e => { e.preventDefault(); $('proxyDeleteDialog').close(); }); $('proxyDeleteConfirm').onclick = () => { const proxy = proxies.find(p => p.id === selectedProxyID); if (proxy) proxies.splice(proxies.indexOf(proxy), 1); selectedProxyID = ''; $('proxyDeleteDialog').close(); renderProxies(); $('proxySelect').focus(); showToast('代理已删除。'); }; $('settingsForm').addEventListener('submit', e => { e.preventDefault(); $('settingsStatus').textContent = '设置已保存 · 最后更新:刚刚'; $('settingsStatus').classList.remove('warning'); showToast('设置已保存,后续新建实例将使用新的默认值。'); });