diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc7c48..0762562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,122 @@ 本项目采用 `0.0.x` 递增版本号。正式安装包、便携包和校验清单发布在 [GitHub Releases](https://github.com/PuppetWen/CDriveShiftAI/releases)。 +## 0.1.0 + +### 鼠标快捷操作 + +- 长按时长范围由 500–10000 毫秒调整为 0–3000 毫秒,步进保持 100 毫秒;0 毫秒表示鼠标按键按下后立即触发。 +- 前端滑杆、设置持久化和 Rust 原生 Raw Input 监听器统一使用新范围,旧配置中大于 3000 毫秒的值会安全收敛到上限。 +- 原生监听器针对 0 毫秒使用即时发送路径,不创建无意义的延时线程。 + +### 全应用字体大小 + +- 原有“小 / 标准 / 大 / 特大”四档按钮改为连续滑杆,以原“标准”字体为 100%。 +- 可在 50%–300% 范围内来回拖动,每次变化 10%;主窗口、极速搜索、搜索结果和辅助窗口内的全部文字实时变化。 +- 卡片、按钮、图标与间距不跟随字号整体放大,并移除整页横向滚动容器,避免吸顶内容在高倍缩放时被压住。 +- 拖动时每一步都会实时应用,松开或键盘调整结束后保存最终值;旧版本设置会归一到最近的 10%。 +- 百分比标记按滑块圆点中心的实际可移动轨道定位,100% 标记与 100% 圆点精确对齐。 + +### 全局局部放大镜 + +- 新增跨程序、跨显示器生效的 Windows 局部放大镜,按住自定义组合键时在鼠标附近显示,松开后隐藏。 +- 点击录入区后,实际按住 Ctrl、Alt、Shift 或 Win 组合并滚动一次即可保存快捷方式,不使用下拉框。 +- 滚轮用于调节放大倍率,放大镜宽度和高度分别通过滑杆设置;放大窗口为鼠标穿透,不会拦截点击。 +- 只有组合键与滚轮同时触发后才显示,单独按 Ctrl 不会放大;快捷键录入期间会暂停全局钩子,确保滚轮事件进入录入框。 +- 放大区域增加明显边框,采样画面始终以鼠标位置为中心,并且放大画面不显示鼠标图标。 + +### 兼容与验证 + +- 新增设置读取归一化,异常缩放值和鼠标毫秒值在传入窗口或原生监听器前完成验证、取整与限制。 +- 新增连续缩放、旧设置兼容、0 毫秒即时触发范围及原生 3000 毫秒上限测试。 + +## 0.0.12 + +### 鼠标快捷操作 + +- 鼠标触发按键由下拉框改为直接录入:点击录入区域后,按下鼠标后退侧键、前进侧键或中键即可保存;左键和右键不会误覆盖现有设置。 +- 录入结果立即持久化并同步到底层全局鼠标监听器,同时保留一键关闭鼠标触发的入口。 +- 长按时长由手动输入改为 500–10000 毫秒滑杆,步进 100 毫秒,同时显示精确毫秒与秒数,并支持键盘方向键调节。 + +### 历史更新 + +- “更新与诊断”界面内置 0.0.1 至当前版本的更新说明,离线时也能查看历史内容。 +- 每个历史版本使用独立折叠面板,默认收起;展开或收起一条记录不会影响其他版本。 +- 历史列表内容较多时显示独立纵向滚动条,支持鼠标滚轮与键盘下滑;每条记录保留对应的 GitHub Release 入口,并在窄屏下自动切换为单列布局。 + +### 验证 + +- 新增鼠标按键事件映射、长按毫秒归一化与历史版本数据完整性测试。 +- 58 项单元测试、TypeScript/Electron 类型检查、前后端生产构建及安装包/便携版冒烟验证通过。 + +## 0.0.11 + +### 迁移可靠性 + +- 对 Windows 在目录最终切换阶段返回的临时 `EPERM`、`EACCES`、`EBUSY` 和 `ENOTEMPTY` 增加有限退避重试,降低实时防护、索引器或资源管理器短暂持有句柄导致的偶发失败。 +- 重试期间若目标路径被其他程序创建会立即停止,不覆盖现有目录;持续被占用时给出关闭关联应用、资源管理器窗口、同步工具或实时防护的明确提示。 +- 源目录切换、目标目录发布、失败恢复和迁移回滚共用同一套受控重命名逻辑。 + +### 路径记忆与界面可读性 + +- 浏览目录会按迁移源、迁移目标、分析、搜索范围、内容索引和复制目标分别记住上次选择,并在下次打开时回到对应位置。 +- 设置新增“小 / 标准 / 大 / 特大”四档字体与界面大小,文字、按钮、间距和图标同步缩放,选择后立即生效并持久化。 +- 大屏页面内容宽度上限由 1250px 扩展到 1540px,更充分利用最大化和高分辨率窗口空间,同时保留窄屏自适应。 + +### 验证 + +- 新增临时重命名占用重试、目标路径冲突保护和目录浏览历史清理测试。 +- 53 项单元测试及 TypeScript/Electron 类型检查通过。 + +## 0.0.10 + +### 安全迁移 + +- 修复源目录包含 Junction、目录符号链接或文件符号链接时,被统一误报为“存在无法读取的条目”并阻止迁移的问题。 +- Windows 复制阶段改为使用 `robocopy /SJ /SL`,保留链接本身而不展开链接目标,避免递归复制、重复占用空间或遗漏链接。 +- 迁移校验新增重解析点数量、相对路径和链接目标比对;真正的权限错误、读取错误和扫描截断仍会阻止迁移。 +- 复制后重新扫描当前源目录和暂存副本,降低复制期间发生写入却仍误判校验成功的风险。 + +### 风险提示与验证 + +- 预检界面显示检测到的链接数量,并明确说明关联应用仍在运行或持续写入时可能导致迁移失败或遗漏切换瞬间的新写入。 +- 新增目录汇总和真实 Robocopy Junction 回归测试,验证链接不会被展开或排除。 +- 49 项单元测试、TypeScript/Electron 类型检查和前后端生产构建通过。 + +## 0.0.8 + +### 索引可靠性 + +- 修复 Windows 上已有持久化索引时,刷新过程无法直接覆盖旧缓存并返回 `os error 5` 的问题;现在会先释放旧内存映射,再以可回滚的原子替换发布新缓存。 +- 增加系统盘索引完整性校验。只有 120 条等明显残缺的旧缓存不会再显示为 `CACHED / 索引已就绪`,而会自动废弃并重新构建。 +- MFT 快速通道若返回明显不完整的系统盘结果,会自动切换到完整目录扫描;最终结果仍不完整时保持错误状态,不再保存或冒充可用索引。 +- 缓存无法持久化时明确标记为仅本次会话可用,并把索引状态迁移和底层诊断写入本地日志。 + +### 验证 + +- 新增 Windows 已有缓存覆盖、残缺系统盘缓存拒绝和失败回滚测试。 +- 缓存重启、增量重放、强制刷新、动态文件变化以及 616 万条真实索引的分页查询回归通过。 + +## 0.0.7 + +### 搜索与文件操作 + +- 长路径悬浮提示改为显示完整路径,可在目录分隔位置自然换行,并自动保持在窗口边界内。 +- 搜索结果右键菜单新增受控强制删除:先枚举占用进程并要求确认,随后关闭相关进程树再删除目标;失败时保留明确诊断信息。 +- 目录名称搜索、分页懒加载、完整结果排序、实时新增/删除和内容搜索范围清理完成回归验证。 + +### 多语言与界面 + +- 新增不少于十种主流语言选择,补齐导航、搜索、设置、迁移、归属地图、更新说明和索引运行状态的动态切换。 +- 侧边栏支持低延迟拖动调整宽度与折叠,统一校准图标、标题、按钮、更新记录和搜索筛选区的对齐。 +- 五套主题补齐语言选择器、完整路径提示和强制删除确认窗口。 + +### 性能与验证 + +- 616 万级持久化名称索引的包含、全词和模糊查询继续保持亚秒响应。 +- 托盘静默时销毁渲染器并暂停全量工作,仅保留索引监听、快捷键与托盘能力。 +- 增加完整路径提示边界、占用进程强制删除、缓存重启、增量重放和动态文件变化回归测试。 + ## 0.0.6 ### 极速搜索 diff --git a/SMOKE_TEST_REPORT.md b/SMOKE_TEST_REPORT.md new file mode 100644 index 0000000..a203d86 --- /dev/null +++ b/SMOKE_TEST_REPORT.md @@ -0,0 +1,168 @@ +# CDriveShiftAI 全功能测试报告 + +生成时间:2026-08-06 + +## 一、测试目标 +- 执行项目现有单元测试与所有烟雾测试脚本。 +- 发现并修复阻塞功能验证的缺陷,不移除现有功能。 +- 每条测试记录包含覆盖功能与测试用例。 + +## 二、测试环境 +- 工作目录:`E:\DevelopmentTools\AIDevelop\CDriveShiftAI` +- 平台:Windows(本地) +- 引擎:Node + Electron + Chrome 自动化脚本 + +## 三、测试记录(按执行顺序) + +1. `npm run test` + - 功能:全量单元测试(核心模块、服务层、辅助工具、类型脚本) + - 用例:Vitest 全量运行(13 个测试文件 / 46 条用例) + - 结果:全部通过 + +2. `npm run test:indexer` + - 功能:索引器检索能力 + - 用例:名字匹配、分页、复合过滤、作用域过滤、正则搜索、目录创建/删除场景、内容搜索、持久化内容文档 + - 结果:全部通过 + +3. `npm run test:force-delete` + - 功能:强制删除与相关进程终止 + - 用例:进程托管、目标移除、返回值校验、删除后清理 + - 结果:全部通过 + +4. `npm run test:index-cache` + - 功能:索引缓存生命周期 + - 用例:缓存模式启动、增量重建、重启后增量恢复、手动刷新 + - 结果:全部通过 + +5. `npm run test:packaged-index-cache` + - 功能:打包模式下缓存可复用性 + - 用例:启动后缓存读取、二次启动复用、状态与条目数量校验 + - 结果:全部通过 + +6. `npm run test:background-cpu` + - 功能:后台任务 CPU 行为 + - 用例:初始、前台重建索引、再次降噪三阶段资源监控 + - 结果:全部通过 + +7. `npm run test:search-performance` + - 功能:搜索性能 + - 用例:子串、完整词、模糊匹配查询时延与返回条数 + - 结果:全部通过 + +8. `npm run test:app-performance` + - 功能:应用启动与资源占用 + - 用例:UI 可用耗时、索引可用时延、空闲与运行时 CPU/内存、工作进程与索引器活跃性 + - 结果:全部通过 + +9. `npm run test:tray-performance` + - 功能:托盘驻留与资源收缩 + - 用例:启动后托盘化场景内存/CPU、子进程裁剪、启动最小化后可恢复 + - 结果:全部通过 + +10. `npm run test:clean-exit` + - 功能:优雅退出机制 + - 用例:退出耗时、EPIPE 异常、残留进程 + - 结果:全部通过 + +11. `npm run test:auto-update` + - 功能:便携版自动更新主流程 + - 用例:版本更新、替换目标文件、补丁文件与运行时产物清理 + - 结果:全部通过 + +12. `npm run test:auto-update-rejection` + - 功能:更新校验失败回退 + - 用例:伪造包校验触发拒绝、主程序版本保持不变 + - 结果:全部通过 + +13. `npm run test:auto-update-rollback` + - 功能:启动阶段回滚流程 + - 用例:非法启动副本触发回滚、恢复旧版本、清理失败替换 + - 结果:全部通过 + +14. `npm run test:installed-update` + - 功能:已安装版更新流程 + - 用例:安装路径保留、数据保留、备份包清理、安装器副作用检查 + - 结果:全部通过 + +15. `npm run test:portable` + - 功能:便携版打包与数据目录分离 + - 用例:单文件启动、数据目录与可执行同目录、环境目录写入 + - 结果:全部通过 + +16. `npm run test:ownership-state` + - 功能:归属地图扫描状态持久化 + - 用例:扫描记录恢复、扫描模式不重复运行、历史记录回填 + - 结果:全部通过 + +17. `npm run test:ownership-map` + - 功能:归属地图扫描与展示 + - 用例:分类统计、跨盘归属样本、错误计数、分类数量 + - 结果:全部通过 + +18. `npm run test:analysis-state` + - 功能:分析结果持久化与恢复 + - 用例:分析入库、上次分析恢复、删除后持久化清理、重启后二次验证 + - 首次结果:首次失败(页面目标连接失败) + - 修复后结果:全部通过 + +19. `npm run test:search-state` + - 功能:搜索、设置、快搜与快捷键/迁移状态 + - 用例:搜索历史与书签、文件夹归属、导航恢复、设置自动保存(系统/AI)、快捷键冲突与历史迁移回放 + - 结果:全部通过 + +20. `npm run test:visual` + - 功能:关键界面视觉与交互回归(概览) + - 用例:概览、设置、搜索、迁移、分析、归属图、AI 设置、多主题快速切换、关键控件布局与交互截图 + - 首次结果:首次失败(无 app root 渲染) + - 修复后结果:全部通过 + +21. `node scripts/visual-smoke.mjs "file:///E:/DevelopmentTools/AIDevelop/CDriveShiftAI/dist/index.html?view=search"` + - 功能:搜索视图可视化回归分支(直接 dist 回放) + - 用例:搜索视图加载、搜索列宽恢复、搜索模式切换、搜索相关控件快照 + - 结果:通过(`searchSizeColumn: true`) + +22. `node scripts/visual-smoke.mjs "file:///E:/DevelopmentTools/AIDevelop/CDriveShiftAI/dist/index.html?view=settings"` + - 功能:设置视图可视化回归分支(直接 dist 回放) + - 用例:设置页加载、侧边栏相关控件渲染、设置项快照 + - 结果:通过 + +23. `node scripts/visual-smoke.mjs "file:///E:/DevelopmentTools/AIDevelop/CDriveShiftAI/dist/index.html?view=ownership-map"` + - 功能:归属地图视图可视化回归分支(直接 dist 回放) + - 用例:归属地图加载、上下文菜单、分布图区域与卡片交互快照 + - 结果:通过 + +24. `node scripts/visual-smoke.mjs "file:///E:/DevelopmentTools/AIDevelop/CDriveShiftAI/dist/index.html?view=analyze"` + - 功能:分析视图可视化回归分支(直接 dist 回放) + - 用例:分析页加载、AI 相关切换按钮、清空结果后的状态 + - 结果:通过 + +25. `node scripts/visual-smoke.mjs "file:///E:/DevelopmentTools/AIDevelop/CDriveShiftAI/dist/index.html?view=migrate"` + - 功能:迁移视图可视化回归分支(直接 dist 回放) + - 用例:迁移页加载、阶段展示、迁移历史与状态区域渲染 + - 结果:通过 + +## 四、发现的缺陷与修复 + +1. `scripts/visual-smoke.mjs` + - 问题:当 `dist/index.html` 作为回退入口时,Chrome 文件协议下无法加载模块脚本,页面 `#root` 无子节点导致超时。 + - 现象:`document.getElementById("root")?.children.length` 一直为 0。 + - 修复: + - 增加 `--allow-file-access-from-files` + - 增加 `resolvePreviewUrl`,无 Vite 服务时回退到 `dist/index.html`。 + - 统一通过 `fetchPageTarget` 选择有 `webSocketDebuggerUrl` 的 page 目标。 + - 结果:`test:visual` 与各 `view` 参数可稳定通过。 + +2. `scripts/smoke-analysis-persistence.mjs` + - 问题:启动阶段直接一次性取 `json/list` 后查 `page` 目标,偶发性取到无 `webSocketDebuggerUrl` 的项导致误判。 + - 现象:`Electron page target was unavailable`、`fetch failed ECONNREFUSED` 间歇出现。 + - 修复:为 `type === "page"` 且有 `webSocketDebuggerUrl` 的目标加入重试循环;连接失败时捕获并继续重试。 + - 结果:`test:analysis-state` 可稳定通过。 + +## 五、结论 +- 当前工作树下,项目现有测试脚本与单元测试在完整回归中均通过。 +- 修改仅涉及测试脚本的稳定性与环境兼容性,不涉及业务功能删减。 + +## 六、最终全量复测(当前状态) + +- 复测命令组合(均通过):`test`, `test:indexer`, `test:force-delete`, `test:index-cache`, `test:packaged-index-cache`, `test:background-cpu`, `test:search-performance`, `test:app-performance`, `test:tray-performance`, `test:clean-exit`, `test:auto-update`, `test:auto-update-rejection`, `test:auto-update-rollback`, `test:installed-update`, `test:portable`, `test:ownership-state`, `test:ownership-map`, `test:analysis-state`, `test:search-state`, `test:visual` +- 额外视图验证命令(均通过):`node scripts/visual-smoke.mjs` 结合 `?view=search|settings|ownership-map|analyze|migrate` diff --git a/docs/RELEASE-0.0.10.md b/docs/RELEASE-0.0.10.md new file mode 100644 index 0000000..939af91 --- /dev/null +++ b/docs/RELEASE-0.0.10.md @@ -0,0 +1,31 @@ +# CDriveShiftAI 0.0.10 发布说明 + +本版修复安全迁移对 Windows Junction 和符号链接的处理。此前目录汇总会把这些链接记录为扫描错误,导致类似 `.codex` 的目录即使权限正常也无法通过预检;若只放开预检,原复制参数又会排除 Junction,产生不完整副本。 + +## 链接安全迁移 + +- 将 Junction、目录符号链接和文件符号链接作为独立重解析点统计,不再误报为读取失败。 +- 使用 `robocopy /SJ /SL` 复制链接本身,不跟随或展开链接目标。 +- 保留真正的扫描保护:无法打开目录、无法读取链接、无法读取条目或超过扫描上限时仍会停止迁移。 +- 预检页面显示链接数量,并说明链接目标不会被展开。 + +## 完整性校验 + +- 复制完成后重新扫描当前源目录和目标暂存副本。 +- 同时比较文件数、目录数、总字节数和重解析点数量。 +- 逐项比较每个链接的相对路径和链接目标,链接缺失或目标变化时拒绝切换。 +- 校验失败继续使用现有事务恢复流程,不删除原目录。 + +## 运行中程序 + +迁移前必须完全退出会读取或写入源目录的应用。运行中的程序可能锁定文件、阻止目录重命名,或在最终切换瞬间产生尚未复制的新写入;Windows 无法可靠枚举所有打开文件,因此应用不会把在线迁移宣称为安全。 + +迁移 `.codex` 前,应关闭 Codex Desktop、Codex CLI、相关终端任务及后台 Codex 进程。 + +## 验证 + +- 真实 Windows Junction 通过 Robocopy 复制后仍保持为 Junction,链接目标未被展开。 +- 当前 `.codex` 只读汇总识别 1 个 Junction,扫描错误为 0。 +- 14 个测试文件、49 项单元测试全部通过。 +- TypeScript 与 Electron 类型检查通过。 +- Electron 主进程和 Web 生产构建通过。 diff --git a/docs/RELEASE-0.0.11.md b/docs/RELEASE-0.0.11.md new file mode 100644 index 0000000..89e0b29 --- /dev/null +++ b/docs/RELEASE-0.0.11.md @@ -0,0 +1,31 @@ +# CDriveShiftAI 0.0.11 发布说明 + +本版集中处理迁移最终切换阶段的偶发 Windows 占用错误,并改善目录浏览和高分辨率界面的使用体验。 + +## 迁移可靠性 + +- 对 `rename` 返回的临时 `EPERM`、`EACCES`、`EBUSY` 和 `ENOTEMPTY` 进行有限退避重试,覆盖实时防护、索引器、资源管理器或同步工具短暂持有目录句柄的情况。 +- 每次失败后重新确认目标路径;若迁移期间已有其他程序创建目标目录,会立即停止且不覆盖数据。 +- 若重试后仍被 Windows 拒绝,会明确提示关闭关联程序、资源管理器窗口、同步工具或实时防护。 +- 相同保护应用于源目录切换、目标目录发布、自动恢复和迁移回滚。 + +运行中的程序仍然会影响迁移。程序可能锁定文件或目录,也可能在复制和最终切换之间继续写入;因此迁移 `.codex` 等运行数据前,应退出 Codex Desktop、Codex CLI、相关终端任务及后台进程。有限重试只能处理短暂占用,不能把持续运行中的目录变成安全的在线迁移。 + +## 目录浏览记忆 + +- 浏览器会记住用户实际选择的目录,并在下次打开时作为默认位置。 +- 迁移源、迁移目标、分析目录、名称搜索范围、内容索引范围和复制目标分别保存,避免不同任务互相覆盖路径。 +- 若上次目录已被删除或磁盘离线,会忽略失效记录并使用系统默认位置。 +- 快速搜索独立窗口使用自身窗口作为目录对话框父窗口,不依赖主窗口保持打开。 + +## 字体、界面大小和全屏布局 + +- 设置 → 界面外观新增“小(90%)/ 标准(100%)/ 大(110%)/ 特大(120%)”四档。 +- 调整会同步缩放文字、按钮、图标和间距,立即应用到主窗口、快速搜索窗口和卸载恢复窗口,并在重启后保留。 +- 常规页面的大屏内容宽度上限从 1250px 提升到 1540px;窄窗口仍按原有断点自适应。 + +## 验证 + +- 15 个测试文件、53 项单元测试通过。 +- TypeScript 与 Electron 类型检查通过。 +- Electron 主进程和 Web 生产构建通过。 diff --git a/docs/RELEASE-0.0.12.md b/docs/RELEASE-0.0.12.md new file mode 100644 index 0000000..72a1e08 --- /dev/null +++ b/docs/RELEASE-0.0.12.md @@ -0,0 +1,33 @@ +# CDriveShiftAI 0.0.12 发布说明 + +本版本重新设计鼠标快捷操作设置,并把完整的历史更新记录直接带进“更新与诊断”界面。 + +## 鼠标快捷操作 + +- 触发按键不再通过下拉框选择。点击录入区域后,直接按鼠标后退侧键、前进侧键或中键即可保存。 +- 左键和右键不会覆盖现有设置;按下不支持的按键时,录入器会继续等待并显示有效按键提示。 +- 录入结果即时持久化并同步到底层全局鼠标监听器,同时保留一键关闭鼠标触发。 +- 长按时长改为 500–10000 毫秒滑杆,步进 100 毫秒;界面同时显示精确毫秒数与秒数。 +- 滑杆支持鼠标拖动和键盘方向键,拖动或键盘调整结束、以及失焦时自动保存。 + +## 历史更新 + +- “更新与诊断”内置从 0.0.1 开始的历史版本说明,无网络时仍可查看。 +- 每个历史版本都可以独立展开或收起,默认全部收起,展开一条不会改变其他条目的状态。 +- 历史条目超过可视区域时显示独立纵向滚动条,可使用鼠标滚轮或键盘继续下滑。 +- 展开后按模块显示该版本的改动,并提供对应 GitHub Release 的快捷入口。 +- 历史内容在窄屏下自动改为单列布局,保持可读性。 + +## 验证 + +- 58 项单元测试通过。 +- TypeScript 前端与 Electron 主进程类型检查通过。 +- Web 与 Electron 主进程生产构建通过。 +- Windows 安装包、便携版打包和干净退出冒烟验证通过。 + +## 下载 + +- `CDriveShiftAI-x64.exe`:安装版,可选择安装目录。 +- `CDriveShiftAI-x64-portable.exe`:便携版,可在原位置直接运行。 +- `update-manifest.json`:自动更新清单,包含安装包的 SHA-512 校验值。 +- `latest.yml`:Electron 更新元数据。 diff --git a/docs/RELEASE-0.1.0.md b/docs/RELEASE-0.1.0.md new file mode 100644 index 0000000..c1f4080 --- /dev/null +++ b/docs/RELEASE-0.1.0.md @@ -0,0 +1,49 @@ +# CDriveShiftAI 0.1.0 发布说明 + +本版本重新调整鼠标快捷操作的时间范围,将应用字体大小改为连续百分比滑杆,并加入跨程序的 Windows 局部放大镜。 + +## 鼠标快捷操作 + +- 长按时长范围由 500–10000 毫秒改为 0–3000 毫秒,步进为 100 毫秒。 +- 0 毫秒表示按下已配置的鼠标后退侧键、前进侧键或中键后立即触发。 +- 前端设置、持久化存储与 Rust 原生 Raw Input 监听器使用相同范围。 +- 旧配置中大于 3000 毫秒的时间会自动调整到 3000 毫秒,不会造成配置读取失败。 + +## 全应用字体大小 + +- 原有四档大小按钮改为连续滑杆,以此前的“标准”字体大小为 100%。 +- 可在 50%–300% 范围内来回拖动,每次变化 10%,不是按钮选择。 +- 主窗口、极速搜索、搜索结果及辅助窗口内的全部文字同步生效;控件、卡片、间距和图标保持原尺寸。 +- 拖动时文字跟随滑块逐步放大或缩小,松开或键盘调整结束后自动保存最终值。 +- 旧版本中的比例会归一到最近的 10%。 +- 标准 100% 竖线与滑块圆点使用相同的有效轨道坐标。 +- 移除整页缩放与整页横向滚动容器,避免高倍字号时吸顶卡片重叠或被压住。 + +## 全局局部放大镜 + +- 新增在所有应用和显示器上生效的 Windows 局部放大镜,按住组合键时跟随鼠标显示。 +- 快捷方式通过点击录入区后实际按住 Ctrl、Alt、Shift 或 Win 组合并滚动一次完成设置。 +- 按住组合键时滚动滚轮调整放大倍率;宽度和高度由两个独立滑杆调整。 +- 放大窗口不接收鼠标点击,松开组合键后自动隐藏。 +- 单独按 Ctrl 不会显示放大镜;只有组合键和滚轮同时触发后才显示。录入期间原生全局钩子会暂停,避免抢走滚轮事件。 +- 放大区域带边框,采样中心固定在鼠标位置,放大画面不显示鼠标图标。 + +## 历史更新 + +- 更新与诊断继续提供逐条独立折叠的离线历史记录。 +- 历史列表超过可视区域时显示独立滚动条,支持鼠标滚轮和键盘下滑。 +- v0.0.12 已加入历史更新列表。 + +## 验证 + +- 65 项 TypeScript 单元测试通过。 +- 索引器 9 项和更新器 4 项 Rust 原生测试通过。 +- 50%–300% 与 0–3000 ms 设置页滑杆布局、100% 标记对齐、字体只缩放验证及放大镜宽高范围检查通过。 +- Windows 安装版与便携版构建、单文件便携运行和干净退出冒烟验证通过。 + +## 下载 + +- `CDriveShiftAI-x64.exe`:Windows x64 安装版,可选择安装目录。 +- `CDriveShiftAI-x64-portable.exe`:Windows x64 便携版。 +- `update-manifest.json`:包含安装版和便携版 SHA-512 校验值的更新清单。 +- `latest.yml`:Electron 更新元数据。 diff --git a/electron/analyzer.ts b/electron/analyzer.ts index 58da9fa..8639ffe 100644 --- a/electron/analyzer.ts +++ b/electron/analyzer.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { lstat, opendir, readdir } from "node:fs/promises"; +import { lstat, opendir, readdir, readlink } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -44,7 +44,10 @@ function firstChild(root: string, itemPath: string): string { return path.join(root, first); } -export async function summarizeDirectory(inputPath: string): Promise { +export async function summarizeDirectory( + inputPath: string, + options: { includeReparsePoints?: boolean } = {} +): Promise { const root = normalizeWindowsPath(inputPath); const rootStat = await lstat(root); if (!rootStat.isDirectory()) throw new Error("所选路径不是目录"); @@ -53,11 +56,13 @@ export async function summarizeDirectory(inputPath: string): Promise(); const childMap = new Map(); const sampleNames: string[] = []; const scanErrors: string[] = []; + const reparsePoints: Array<{ relativePath: string; target: string }> = []; const queue = [root]; let processed = 0; @@ -82,11 +87,6 @@ export async function summarizeDirectory(inputPath: string): Promise([ + "migration-source", + "migration-destination", + "analysis", + "search-scope", + "content-index", + "copy-destination", + "general" +]); + +export function normalizeDirectoryDialogPurpose(value: unknown): DirectoryDialogPurpose { + return typeof value === "string" && DIRECTORY_DIALOG_PURPOSES.has(value as DirectoryDialogPurpose) + ? (value as DirectoryDialogPurpose) + : "general"; +} + +export function sanitizeDirectoryDialogPaths( + value: unknown +): Partial> { + if (!value || typeof value !== "object") return {}; + const input = value as Record; + const result: Partial> = {}; + for (const purpose of DIRECTORY_DIALOG_PURPOSES) { + const candidate = input[purpose]; + if (typeof candidate === "string" && candidate.trim()) { + result[purpose] = candidate.trim().slice(0, 32_768); + } + } + return result; +} diff --git a/electron/force-delete.ts b/electron/force-delete.ts new file mode 100644 index 0000000..9b5fbc0 --- /dev/null +++ b/electron/force-delete.ts @@ -0,0 +1,308 @@ +import { execFile } from "node:child_process"; +import { lstat, rm } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { logger, serializeError } from "./logger"; +import { + isElevated, + isHighRiskApplicationPath, + isPathWithin, + normalizeWindowsPath, + protectedReason, + samePath +} from "./system"; +import type { + ForceDeletePreview, + ForceDeleteProcess, + ForceDeleteResult +} from "./types"; + +const execFileAsync = promisify(execFile); +const VERIFICATION_TTL_MS = 2 * 60_000; +const NEVER_TERMINATE = new Set([ + "system", + "registry", + "smss.exe", + "csrss.exe", + "wininit.exe", + "services.exe", + "lsass.exe", + "winlogon.exe", + "dwm.exe", + "explorer.exe", + "svchost.exe" +]); + +interface WindowsProcessRecord { + pid: number; + name: string; + executablePath?: string; + commandLine?: string; +} + +interface VerificationRecord { + path: string; + expiresAt: number; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseProcessList(raw: string): WindowsProcessRecord[] { + if (!raw.trim()) return []; + const parsed = JSON.parse(raw) as unknown; + const values = Array.isArray(parsed) ? parsed : [parsed]; + return values.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const item = value as Record; + const pid = Number(item.pid ?? item.ProcessId); + if (!Number.isInteger(pid) || pid <= 0) return []; + return [{ + pid, + name: optionalString(item.name ?? item.Name) ?? `PID ${pid}`, + executablePath: optionalString(item.executablePath ?? item.ExecutablePath), + commandLine: optionalString(item.commandLine ?? item.CommandLine) + }]; + }); +} + +function normalizeCommandLine(value: string): string { + return value.replaceAll("/", "\\").toLocaleLowerCase(); +} + +export function commandLineReferencesTarget( + commandLine: string, + targetPath: string +): boolean { + const haystack = normalizeCommandLine(commandLine); + const needle = normalizeWindowsPath(targetPath).toLocaleLowerCase(); + let offset = haystack.indexOf(needle); + while (offset >= 0) { + const before = offset === 0 ? "" : haystack[offset - 1]; + const after = haystack[offset + needle.length] ?? ""; + const beforeBoundary = before === "" || /[\s"'=]/u.test(before); + const afterBoundary = after === "" || /[\\\s"']/u.test(after); + if (beforeBoundary && afterBoundary) return true; + offset = haystack.indexOf(needle, offset + 1); + } + return false; +} + +export function matchProcessesForTarget( + processes: WindowsProcessRecord[], + targetPath: string, + targetIsDirectory: boolean, + excludedPids: ReadonlySet = new Set() +): ForceDeleteProcess[] { + return processes.flatMap((processRecord) => { + const executableMatch = processRecord.executablePath + ? targetIsDirectory + ? isPathWithin(processRecord.executablePath, targetPath) + : samePath(processRecord.executablePath, targetPath) + : false; + const commandLineMatch = processRecord.commandLine + ? commandLineReferencesTarget(processRecord.commandLine, targetPath) + : false; + if (!executableMatch && !commandLineMatch) return []; + const protectedProcess = + processRecord.pid <= 4 || + excludedPids.has(processRecord.pid) || + NEVER_TERMINATE.has(processRecord.name.toLocaleLowerCase()); + return [{ + pid: processRecord.pid, + name: processRecord.name, + executablePath: processRecord.executablePath, + matchReason: executableMatch ? "executable" : "command-line", + canTerminate: !protectedProcess + }]; + }); +} + +async function listWindowsProcesses(): Promise { + if (process.platform !== "win32") return []; + const script = [ + "$ErrorActionPreference='SilentlyContinue'", + "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new()", + "$items=@(Get-CimInstance Win32_Process | ForEach-Object {", + "[PSCustomObject]@{pid=[int]$_.ProcessId;name=[string]$_.Name;executablePath=[string]$_.ExecutablePath;commandLine=[string]$_.CommandLine}", + "})", + "ConvertTo-Json -Compress -InputObject $items" + ].join("; "); + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], + { windowsHide: true, timeout: 8_000, maxBuffer: 4 * 1024 * 1024 } + ); + return parseProcessList(stdout); +} + +async function terminateProcessTree(pid: number): Promise { + try { + process.kill(pid, 0); + } catch { + return true; + } + try { + await execFileAsync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 10_000, + maxBuffer: 512 * 1024 + }); + return true; + } catch { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } + } +} + +async function removePermanently(targetPath: string): Promise { + try { + await rm(targetPath, { + recursive: true, + force: true, + maxRetries: 6, + retryDelay: 300 + }); + return; + } catch (firstError) { + if (process.platform === "win32") { + await execFileAsync( + "attrib.exe", + ["-R", "-S", "-H", targetPath, "/S", "/D"], + { windowsHide: true, timeout: 15_000, maxBuffer: 512 * 1024 } + ).catch(() => undefined); + try { + await rm(targetPath, { + recursive: true, + force: true, + maxRetries: 8, + retryDelay: 400 + }); + return; + } catch (secondError) { + throw secondError; + } + } + throw firstError; + } +} + +export class ForceDeleteService { + private readonly verifications = new Map(); + + constructor( + private readonly options: { + applicationExecutable: string; + applicationDataRoot: string; + createVerificationId: () => string; + } + ) {} + + private validateTarget(targetPath: string): string { + const normalized = normalizeWindowsPath(targetPath); + const protection = protectedReason(normalized); + if (protection) throw new Error(`受保护路径不能强制删除:${protection}`); + if ( + isPathWithin(this.options.applicationExecutable, normalized) || + isPathWithin(this.options.applicationDataRoot, normalized) + ) { + throw new Error("不能强制删除 CDriveShiftAI 当前程序或数据所在目录"); + } + return normalized; + } + + private cleanExpiredVerifications(): void { + const now = Date.now(); + for (const [id, verification] of this.verifications) { + if (verification.expiresAt <= now) this.verifications.delete(id); + } + } + + async preview(targetPath: string): Promise { + this.cleanExpiredVerifications(); + const normalized = this.validateTarget(targetPath); + const stats = await lstat(normalized); + const processes = matchProcessesForTarget( + await listWindowsProcesses(), + normalized, + stats.isDirectory(), + new Set([process.pid, process.ppid]) + ); + const verificationId = this.options.createVerificationId(); + this.verifications.set(verificationId, { + path: normalized, + expiresAt: Date.now() + VERIFICATION_TTL_MS + }); + logger.info("force_delete.preview", { + targetPath: normalized, + isDirectory: stats.isDirectory(), + relatedProcesses: processes.length, + terminableProcesses: processes.filter((item) => item.canTerminate).length + }); + return { + verificationId, + path: normalized, + name: path.basename(normalized), + isDirectory: stats.isDirectory(), + isSymbolicLink: stats.isSymbolicLink(), + highRisk: isHighRiskApplicationPath(normalized), + elevated: await isElevated(), + processes + }; + } + + async execute(verificationId: string): Promise { + this.cleanExpiredVerifications(); + const verification = this.verifications.get(verificationId); + if (!verification) throw new Error("强制删除确认已过期,请重新预检"); + this.verifications.delete(verificationId); + const normalized = this.validateTarget(verification.path); + const stats = await lstat(normalized); + const processes = matchProcessesForTarget( + await listWindowsProcesses(), + normalized, + stats.isDirectory(), + new Set([process.pid, process.ppid]) + ); + const terminated: ForceDeleteProcess[] = []; + const failed: ForceDeleteProcess[] = []; + for (const processRecord of processes.filter((item) => item.canTerminate)) { + if (await terminateProcessTree(processRecord.pid)) terminated.push(processRecord); + else failed.push(processRecord); + } + if (failed.length > 0) { + logger.warn("force_delete.process_termination_failed", { + targetPath: normalized, + processes: failed.map(({ pid, name }) => ({ pid, name })) + }); + throw new Error( + `无法结束 ${failed.map((item) => `${item.name} (PID ${item.pid})`).join("、")};请以管理员身份运行后重试` + ); + } + try { + await removePermanently(normalized); + logger.info("force_delete.completed", { + targetPath: normalized, + terminatedProcesses: terminated.map(({ pid, name }) => ({ pid, name })) + }); + return { deleted: true, terminatedProcesses: terminated }; + } catch (error) { + logger.error("force_delete.failed", { + targetPath: normalized, + terminatedProcesses: terminated.map(({ pid, name }) => ({ pid, name })), + error: serializeError(error) + }); + const code = (error as NodeJS.ErrnoException).code; + throw new Error( + code === "EPERM" || code === "EACCES" || code === "EBUSY" + ? "目标仍被其他程序占用或当前权限不足;请关闭未列出的占用程序,或以管理员身份运行后重试" + : `强制删除失败:${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/electron/i18n.ts b/electron/i18n.ts new file mode 100644 index 0000000..fb1ea20 --- /dev/null +++ b/electron/i18n.ts @@ -0,0 +1,78 @@ +import { app } from "electron"; +import type { AppLanguage } from "./types"; + +type ResolvedLanguage = Exclude; + +const supported: readonly ResolvedLanguage[] = [ + "zh-CN", "zh-TW", "en-US", "ja-JP", "ko-KR", "es-ES", "fr-FR", "de-DE", + "pt-BR", "ru-RU", "ar-SA", "hi-IN", "id-ID", "it-IT", "tr-TR" +]; + +export function resolveNativeLanguage(language: AppLanguage): ResolvedLanguage { + if (language !== "system") return language; + const locale = app.getLocale().toLowerCase(); + if (locale.startsWith("zh-hant") || ["zh-tw", "zh-hk", "zh-mo"].includes(locale)) return "zh-TW"; + if (locale.startsWith("zh")) return "zh-CN"; + const exact = supported.find((item) => item.toLowerCase() === locale); + if (exact) return exact; + const prefix = locale.split("-")[0]; + return supported.find((item) => item.toLowerCase().startsWith(`${prefix}-`)) ?? "en-US"; +} + +const english = { + tagline: "Whole-disk search and safe AI migration", + quickWindow: "CDriveShiftAI Quick Search", + uninstallWindow: "CDriveShiftAI · Restore before uninstall", + open: "Open CDriveShiftAI", + quickSearch: "Standalone quick search", + quickFunctions: "Quick actions", + overview: "Space overview", + search: "Fast search", + ownership: "Disk ownership map", + analyze: "AI ownership analysis", + migrate: "Safe migration", + history: "Migration history", + aiService: "AI service", + enabled: "Enabled", + paused: "Paused", + notTested: "Connection test not completed", + configureAi: "Configure URL, key, and model…", + theme: "Interface theme", + settings: "Settings and shortcuts", + exit: "Exit", + unableAi: "Unable to switch AI service", + aurora: "Pixel · Pixel Lake", + matrix: "Tech · HUD data flow", + calm: "Crystal · Frosted glow", + ember: "Ember · Ember Hive", + ivory: "Ivory · Warm porcelain" +} as const; + +type NativeStrings = { [Key in keyof typeof english]: string }; +type NativeOverrides = Partial; + +const overrides: Record = { + "en-US": {}, + "zh-CN": { + tagline: "全盘 AI 智迁", quickWindow: "CDriveShiftAI 极速搜索", uninstallWindow: "CDriveShiftAI · 卸载前恢复", open: "打开 CDriveShiftAI", quickSearch: "独立极速搜索", quickFunctions: "快速功能", overview: "空间总览", search: "极速搜索", ownership: "磁盘归属地图", analyze: "AI 归属分析", migrate: "安全迁移", history: "迁移记录", aiService: "AI 服务", enabled: "已启用", paused: "已暂停", notTested: "尚未完成连接测试", configureAi: "配置 URL、Key 与模型…", theme: "界面主题", settings: "设置与快捷键", exit: "退出", unableAi: "无法切换 AI 服务", aurora: "方块 · 像素湖境", matrix: "科技 · HUD 数据流", calm: "晶境 · 玻璃流光", ember: "熔橙 · 熔芯蜂巢", ivory: "暖瓷 · 米白陶影" + }, + "zh-TW": { + tagline: "全碟 AI 智慧搬移", quickWindow: "CDriveShiftAI 快速搜尋", uninstallWindow: "CDriveShiftAI · 解除安裝前還原", open: "開啟 CDriveShiftAI", quickSearch: "獨立快速搜尋", quickFunctions: "快速功能", overview: "空間總覽", search: "快速搜尋", ownership: "磁碟歸屬地圖", analyze: "AI 歸屬分析", migrate: "安全搬移", history: "搬移記錄", aiService: "AI 服務", enabled: "已啟用", paused: "已暫停", notTested: "尚未完成連線測試", configureAi: "設定 URL、Key 與模型…", theme: "介面主題", settings: "設定與快速鍵", exit: "結束", unableAi: "無法切換 AI 服務" + }, + "ja-JP": { tagline: "全ドライブ検索と安全な AI 移行", quickWindow: "CDriveShiftAI クイック検索", open: "CDriveShiftAI を開く", quickSearch: "クイック検索", quickFunctions: "クイック操作", overview: "容量概要", search: "高速検索", ownership: "ディスク所有マップ", analyze: "AI 所有分析", migrate: "安全な移行", history: "移行履歴", aiService: "AI サービス", enabled: "有効", paused: "一時停止", notTested: "接続テスト未完了", configureAi: "URL・キー・モデルを設定…", theme: "テーマ", settings: "設定とショートカット", exit: "終了", unableAi: "AI サービスを切り替えられません" }, + "ko-KR": { tagline: "전체 디스크 검색과 안전한 AI 마이그레이션", quickWindow: "CDriveShiftAI 빠른 검색", open: "CDriveShiftAI 열기", quickSearch: "독립 빠른 검색", quickFunctions: "빠른 기능", overview: "공간 개요", search: "빠른 검색", ownership: "디스크 소유권 지도", analyze: "AI 소유권 분석", migrate: "안전한 이동", history: "이동 기록", aiService: "AI 서비스", enabled: "사용", paused: "일시 중지", notTested: "연결 테스트 미완료", configureAi: "URL, 키 및 모델 설정…", theme: "화면 테마", settings: "설정 및 바로가기", exit: "종료", unableAi: "AI 서비스를 전환할 수 없음" }, + "es-ES": { tagline: "Búsqueda en todos los discos y migración segura con IA", quickWindow: "Búsqueda rápida de CDriveShiftAI", open: "Abrir CDriveShiftAI", quickSearch: "Búsqueda rápida", quickFunctions: "Acciones rápidas", overview: "Resumen de espacio", search: "Búsqueda rápida", ownership: "Mapa de propiedad", analyze: "Análisis con IA", migrate: "Migración segura", history: "Historial", aiService: "Servicio de IA", enabled: "Activado", paused: "Pausado", notTested: "Prueba de conexión pendiente", configureAi: "Configurar URL, clave y modelo…", theme: "Tema de interfaz", settings: "Configuración y atajos", exit: "Salir", unableAi: "No se pudo cambiar el servicio de IA" }, + "fr-FR": { tagline: "Recherche sur tous les disques et migration IA sécurisée", quickWindow: "Recherche rapide CDriveShiftAI", open: "Ouvrir CDriveShiftAI", quickSearch: "Recherche rapide", quickFunctions: "Actions rapides", overview: "Vue de l’espace", search: "Recherche rapide", ownership: "Carte d’appartenance", analyze: "Analyse par IA", migrate: "Migration sécurisée", history: "Historique", aiService: "Service IA", enabled: "Activé", paused: "En pause", notTested: "Test de connexion non terminé", configureAi: "Configurer l’URL, la clé et le modèle…", theme: "Thème de l’interface", settings: "Paramètres et raccourcis", exit: "Quitter", unableAi: "Impossible de changer de service IA" }, + "de-DE": { tagline: "Laufwerksweite Suche und sichere KI-Migration", quickWindow: "CDriveShiftAI Schnellsuche", open: "CDriveShiftAI öffnen", quickSearch: "Schnellsuche", quickFunctions: "Schnellaktionen", overview: "Speicherübersicht", search: "Schnellsuche", ownership: "Zuordnungskarte", analyze: "KI-Zuordnungsanalyse", migrate: "Sichere Migration", history: "Migrationsverlauf", aiService: "KI-Dienst", enabled: "Aktiviert", paused: "Pausiert", notTested: "Verbindungstest nicht abgeschlossen", configureAi: "URL, Schlüssel und Modell einrichten…", theme: "Oberflächenthema", settings: "Einstellungen und Tastenkürzel", exit: "Beenden", unableAi: "KI-Dienst konnte nicht gewechselt werden" }, + "pt-BR": { tagline: "Busca em todos os discos e migração segura com IA", quickWindow: "Busca rápida do CDriveShiftAI", open: "Abrir CDriveShiftAI", quickSearch: "Busca rápida", quickFunctions: "Ações rápidas", overview: "Visão do espaço", search: "Busca rápida", ownership: "Mapa de propriedade", analyze: "Análise por IA", migrate: "Migração segura", history: "Histórico", aiService: "Serviço de IA", enabled: "Ativado", paused: "Pausado", notTested: "Teste de conexão pendente", configureAi: "Configurar URL, chave e modelo…", theme: "Tema da interface", settings: "Configurações e atalhos", exit: "Sair", unableAi: "Não foi possível trocar o serviço de IA" }, + "ru-RU": { tagline: "Поиск по всем дискам и безопасный перенос с ИИ", quickWindow: "Быстрый поиск CDriveShiftAI", open: "Открыть CDriveShiftAI", quickSearch: "Быстрый поиск", quickFunctions: "Быстрые действия", overview: "Обзор места", search: "Быстрый поиск", ownership: "Карта принадлежности", analyze: "Анализ с ИИ", migrate: "Безопасный перенос", history: "История", aiService: "Сервис ИИ", enabled: "Включён", paused: "Приостановлен", notTested: "Проверка подключения не завершена", configureAi: "Настроить URL, ключ и модель…", theme: "Тема интерфейса", settings: "Настройки и сочетания клавиш", exit: "Выход", unableAi: "Не удалось сменить сервис ИИ" }, + "ar-SA": { tagline: "بحث في جميع الأقراص ونقل آمن بالذكاء الاصطناعي", quickWindow: "بحث CDriveShiftAI السريع", open: "فتح CDriveShiftAI", quickSearch: "بحث سريع", quickFunctions: "إجراءات سريعة", overview: "نظرة على المساحة", search: "بحث سريع", ownership: "خريطة الملكية", analyze: "تحليل بالذكاء الاصطناعي", migrate: "نقل آمن", history: "سجل النقل", aiService: "خدمة الذكاء الاصطناعي", enabled: "مفعّل", paused: "متوقف مؤقتًا", notTested: "اختبار الاتصال غير مكتمل", configureAi: "إعداد الرابط والمفتاح والنموذج…", theme: "سمة الواجهة", settings: "الإعدادات والاختصارات", exit: "خروج", unableAi: "تعذر تبديل خدمة الذكاء الاصطناعي" }, + "hi-IN": { tagline: "सभी डिस्क में खोज और सुरक्षित AI माइग्रेशन", quickWindow: "CDriveShiftAI त्वरित खोज", open: "CDriveShiftAI खोलें", quickSearch: "त्वरित खोज", quickFunctions: "त्वरित क्रियाएँ", overview: "स्थान अवलोकन", search: "तेज़ खोज", ownership: "स्वामित्व मानचित्र", analyze: "AI विश्लेषण", migrate: "सुरक्षित माइग्रेशन", history: "इतिहास", aiService: "AI सेवा", enabled: "सक्रिय", paused: "रोका गया", notTested: "कनेक्शन परीक्षण अधूरा", configureAi: "URL, कुंजी और मॉडल कॉन्फ़िगर करें…", theme: "इंटरफ़ेस थीम", settings: "सेटिंग्स और शॉर्टकट", exit: "बाहर निकलें", unableAi: "AI सेवा बदली नहीं जा सकी" }, + "id-ID": { tagline: "Pencarian seluruh disk dan migrasi AI yang aman", quickWindow: "Pencarian cepat CDriveShiftAI", open: "Buka CDriveShiftAI", quickSearch: "Pencarian cepat", quickFunctions: "Tindakan cepat", overview: "Ringkasan ruang", search: "Pencarian cepat", ownership: "Peta kepemilikan", analyze: "Analisis AI", migrate: "Migrasi aman", history: "Riwayat", aiService: "Layanan AI", enabled: "Aktif", paused: "Dijeda", notTested: "Uji koneksi belum selesai", configureAi: "Atur URL, kunci, dan model…", theme: "Tema antarmuka", settings: "Pengaturan dan pintasan", exit: "Keluar", unableAi: "Tidak dapat mengganti layanan AI" }, + "it-IT": { tagline: "Ricerca su tutti i dischi e migrazione IA sicura", quickWindow: "Ricerca rapida CDriveShiftAI", open: "Apri CDriveShiftAI", quickSearch: "Ricerca rapida", quickFunctions: "Azioni rapide", overview: "Panoramica spazio", search: "Ricerca rapida", ownership: "Mappa proprietà", analyze: "Analisi IA", migrate: "Migrazione sicura", history: "Cronologia", aiService: "Servizio IA", enabled: "Attivo", paused: "In pausa", notTested: "Test di connessione non completato", configureAi: "Configura URL, chiave e modello…", theme: "Tema interfaccia", settings: "Impostazioni e scorciatoie", exit: "Esci", unableAi: "Impossibile cambiare servizio IA" }, + "tr-TR": { tagline: "Tüm disklerde arama ve güvenli yapay zekâ taşıması", quickWindow: "CDriveShiftAI hızlı arama", open: "CDriveShiftAI'ı aç", quickSearch: "Hızlı arama", quickFunctions: "Hızlı işlemler", overview: "Alan özeti", search: "Hızlı arama", ownership: "Sahiplik haritası", analyze: "Yapay zekâ analizi", migrate: "Güvenli taşıma", history: "Taşıma geçmişi", aiService: "Yapay zekâ hizmeti", enabled: "Etkin", paused: "Duraklatıldı", notTested: "Bağlantı testi tamamlanmadı", configureAi: "URL, anahtar ve model ayarla…", theme: "Arayüz teması", settings: "Ayarlar ve kısayollar", exit: "Çıkış", unableAi: "Yapay zekâ hizmeti değiştirilemedi" } +}; + +export function nativeStrings(language: AppLanguage): NativeStrings { + return { ...english, ...overrides[resolveNativeLanguage(language)] }; +} diff --git a/electron/main.ts b/electron/main.ts index 85c4011..4ea69e3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -12,7 +12,8 @@ import { screen, shell, Tray, - type MenuItemConstructorOptions + type MenuItemConstructorOptions, + type OpenDialogOptions } from "electron"; import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; @@ -30,6 +31,9 @@ import { } from "./ai-client"; import { configureApplicationDataPaths } from "./data-root"; import { createDiagnosticReport } from "./diagnostics"; +import { normalizeDirectoryDialogPurpose } from "./directory-dialog"; +import { ForceDeleteService } from "./force-delete"; +import { nativeStrings } from "./i18n"; import { configureLogger, getLogDirectory, @@ -38,6 +42,7 @@ import { } from "./logger"; import { MigrationService } from "./migration"; import { SearchService } from "./search"; +import { normalizeStoredUiScale } from "./settings-normalization"; import { AppStore } from "./store"; import { createTrayMenuIcon, type TrayIconKind } from "./tray-icons"; import { @@ -100,10 +105,16 @@ let isQuitting = false; let shutdownComplete = false; let shutdownPromise: Promise | undefined; let visibleUpdateCheckTimer: NodeJS.Timeout | undefined; +let mainWindowRecoveryTimer: NodeJS.Timeout | undefined; let lastVisibleUpdateCheckAt = 0; const store = new AppStore(); let migrationService: MigrationService; const aiVerifications = new Map(); +const forceDeleteService = new ForceDeleteService({ + applicationExecutable: process.execPath, + applicationDataRoot, + createVerificationId: randomUUID +}); function syncSearchBackgroundMode(): void { const hasVisibleWindow = [mainWindow, quickSearchWindow, uninstallRestoreWindow].some( @@ -116,7 +127,7 @@ function syncSearchBackgroundMode(): void { searchService?.setBackgroundMode(!hasVisibleWindow); } -function trackWindowActivity(window: BrowserWindow): void { +function trackWindowActivity(window: BrowserWindow, role: "main" | "quick"): void { const sync = () => setImmediate(syncSearchBackgroundMode); window.on("show", sync); window.on("hide", sync); @@ -135,6 +146,22 @@ function trackWindowActivity(window: BrowserWindow): void { reason: details.reason, exitCode: details.exitCode }); + if ( + role === "main" && + !isQuitting && + window.isVisible() && + details.reason !== "clean-exit" + ) { + if (mainWindowRecoveryTimer) clearTimeout(mainWindowRecoveryTimer); + mainWindowRecoveryTimer = setTimeout(() => { + mainWindowRecoveryTimer = undefined; + if (isQuitting || mainWindow !== window) return; + logger.warn("renderer.recovering", { reason: details.reason }); + if (!window.isDestroyed()) window.destroy(); + mainWindow = createWindow(); + }, 250); + mainWindowRecoveryTimer.unref(); + } }); } @@ -347,8 +374,21 @@ function showAsSoonAsRenderable(window: BrowserWindow, maximized = false): void window.once("closed", () => clearTimeout(fallback)); } +function applyUiScale(window: BrowserWindow, scale: AppSettings["uiScale"]): void { + if (!window.isDestroyed()) window.webContents.send("settings:text-scale-preview", scale); +} + +function initializeUiScale(window: BrowserWindow, scale: AppSettings["uiScale"]): void { + applyUiScale(window, scale); + window.webContents.on("did-finish-load", () => { + applyUiScale(window, store.getSettings().uiScale); + }); +} + function createWindow(): BrowserWindow { - const effect = store.getSettings().effectMode; + const settings = store.getSettings(); + const effect = settings.effectMode; + const strings = nativeStrings(settings.language); const colors = effectColors[effect]; const restored = restoreWindowBounds( store.getUiLayout().mainWindowBounds, @@ -364,7 +404,7 @@ function createWindow(): BrowserWindow { show: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI · 全盘 AI 智迁", + title: `CDriveShiftAI · ${strings.tagline}`, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -382,8 +422,9 @@ function createWindow(): BrowserWindow { } }); + initializeUiScale(window, settings.uiScale); window.setMenuBarVisibility(false); - trackWindowActivity(window); + trackWindowActivity(window, "main"); window.on("restore", () => triggerVisibleUpdateCheck("main-window-restored")); trackWindowBounds(window, "mainWindowBounds"); window.on("close", (event) => { @@ -423,12 +464,24 @@ function createWindow(): BrowserWindow { } function emitSettingsChanged(settings: AppSettings): void { + const strings = nativeStrings(settings.language); for (const window of [mainWindow, quickSearchWindow]) { if (window && !window.isDestroyed()) { window.webContents.send("settings:changed", settings); applyNativeEffect(settings.effectMode, window); + applyUiScale(window, settings.uiScale); } } + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setTitle(`CDriveShiftAI · ${strings.tagline}`); + } + if (quickSearchWindow && !quickSearchWindow.isDestroyed()) { + quickSearchWindow.setTitle(strings.quickWindow); + } + if (uninstallRestoreWindow && !uninstallRestoreWindow.isDestroyed()) { + uninstallRestoreWindow.setTitle(strings.uninstallWindow); + applyUiScale(uninstallRestoreWindow, settings.uiScale); + } } function emitUpdateState(state: AppUpdateInfo): void { @@ -522,6 +575,7 @@ function createQuickSearchWindow(): BrowserWindow { return quickSearchWindow; } const settings = store.getSettings(); + const strings = nativeStrings(settings.language); const colors = effectColors[settings.effectMode]; const restored = restoreWindowBounds( store.getUiLayout().quickSearchWindowBounds, @@ -537,7 +591,7 @@ function createQuickSearchWindow(): BrowserWindow { show: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI 极速搜索", + title: strings.quickWindow, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -555,8 +609,9 @@ function createQuickSearchWindow(): BrowserWindow { } }); quickSearchWindow = window; + initializeUiScale(window, settings.uiScale); window.setMenuBarVisibility(false); - trackWindowActivity(window); + trackWindowActivity(window, "quick"); trackWindowBounds(window, "quickSearchWindowBounds"); window.once("ready-to-show", () => { if (restored?.maximized) window.maximize(); @@ -571,6 +626,7 @@ function createQuickSearchWindow(): BrowserWindow { function createUninstallRestoreWindow(): BrowserWindow { const settings = store.getSettings(); + const strings = nativeStrings(settings.language); const colors = effectColors[settings.effectMode]; const window = new BrowserWindow({ width: 940, @@ -582,7 +638,7 @@ function createUninstallRestoreWindow(): BrowserWindow { maximizable: false, backgroundColor: colors.background, icon: applicationIconPath(), - title: "CDriveShiftAI · 卸载前恢复", + title: strings.uninstallWindow, titleBarStyle: "hidden", titleBarOverlay: { color: "#00000000", @@ -599,6 +655,7 @@ function createUninstallRestoreWindow(): BrowserWindow { } }); uninstallRestoreWindow = window; + initializeUiScale(window, settings.uiScale); window.setMenuBarVisibility(false); window.once("ready-to-show", () => window.show()); window.on("closed", () => { @@ -646,27 +703,28 @@ async function chooseTrayAiProvider(providerId: AiProviderId): Promise { } function createTray(): void { + const settings = store.getSettings(); + const strings = nativeStrings(settings.language); if (!tray) { const image = nativeImage.createFromPath(applicationIconPath()).resize({ width: 20, height: 20 }); tray = new Tray(image); - tray.setToolTip("CDriveShiftAI · 全盘 AI 智迁"); tray.on("double-click", () => showMainView("overview")); } + tray.setToolTip(`CDriveShiftAI · ${strings.tagline}`); - const settings = store.getSettings(); const providerLabel = trayAiProviders.find((item) => item.id === settings.ai.provider)?.label ?? settings.ai.provider; const viewItems: MenuItemConstructorOptions[] = [ - ["空间总览", "overview", "overview"], - ["极速搜索", "search", "search"], - ["磁盘归属地图", "ownership-map", "map"], - ["AI 归属分析", "analyze", "ai"], - ["安全迁移", "migrate", "move"], - ["迁移记录", "history", "history"] + [strings.overview, "overview", "overview"], + [strings.search, "search", "search"], + [strings.ownership, "ownership-map", "map"], + [strings.analyze, "analyze", "ai"], + [strings.migrate, "migrate", "move"], + [strings.history, "history", "history"] ].map(([label, view, icon]) => ({ label, icon: createTrayMenuIcon(icon as TrayIconKind), @@ -675,29 +733,29 @@ function createTray(): void { const template: MenuItemConstructorOptions[] = [ { - label: "打开 CDriveShiftAI", + label: strings.open, icon: createTrayMenuIcon("app"), click: () => showMainView("overview") }, { - label: "独立极速搜索", + label: strings.quickSearch, icon: createTrayMenuIcon("search", "#45aaba"), click: () => createQuickSearchWindow() }, { type: "separator" }, { - label: "快速功能", + label: strings.quickFunctions, icon: createTrayMenuIcon("quick", "#d9a441"), submenu: viewItems }, { - label: `AI 服务 · ${providerLabel}`, + label: `${strings.aiService} · ${providerLabel}`, icon: createTrayMenuIcon("ai", settings.ai.enabled ? "#32a879" : "#9a7b45"), submenu: [ { label: settings.ai.verifiedAt - ? `${settings.ai.enabled ? "已启用" : "已暂停"} · ${settings.ai.model || providerLabel}` - : "尚未完成连接测试", + ? `${settings.ai.enabled ? strings.enabled : strings.paused} · ${settings.ai.model || providerLabel}` + : strings.notTested, icon: createTrayMenuIcon("ai-status"), enabled: false }, @@ -716,7 +774,7 @@ function createTray(): void { click: () => { void chooseTrayAiProvider(provider.id).catch((error) => { void dialog.showErrorBox( - "无法切换 AI 服务", + strings.unableAi, error instanceof Error ? error.message : String(error) ); }); @@ -724,21 +782,21 @@ function createTray(): void { })), { type: "separator" }, { - label: "配置 URL、Key 与模型…", + label: strings.configureAi, icon: createTrayMenuIcon("ai-config"), click: () => showMainView("settings", { focus: "ai-settings" }) } ] }, { - label: "界面主题", + label: strings.theme, icon: createTrayMenuIcon("theme", "#a36fd1"), submenu: ([ - ["aurora", "方块 · 像素湖境"], - ["matrix", "科技 · HUD 数据流"], - ["calm", "晶境 · 玻璃流光"], - ["ember", "熔橙 · 熔芯蜂巢"], - ["ivory", "暖瓷 · 米白陶影"] + ["aurora", strings.aurora], + ["matrix", strings.matrix], + ["calm", strings.calm], + ["ember", strings.ember], + ["ivory", strings.ivory] ] as const).map(([effectMode, label]) => ({ label, type: "radio" as const, @@ -760,13 +818,13 @@ function createTray(): void { })) }, { - label: "设置与快捷键", + label: strings.settings, icon: createTrayMenuIcon("settings"), click: () => showMainView("settings") }, { type: "separator" }, { - label: "退出", + label: strings.exit, icon: createTrayMenuIcon("exit", "#bd5d5d"), click: () => app.quit() } @@ -1105,6 +1163,19 @@ function registerIpc(): void { settings.mouseQuickSearchHoldMs ); } + if ( + previous.magnifierEnabled !== settings.magnifierEnabled || + previous.magnifierModifiers !== settings.magnifierModifiers || + previous.magnifierWidth !== settings.magnifierWidth || + previous.magnifierHeight !== settings.magnifierHeight + ) { + await searchService?.configureMagnifier( + settings.magnifierEnabled, + settings.magnifierModifiers, + settings.magnifierWidth, + settings.magnifierHeight + ); + } emitSettingsChanged(settings); createTray(); return settings; @@ -1161,6 +1232,19 @@ function registerIpc(): void { createQuickSearchWindow(); return true; }); + ipcMain.handle("shortcut:magnifier-status", () => + searchService?.getMagnifierStatus() ?? { + available: false, + enabled: store.getSettings().magnifierEnabled, + modifiers: store.getSettings().magnifierModifiers, + width: store.getSettings().magnifierWidth, + height: store.getSettings().magnifierHeight, + message: "Windows 局部放大镜尚未启动" + } + ); + ipcMain.handle("shortcut:magnifier-capture", async (_event, active: unknown) => { + return (await searchService?.setMagnifierCapture(active === true)) ?? false; + }); ipcMain.handle("app:navigate", (_event, raw: unknown) => { if (!raw || typeof raw !== "object") throw new Error("导航请求无效"); @@ -1339,12 +1423,47 @@ function registerIpc(): void { return updated; }); - ipcMain.handle("dialog:directory", async (_event, title?: unknown) => { - const result = await dialog.showOpenDialog(mainWindow!, { + ipcMain.handle("dialog:directory", async (event, title?: unknown, purpose?: unknown) => { + const safePurpose = normalizeDirectoryDialogPurpose(purpose); + const rememberedPath = store.getDirectoryDialogPath(safePurpose); + const defaultPath = rememberedPath + ? await stat(rememberedPath).then( + (details) => (details.isDirectory() ? rememberedPath : undefined), + () => undefined + ) + : undefined; + const parentWindow = BrowserWindow.fromWebContents(event.sender); + const options: OpenDialogOptions = { title: typeof title === "string" ? title.slice(0, 120) : "选择目录", + ...(defaultPath ? { defaultPath } : {}), properties: ["openDirectory", "createDirectory", "dontAddToRecent"] - }); - return result.canceled ? null : result.filePaths[0] ?? null; + }; + const result = parentWindow && !parentWindow.isDestroyed() + ? await dialog.showOpenDialog(parentWindow, options) + : await dialog.showOpenDialog(options); + const selectedPath = result.canceled ? undefined : result.filePaths[0]; + if (!selectedPath) return null; + await store.saveDirectoryDialogPath(safePurpose, selectedPath); + return selectedPath; + }); + ipcMain.handle("settings:preview-ui-scale", (event, rawScale: unknown) => { + if (typeof rawScale !== "number" || !Number.isFinite(rawScale)) { + throw new Error("界面缩放比例无效"); + } + const scale = normalizeStoredUiScale(rawScale); + const sourceWindow = BrowserWindow.fromWebContents(event.sender); + event.sender.send("settings:text-scale-preview", scale); + if (quickSearchWindow && !quickSearchWindow.isDestroyed() && quickSearchWindow !== sourceWindow) { + applyUiScale(quickSearchWindow, scale); + } + if ( + uninstallRestoreWindow && + !uninstallRestoreWindow.isDestroyed() && + uninstallRestoreWindow !== sourceWindow + ) { + applyUiScale(uninstallRestoreWindow, scale); + } + return scale; }); ipcMain.handle("shell:reveal", async (_event, targetPath: unknown) => { @@ -1508,6 +1627,14 @@ function registerIpc(): void { return true; }); + ipcMain.handle("shell:force-delete-preview", (_event, targetPath: unknown) => + forceDeleteService.preview(assertString(targetPath, "路径")) + ); + + ipcMain.handle("shell:force-delete-execute", (_event, verificationId: unknown) => + forceDeleteService.execute(assertString(verificationId, "强制删除确认 ID", 128)) + ); + ipcMain.handle( "shell:search-context-menu", async (event, targetPath: unknown, isDirectory: unknown) => { @@ -1917,6 +2044,12 @@ if (!singleInstance) { { button: currentSettings.mouseQuickSearchButton, holdMs: currentSettings.mouseQuickSearchHoldMs + }, + { + enabled: currentSettings.magnifierEnabled, + modifiers: currentSettings.magnifierModifiers, + width: currentSettings.magnifierWidth, + height: currentSettings.magnifierHeight } ); migrationService = new MigrationService(store, (record, message) => { diff --git a/electron/migration.ts b/electron/migration.ts index e976513..a7ff882 100644 --- a/electron/migration.ts +++ b/electron/migration.ts @@ -16,7 +16,7 @@ import { summarizeDirectory } from "./analyzer"; import { hasSignificantAnalysisChange } from "./analysis-freshness"; import { migrationDestinationFor } from "./migration-path"; import { AppStore } from "./store"; -import type { MigrationRecord, PreflightResult } from "./types"; +import type { DirectorySummary, MigrationRecord, PreflightResult } from "./types"; import { isHighRiskApplicationPath, isPathWithin, @@ -27,6 +27,9 @@ import { type ProgressHandler = (record: MigrationRecord, message: string) => void; +const TRANSIENT_RENAME_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]); +const RENAME_RETRY_DELAYS_MS = [150, 300, 600, 1_000, 1_500, 2_000] as const; + async function exists(candidate: string): Promise { try { await access(candidate); @@ -36,33 +39,88 @@ async function exists(candidate: string): Promise { } } +async function entryExists(candidate: string): Promise { + try { + await lstat(candidate); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export async function renameWithRetry( + source: string, + destination: string, + options: { + operation?: string; + retryDelaysMs?: readonly number[]; + renameEntry?: typeof rename; + destinationExists?: (candidate: string) => Promise; + wait?: (milliseconds: number) => Promise; + } = {} +): Promise { + const operation = options.operation ?? "目录切换"; + const retryDelaysMs = options.retryDelaysMs ?? RENAME_RETRY_DELAYS_MS; + const renameEntry = options.renameEntry ?? rename; + const destinationExists = options.destinationExists ?? entryExists; + const wait = options.wait ?? ((milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds); + })); + + for (let attempt = 0; ; attempt += 1) { + try { + await renameEntry(source, destination); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? "UNKNOWN"; + if (!TRANSIENT_RENAME_CODES.has(code)) throw error; + if (await destinationExists(destination)) { + throw new Error(`${operation}失败:目标路径在迁移期间已被其他程序创建:${destination}`); + } + const delay = retryDelaysMs[attempt]; + if (delay === undefined) { + throw new Error( + `${operation}失败:Windows 持续拒绝重命名(${code})。` + + "请关闭关联程序、资源管理器窗口、同步工具或实时防护后重试。" + ); + } + await wait(delay); + } + } +} + async function availableBytes(candidate: string): Promise { const root = path.parse(candidate).root; const stats = await statfs(root); return Number(stats.bavail) * Number(stats.bsize); } -function runRobocopy(source: string, destination: string): Promise { +export function migrationRobocopyArguments(source: string, destination: string): string[] { + return [ + source, + destination, + "/E", + "/COPY:DAT", + "/DCOPY:DAT", + "/R:2", + "/W:1", + "/SJ", + "/SL", + "/MT:16", + "/NP", + "/NJH", + "/NJS", + "/NFL", + "/NDL" + ]; +} + +export function runRobocopy(source: string, destination: string): Promise { return new Promise((resolve, reject) => { execFile( "robocopy.exe", - [ - source, - destination, - "/E", - "/COPY:DAT", - "/DCOPY:DAT", - "/R:2", - "/W:1", - "/XJ", - "/SL", - "/MT:16", - "/NP", - "/NJH", - "/NJS", - "/NFL", - "/NDL" - ], + migrationRobocopyArguments(source, destination), { windowsHide: true, maxBuffer: 4 * 1024 * 1024, @@ -85,21 +143,51 @@ function runRobocopy(source: string, destination: string): Promise { }); } -async function verifyCopy( - source: string, - destination: string, - expected?: { totalBytes: number; fileCount: number; directoryCount: number } -) { - const sourceSummary = expected ?? (await summarizeDirectory(source)); - const destinationSummary = await summarizeDirectory(destination); +export function normalizeReparseTarget(target: string): string { + let normalized = target.replaceAll("/", "\\"); + if (/^\\\\\?\\[a-z]:\\/iu.test(normalized)) normalized = normalized.slice(4); + if (/^\\\?\?\\[a-z]:\\/iu.test(normalized)) normalized = normalized.slice(4); + normalized = path.win32.normalize(normalized); + const root = path.win32.parse(normalized).root; + while (normalized.length > root.length && normalized.endsWith("\\")) { + normalized = normalized.slice(0, -1); + } + return normalized.toLocaleLowerCase(); +} + +function normalizedReparsePoints(summary: DirectorySummary): string[] { + return (summary.reparsePoints ?? []) + .map(({ relativePath, target }) => + `${path.win32.normalize(relativePath).toLocaleLowerCase()}\u0000${normalizeReparseTarget(target)}` + ) + .sort(); +} + +async function verifyCopy(source: string, destination: string) { + const [sourceSummary, destinationSummary] = await Promise.all([ + summarizeDirectory(source, { includeReparsePoints: true }), + summarizeDirectory(destination, { includeReparsePoints: true }) + ]); + if (sourceSummary.scanErrors.length > 0) { + throw new Error(`源目录复验失败:${sourceSummary.scanErrors[0]}`); + } + if (destinationSummary.scanErrors.length > 0) { + throw new Error(`目标目录复验失败:${destinationSummary.scanErrors[0]}`); + } + const sourceReparsePoints = normalizedReparsePoints(sourceSummary); + const destinationReparsePoints = normalizedReparsePoints(destinationSummary); const matches = sourceSummary.totalBytes === destinationSummary.totalBytes && sourceSummary.fileCount === destinationSummary.fileCount && - sourceSummary.directoryCount === destinationSummary.directoryCount; + sourceSummary.directoryCount === destinationSummary.directoryCount && + sourceSummary.reparsePointCount === destinationSummary.reparsePointCount && + sourceReparsePoints.length === destinationReparsePoints.length && + sourceReparsePoints.every((entry, index) => entry === destinationReparsePoints[index]); if (!matches) { throw new Error( `副本校验不一致:源目录 ${sourceSummary.fileCount} 个文件 / ${sourceSummary.totalBytes} 字节,` + - `目标目录 ${destinationSummary.fileCount} 个文件 / ${destinationSummary.totalBytes} 字节` + `${sourceSummary.reparsePointCount} 个重解析点;目标目录 ${destinationSummary.fileCount} 个文件 / ` + + `${destinationSummary.totalBytes} 字节,${destinationSummary.reparsePointCount} 个重解析点` ); } return destinationSummary; @@ -230,6 +318,7 @@ export class MigrationService { availableBytes: 0, fileCount: 0, directoryCount: 0, + reparsePointCount: 0, risk: "blocked", warnings, blockers: ["路径格式无效"] @@ -252,6 +341,7 @@ export class MigrationService { let requiredBytes = 0; let fileCount = 0; let directoryCount = 0; + let reparsePointCount = 0; let free = 0; if (blockers.length === 0) { const stats = await lstat(source); @@ -263,13 +353,19 @@ export class MigrationService { requiredBytes = summary.totalBytes; fileCount = summary.fileCount; directoryCount = summary.directoryCount; + reparsePointCount = summary.reparsePointCount; free = await availableBytes(destinationBase); const safetyMargin = Math.max(512 * 1024 ** 2, Math.ceil(requiredBytes * 0.03)); if (free < requiredBytes + safetyMargin) { blockers.push("目标磁盘可用空间不足(已包含 3% 或 512 MB 的安全余量)"); } if (summary.scanErrors.length > 0) { - blockers.push("源目录存在无法读取的条目,无法保证完整迁移"); + blockers.push(`源目录扫描不完整,无法保证完整迁移:${summary.scanErrors[0]}`); + } + if (reparsePointCount > 0) { + warnings.push( + `检测到 ${reparsePointCount.toLocaleString()} 个目录联接或符号链接;迁移时将复制链接本身,不会展开链接目标。` + ); } if (fileCount > 200_000) warnings.push("文件数量较多,复制和逐项校验会需要较长时间。"); } @@ -300,6 +396,7 @@ export class MigrationService { availableBytes: free, fileCount, directoryCount, + reparsePointCount, risk, warnings, blockers @@ -395,16 +492,16 @@ export class MigrationService { record.stage = "verifying"; record.copiedBytes = record.totalBytes; await this.persist(record, "正在核对文件数、目录数与总字节数"); - await verifyCopy(preflight.source, stagingPath, { - totalBytes: preflight.requiredBytes, - fileCount: preflight.fileCount, - directoryCount: preflight.directoryCount - }); + await verifyCopy(preflight.source, stagingPath); record.stage = "switching"; await this.persist(record, "副本校验通过,正在执行原子切换"); - await rename(preflight.source, backupPath); - await rename(stagingPath, preflight.finalDestination); + await renameWithRetry(preflight.source, backupPath, { + operation: "切换源目录" + }); + await renameWithRetry(stagingPath, preflight.finalDestination, { + operation: "发布目标目录" + }); let linkType: MigrationRecord["linkType"] = "symbolic-link"; try { @@ -480,9 +577,11 @@ export class MigrationService { let sourceRestored = false; try { await runRobocopy(record.destination, restorePath); - await verifyCopy(record.destination, restorePath, targetSummary); + await verifyCopy(record.destination, restorePath); await unlink(record.source); - await rename(restorePath, record.source); + await renameWithRetry(restorePath, record.source, { + operation: "恢复源目录" + }); sourceRestored = true; } catch (error) { if (!sourceRestored) { @@ -506,7 +605,7 @@ export class MigrationService { } try { - await verifyCopy(record.destination, record.source, targetSummary); + await verifyCopy(record.destination, record.source); await removeTreeAtExactPath( record.destination, path.dirname(record.destination) @@ -557,12 +656,16 @@ export class MigrationService { const sourceExists = await exists(record.source); const backupExists = record.backupPath ? await exists(record.backupPath) : false; if (!sourceExists && backupExists && record.backupPath) { - await rename(record.backupPath, record.source); + await renameWithRetry(record.backupPath, record.source, { + operation: "恢复迁移备份" + }); } else if (sourceExists && backupExists && record.backupPath) { const sourceStat = await lstat(record.source); if (sourceStat.isSymbolicLink()) { await unlink(record.source); - await rename(record.backupPath, record.source); + await renameWithRetry(record.backupPath, record.source, { + operation: "恢复迁移备份" + }); } } if (record.stagingPath && (await exists(record.stagingPath))) { diff --git a/electron/preload.ts b/electron/preload.ts index 358bcce..7fbcf0f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; import type { ContentIndexerStatus, + DirectoryDialogPurpose, IndexerStatus, MigrationRecord, SearchIndexChangedEvent @@ -18,17 +19,22 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { exportDiagnosticReport: () => ipcRenderer.invoke("diagnostics:export"), getSettings: () => ipcRenderer.invoke("settings:get"), updateSettings: (patch: unknown) => ipcRenderer.invoke("settings:update", patch), + previewUiScale: (scale: number) => ipcRenderer.invoke("settings:preview-ui-scale", scale), checkGlobalShortcut: (shortcut: string, target: string) => ipcRenderer.invoke("shortcut:check", shortcut, target), testGlobalShortcut: (target: string) => ipcRenderer.invoke("shortcut:test", target), getMouseShortcutStatus: () => ipcRenderer.invoke("shortcut:mouse-status"), testMouseShortcut: () => ipcRenderer.invoke("shortcut:mouse-test"), + getMagnifierStatus: () => ipcRenderer.invoke("shortcut:magnifier-status"), + setMagnifierCapture: (active: boolean) => + ipcRenderer.invoke("shortcut:magnifier-capture", active), listAiModels: (input: unknown) => ipcRenderer.invoke("ai:list-models", input), testAiConnection: (input: unknown) => ipcRenderer.invoke("ai:test", input), saveAiDraft: (input: unknown) => ipcRenderer.invoke("ai:save-draft", input), saveAiSettings: (input: unknown) => ipcRenderer.invoke("ai:save", input), - chooseDirectory: (title?: string) => ipcRenderer.invoke("dialog:directory", title), + chooseDirectory: (title?: string, purpose?: DirectoryDialogPurpose) => + ipcRenderer.invoke("dialog:directory", title, purpose), revealPath: (targetPath: string) => ipcRenderer.invoke("shell:reveal", targetPath), openPath: (targetPath: string) => ipcRenderer.invoke("shell:open", targetPath), openWith: (targetPath: string) => ipcRenderer.invoke("shell:open-with", targetPath), @@ -40,6 +46,10 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { getPathProperties: (targetPath: string) => ipcRenderer.invoke("shell:path-properties", targetPath), trashPath: (targetPath: string) => ipcRenderer.invoke("shell:trash", targetPath), + previewForceDelete: (targetPath: string) => + ipcRenderer.invoke("shell:force-delete-preview", targetPath), + executeForceDelete: (verificationId: string) => + ipcRenderer.invoke("shell:force-delete-execute", verificationId), directorySizes: (paths: string[]) => ipcRenderer.invoke("search:directory-sizes", paths), getSearchWorkspace: () => ipcRenderer.invoke("search:workspace-get"), saveSearchWorkspace: (state: unknown) => ipcRenderer.invoke("search:workspace-save", state), @@ -175,6 +185,11 @@ contextBridge.exposeInMainWorld("cDriveShiftAI", { ipcRenderer.on("settings:changed", handler); return () => ipcRenderer.removeListener("settings:changed", handler); }, + onTextScalePreview: (listener: (scale: number) => void) => { + const handler = (_event: Electron.IpcRendererEvent, scale: number) => listener(scale); + ipcRenderer.on("settings:text-scale-preview", handler); + return () => ipcRenderer.removeListener("settings:text-scale-preview", handler); + }, onUpdateStatus: (listener: (status: AppUpdateInfo) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateInfo) => listener(status); diff --git a/electron/search.ts b/electron/search.ts index d2b24d8..3e3d778 100644 --- a/electron/search.ts +++ b/electron/search.ts @@ -13,6 +13,7 @@ import type { IndexerStatus, MouseShortcutButton, MouseShortcutStatus, + MagnifierStatus, NativeResponse, SearchIndexChangedEvent, SearchFilters, @@ -112,13 +113,15 @@ export class SearchService { private lastFullRefreshRequestedAt = 0; private backgroundMode = false; private mouseShortcutStatus: MouseShortcutStatus; + private magnifierStatus: MagnifierStatus; constructor( private readonly onStatus: (status: IndexerStatus) => void, private readonly onContentStatus: (status: ContentIndexerStatus) => void, private readonly onIndexChanged: (event: SearchIndexChangedEvent) => void, private readonly onMouseShortcutHold: () => void, - initialMouseShortcut: { button: MouseShortcutButton; holdMs: number } + initialMouseShortcut: { button: MouseShortcutButton; holdMs: number }, + initialMagnifier: { enabled: boolean; modifiers: string; width: number; height: number } ) { this.mouseShortcutStatus = { available: false, @@ -126,6 +129,11 @@ export class SearchService { holdMs: initialMouseShortcut.holdMs, message: "鼠标全局监听正在启动" }; + this.magnifierStatus = { + available: false, + ...initialMagnifier, + message: "Windows 全屏局部放大镜正在启动" + }; } async start(): Promise { @@ -159,6 +167,9 @@ export class SearchService { child.stderr.on("data", (chunk) => { const message = chunk.toString("utf8").trim(); if (message) this.status.message = message.slice(-300); + if (message) { + logger.warn("indexer.stderr", { message: message.slice(-2_000) }); + } if (message && process.env.CDRIVESHIFTAI_TRIM_DIAGNOSTICS === "1") { process.stderr.write(`${message}\n`); } @@ -238,7 +249,11 @@ export class SearchService { forceRebuild, rebuildReason, mouseButton: this.mouseShortcutStatus.button, - mouseHoldMs: this.mouseShortcutStatus.holdMs + mouseHoldMs: this.mouseShortcutStatus.holdMs, + magnifierEnabled: this.magnifierStatus.enabled, + magnifierModifiers: this.magnifierStatus.modifiers, + magnifierWidth: this.magnifierStatus.width, + magnifierHeight: this.magnifierStatus.height }, 90_000 ); @@ -338,6 +353,57 @@ export class SearchService { return this.getMouseShortcutStatus(); } + getMagnifierStatus(): MagnifierStatus { + return structuredClone(this.magnifierStatus); + } + + async setMagnifierCapture(active: boolean): Promise { + if (this.child) { + await this.request( + { op: "setMagnifierCapture", magnifierCaptureActive: active }, + 3_000 + ); + } + return active; + } + + async configureMagnifier( + enabled: boolean, + modifiers: string, + width: number, + height: number + ): Promise { + this.magnifierStatus = { + ...this.magnifierStatus, + enabled, + modifiers, + width, + height, + message: enabled + ? "按住组合键时显示局部放大镜,滚动滚轮可调倍率" + : "全屏局部放大镜已关闭" + }; + if (this.child) { + const response = (await this.request( + { + op: "setMagnifier", + magnifierEnabled: enabled, + magnifierModifiers: modifiers, + magnifierWidth: width, + magnifierHeight: height + }, + 3_000 + )) as NativeResponse & { available?: boolean }; + this.magnifierStatus.available = response.available === true; + this.magnifierStatus.message = response.available + ? enabled + ? "按住组合键时显示局部放大镜,滚动滚轮可调倍率" + : "全屏局部放大镜已关闭" + : "当前 Windows 环境无法创建全局局部放大镜"; + } + return this.getMagnifierStatus(); + } + setBackgroundMode(background: boolean, force = false): void { if (!force && this.backgroundMode === background) return; this.backgroundMode = background; @@ -916,6 +982,16 @@ export class SearchService { }`; return; } + if (response.event === "magnifierStatus") { + const event = response as NativeResponse & { available?: boolean; errorCode?: number }; + this.magnifierStatus.available = event.available === true; + this.magnifierStatus.message = event.available + ? this.magnifierStatus.enabled + ? "全屏局部放大镜可用" + : "全屏局部放大镜已关闭" + : `Windows 放大镜不可用${event.errorCode ? `(错误 ${event.errorCode})` : ""}`; + return; + } if (response.id == null) return; const pending = this.pending.get(response.id); if (!pending) return; @@ -927,10 +1003,22 @@ export class SearchService { private updateStatus(status: IndexerStatus): void { const previousState = this.status.state; + const previousMode = this.status.mode; if (status.state === "indexing" && previousState !== "indexing") { this.lastFullRefreshRequestedAt = Date.now(); } this.status = structuredClone(status); + if (status.state !== previousState || status.mode !== previousMode) { + logger.info("indexer.status_changed", { + previousState, + previousMode, + state: status.state, + mode: status.mode, + entries: status.entries, + progress: status.progress, + message: status.message + }); + } this.onStatus(this.getStatus()); if (status.state === "ready" && previousState !== "ready") { void this.scheduleDailyRefresh(); diff --git a/electron/settings-normalization.ts b/electron/settings-normalization.ts new file mode 100644 index 0000000..3f9890d --- /dev/null +++ b/electron/settings-normalization.ts @@ -0,0 +1,9 @@ +export function normalizeStoredUiScale(value: unknown, fallback = 1): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Number(Math.min(3, Math.max(0.5, Math.round(value * 10) / 10)).toFixed(1)); +} + +export function normalizeStoredMouseHoldMs(value: unknown, fallback = 3_000): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.round(Math.min(3_000, Math.max(0, value)) / 100) * 100; +} diff --git a/electron/store.ts b/electron/store.ts index 4a5b96f..0e71a6d 100644 --- a/electron/store.ts +++ b/electron/store.ts @@ -1,11 +1,17 @@ import { app, safeStorage } from "electron"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; +import { sanitizeDirectoryDialogPaths } from "./directory-dialog"; +import { + normalizeStoredMouseHoldMs, + normalizeStoredUiScale +} from "./settings-normalization"; import type { AnalysisResult, AppSettings, ContentSearchResult, DirectorySizeResult, + DirectoryDialogPurpose, MigrationRecord, SearchFilters, SearchBookmark, @@ -20,6 +26,8 @@ import type { const defaults: StoreShape = { settings: { effectMode: "aurora", + language: "zh-CN", + uiScale: 1, launchAtLogin: false, launchMinimized: false, minimizeToTray: true, @@ -27,6 +35,10 @@ const defaults: StoreShape = { quickSearchShortcut: "CommandOrControl+Alt+F", mouseQuickSearchButton: "back", mouseQuickSearchHoldMs: 3_000, + magnifierEnabled: false, + magnifierModifiers: "Ctrl", + magnifierWidth: 480, + magnifierHeight: 300, indexRoots: ["*"], excludedPaths: [ "C:\\Windows\\WinSxS", @@ -44,6 +56,7 @@ const defaults: StoreShape = { } }, migrations: [], + directoryDialogPaths: {}, searchBookmarks: [], searchBookmarkFolders: [], uiLayout: {}, @@ -293,6 +306,9 @@ function sanitizeUiLayout(value: unknown): UiLayoutState { if (typeof input.sidebarCollapsed === "boolean") { result.sidebarCollapsed = input.sidebarCollapsed; } + if (typeof input.sidebarWidth === "number" && Number.isFinite(input.sidebarWidth)) { + result.sidebarWidth = Math.max(190, Math.min(360, Math.round(input.sidebarWidth))); + } if ( input.searchResultColumnWidths && typeof input.searchResultColumnWidths === "object" @@ -489,6 +505,22 @@ function mergeSettings(input?: Partial): AppSettings { ) ? aiInput!.protocol! : defaults.settings.ai.protocol; + const magnifierModifierParts = + typeof input?.magnifierModifiers === "string" + ? input.magnifierModifiers + .split("+") + .map((part) => part.trim().toLocaleLowerCase()) + : []; + const magnifierModifiers = [ + magnifierModifierParts.includes("ctrl") || magnifierModifierParts.includes("control") + ? "Ctrl" + : undefined, + magnifierModifierParts.includes("alt") ? "Alt" : undefined, + magnifierModifierParts.includes("shift") ? "Shift" : undefined, + magnifierModifierParts.includes("win") || magnifierModifierParts.includes("meta") + ? "Win" + : undefined + ].filter((part): part is string => Boolean(part)); return { ...defaults.settings, ...input, @@ -497,6 +529,27 @@ function mergeSettings(input?: Partial): AppSettings { ) ? input!.effectMode! : defaults.settings.effectMode, + language: [ + "system", + "zh-CN", + "zh-TW", + "en-US", + "ja-JP", + "ko-KR", + "es-ES", + "fr-FR", + "de-DE", + "pt-BR", + "ru-RU", + "ar-SA", + "hi-IN", + "id-ID", + "it-IT", + "tr-TR" + ].includes(input?.language ?? "") + ? input!.language! + : defaults.settings.language, + uiScale: normalizeStoredUiScale(input?.uiScale, defaults.settings.uiScale), globalShortcut: typeof input?.globalShortcut === "string" ? input.globalShortcut.trim().slice(0, 128) @@ -510,11 +563,14 @@ function mergeSettings(input?: Partial): AppSettings { ) ? input!.mouseQuickSearchButton! : defaults.settings.mouseQuickSearchButton, - mouseQuickSearchHoldMs: - typeof input?.mouseQuickSearchHoldMs === "number" && - Number.isFinite(input.mouseQuickSearchHoldMs) - ? Math.round(Math.min(10_000, Math.max(500, input.mouseQuickSearchHoldMs))) - : defaults.settings.mouseQuickSearchHoldMs, + mouseQuickSearchHoldMs: normalizeStoredMouseHoldMs( + input?.mouseQuickSearchHoldMs, + defaults.settings.mouseQuickSearchHoldMs + ), + magnifierEnabled: Boolean(input?.magnifierEnabled), + magnifierModifiers: magnifierModifiers.join("+") || defaults.settings.magnifierModifiers, + magnifierWidth: Math.round(Math.min(1200, Math.max(160, Number(input?.magnifierWidth) || 480)) / 10) * 10, + magnifierHeight: Math.round(Math.min(900, Math.max(120, Number(input?.magnifierHeight) || 300)) / 10) * 10, indexRoots: Array.isArray(input?.indexRoots) ? input.indexRoots : defaults.settings.indexRoots, excludedPaths: Array.isArray(input?.excludedPaths) ? input.excludedPaths @@ -554,6 +610,7 @@ export class AppStore { .map(sanitizeMigration) .filter((item): item is MigrationRecord => Boolean(item)) .slice(-2_000), + directoryDialogPaths: sanitizeDirectoryDialogPaths(parsed.directoryDialogPaths), searchWorkspace: sanitizeSearchWorkspace(parsed.searchWorkspace), searchBookmarks: ( Array.isArray(parsed.searchBookmarks) ? parsed.searchBookmarks : [] @@ -590,6 +647,18 @@ export class AppStore { return structuredClone(this.data.settings); } + getDirectoryDialogPath(purpose: DirectoryDialogPurpose): string | undefined { + return this.data.directoryDialogPaths[purpose]; + } + + async saveDirectoryDialogPath( + purpose: DirectoryDialogPurpose, + selectedPath: string + ): Promise { + this.data.directoryDialogPaths[purpose] = selectedPath.slice(0, 32_768); + await this.flush(); + } + async updateSettings( patch: Partial & { ai?: Partial; apiKey?: string } ): Promise { diff --git a/electron/types.ts b/electron/types.ts index 48b064e..7970cea 100644 --- a/electron/types.ts +++ b/electron/types.ts @@ -134,6 +134,7 @@ export interface SearchBookmarkFolder { export interface UiLayoutState { sidebarCollapsed?: boolean; + sidebarWidth?: number; searchResultColumnWidths?: SearchResultColumnWidths; searchRenamePosition?: { x: number; @@ -156,6 +157,30 @@ export interface SearchContextActionResult { message?: string; } +export interface ForceDeleteProcess { + pid: number; + name: string; + executablePath?: string; + matchReason: "executable" | "command-line"; + canTerminate: boolean; +} + +export interface ForceDeletePreview { + verificationId: string; + path: string; + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; + highRisk: boolean; + elevated: boolean; + processes: ForceDeleteProcess[]; +} + +export interface ForceDeleteResult { + deleted: boolean; + terminatedProcesses: ForceDeleteProcess[]; +} + export interface DirectorySizeResult { path: string; bytes: number; @@ -198,6 +223,8 @@ export interface DirectorySummary { totalBytes: number; fileCount: number; directoryCount: number; + reparsePointCount: number; + reparsePoints?: Array<{ relativePath: string; target: string }>; lastModified?: string; extensionBreakdown: Array<{ extension: string; count: number; bytes: number }>; largestChildren: Array<{ path: string; bytes: number; isDirectory: boolean }>; @@ -305,6 +332,8 @@ export interface OwnershipMapResult { export interface AppSettings { effectMode: "aurora" | "matrix" | "calm" | "ember" | "ivory"; + language: AppLanguage; + uiScale: UiScale; launchAtLogin: boolean; launchMinimized: boolean; minimizeToTray: boolean; @@ -312,6 +341,10 @@ export interface AppSettings { quickSearchShortcut: string; mouseQuickSearchButton: MouseShortcutButton; mouseQuickSearchHoldMs: number; + magnifierEnabled: boolean; + magnifierModifiers: string; + magnifierWidth: number; + magnifierHeight: number; indexRoots: string[]; excludedPaths: string[]; ai: { @@ -326,6 +359,26 @@ export interface AppSettings { }; } +export type UiScale = number; + +export type AppLanguage = + | "system" + | "zh-CN" + | "zh-TW" + | "en-US" + | "ja-JP" + | "ko-KR" + | "es-ES" + | "fr-FR" + | "de-DE" + | "pt-BR" + | "ru-RU" + | "ar-SA" + | "hi-IN" + | "id-ID" + | "it-IT" + | "tr-TR"; + export type MouseShortcutButton = "disabled" | "back" | "forward" | "middle"; export interface MouseShortcutStatus { @@ -445,6 +498,7 @@ export interface PreflightResult { availableBytes: number; fileCount: number; directoryCount: number; + reparsePointCount: number; risk: "low" | "medium" | "high" | "blocked"; warnings: string[]; blockers: string[]; @@ -466,6 +520,7 @@ export interface InstalledApplication { export interface StoreShape { settings: AppSettings; migrations: MigrationRecord[]; + directoryDialogPaths: Partial>; searchWorkspace?: SearchWorkspaceState; searchBookmarks: SearchBookmark[]; searchBookmarkFolders: SearchBookmarkFolder[]; @@ -474,6 +529,24 @@ export interface StoreShape { ownershipMaps: OwnershipMapResult[]; } +export interface MagnifierStatus { + available: boolean; + enabled: boolean; + modifiers: string; + width: number; + height: number; + message: string; +} + +export type DirectoryDialogPurpose = + | "migration-source" + | "migration-destination" + | "analysis" + | "search-scope" + | "content-index" + | "copy-destination" + | "general"; + export interface NativeResponse { id?: number; event?: string; diff --git a/native/indexer/Cargo.lock b/native/indexer/Cargo.lock index 535f7af..be65cb2 100644 --- a/native/indexer/Cargo.lock +++ b/native/indexer/Cargo.lock @@ -68,7 +68,7 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "cshift-indexer" -version = "0.0.5" +version = "0.0.8" dependencies = [ "memmap2", "notify", diff --git a/native/indexer/Cargo.toml b/native/indexer/Cargo.toml index b1fd96c..1a4dec0 100644 --- a/native/indexer/Cargo.toml +++ b/native/indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-indexer" -version = "0.0.5" +version = "0.0.8" edition = "2021" description = "First-party NTFS MFT indexer for CDriveShiftAI" @@ -15,6 +15,7 @@ regex = "1.11.1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52.0", features = [ "Win32_Foundation", + "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Security", "Win32_Storage_FileSystem", @@ -23,5 +24,8 @@ windows-sys = { version = "0.52.0", features = [ "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_Input", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_HiDpi", + "Win32_UI_Magnification", "Win32_UI_WindowsAndMessaging" ] } diff --git a/native/indexer/src/main.rs b/native/indexer/src/main.rs index 70fe9ab..aed74a8 100644 --- a/native/indexer/src/main.rs +++ b/native/indexer/src/main.rs @@ -13,13 +13,17 @@ use std::env; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Mutex, OnceLock, RwLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const CACHE_MAGIC: &[u8; 8] = b"CSIDX02\0"; const CONTENT_INDEX_VERSION: u32 = 3; +// A normal Windows system volume always contains far more entries than this. +// Keeping the threshold conservative lets us reject an interrupted/stale cache +// without making assumptions about optional data drives. +const MINIMUM_SYSTEM_DRIVE_ENTRIES: usize = 10_000; #[derive(Clone, Debug)] struct Entry { @@ -176,6 +180,11 @@ struct Request { rebuild_reason: Option, mouse_button: Option, mouse_hold_ms: Option, + magnifier_enabled: Option, + magnifier_modifiers: Option, + magnifier_width: Option, + magnifier_height: Option, + magnifier_capture_active: Option, limit: Option, cursor: Option, sort_by: Option, @@ -291,6 +300,181 @@ fn mouse_button_name(button: u64) -> &'static str { } } +#[cfg(windows)] +struct MagnifierState { + available: AtomicBool, + enabled: AtomicBool, + capture_active: AtomicBool, + lens_active: AtomicBool, + modifiers: AtomicU64, + width: AtomicU64, + height: AtomicU64, + factor_tenths: AtomicU64, + displayed_factor_milli: AtomicU64, + rendered_width: AtomicU64, + rendered_height: AtomicU64, + rendered_lens_x: AtomicI32, + rendered_lens_y: AtomicI32, + host_window: AtomicUsize, + control_window_a: AtomicUsize, + control_window_b: AtomicUsize, + front_control: AtomicU64, +} + +#[cfg(windows)] +static MAGNIFIER_STATE: OnceLock = OnceLock::new(); +static INDEXER_BACKGROUNDED: AtomicBool = AtomicBool::new(false); +static GLOBAL_INPUT_LISTENER_AVAILABLE: AtomicBool = AtomicBool::new(false); +static GLOBAL_INPUT_LISTENER_CONFLICT: AtomicBool = AtomicBool::new(false); +#[cfg(windows)] +static GLOBAL_INPUT_LISTENER_MUTEX: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +const WM_CSHIFT_MAGNIFIER_HIDE: u32 = 0x8000 + 0x51; + +#[cfg(windows)] +fn next_magnifier_factor(displayed: u64, target: u64) -> u64 { + if displayed == target { + return target; + } + // Ease toward the requested zoom instead of presenting two unrelated + // captures at either side of a wheel notch. The 0.02x minimum keeps the + // final few frames moving and converges in roughly 80-120 ms. + let difference = target.abs_diff(displayed); + let step = (difference / 4).max(20); + if displayed < target { + displayed.saturating_add(step).min(target) + } else { + displayed.saturating_sub(step).max(target) + } +} + +#[cfg(windows)] +fn input_listener_identity() -> String { + env::var("CDRIVESHIFTAI_INPUT_LISTENER_ID") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "CDriveShiftAI.MouseBackListener".to_string()) +} + +#[cfg(windows)] +unsafe fn claim_global_input_listener(class_name: &[u16], identity: &str) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS}; + use windows_sys::Win32::System::Threading::CreateMutexW; + use windows_sys::Win32::UI::WindowsAndMessaging::{FindWindowExW, HWND_MESSAGE}; + + // Releases prior to the named lock already expose this message-only + // window. Checking it keeps a new portable build from competing with an + // older installed build that is still resident in the tray. + if FindWindowExW(HWND_MESSAGE, 0, class_name.as_ptr(), std::ptr::null()) != 0 { + return false; + } + let mutex_name: Vec = format!("Local\\{identity}.GlobalInputListener\0") + .encode_utf16() + .collect(); + let mutex = CreateMutexW(std::ptr::null(), 0, mutex_name.as_ptr()); + if mutex == 0 { + return false; + } + if GetLastError() == ERROR_ALREADY_EXISTS { + CloseHandle(mutex); + return false; + } + GLOBAL_INPUT_LISTENER_MUTEX.store(mutex as usize, Ordering::SeqCst); + true +} + +#[cfg(windows)] +fn magnifier_available() -> bool { + MAGNIFIER_STATE + .get() + .is_some_and(|state| state.available.load(Ordering::SeqCst)) +} + +#[cfg(not(windows))] +fn magnifier_available() -> bool { + false +} + +#[cfg(windows)] +fn magnifier_modifier_mask(value: &str) -> u64 { + value.split('+').fold(0, |mask, part| { + match part.trim().to_ascii_lowercase().as_str() { + "ctrl" | "control" => mask | 1, + "alt" => mask | 2, + "shift" => mask | 4, + "win" | "meta" => mask | 8, + _ => mask, + } + }) +} + +fn magnifier_size(value: u64, min: u64, max: u64) -> u64 { + ((value.clamp(min, max) + 9) / 10) * 10 +} + +#[cfg(windows)] +fn configure_magnifier(enabled: bool, modifiers: &str, width: u64, height: u64) { + let Some(state) = MAGNIFIER_STATE.get() else { + return; + }; + let modifier_mask = magnifier_modifier_mask(modifiers); + let was_enabled = state.enabled.swap(enabled, Ordering::SeqCst); + state.modifiers.store( + if modifier_mask == 0 { 1 } else { modifier_mask }, + Ordering::SeqCst, + ); + state + .width + .store(magnifier_size(width, 160, 1200), Ordering::SeqCst); + state + .height + .store(magnifier_size(height, 120, 900), Ordering::SeqCst); + + // Width, height and modifier changes are configuration only. In + // particular, moving a settings slider must not touch the native layered + // window at all; the stored dimensions are applied on the next real + // modifier+wheel gesture. Only turning the feature off needs to dismiss an + // already-visible lens immediately. + if was_enabled && !enabled { + state.lens_active.store(false, Ordering::SeqCst); + unsafe { + use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; + let host = state.host_window.load(Ordering::SeqCst) as isize; + if host != 0 { + SendMessageW(host, WM_CSHIFT_MAGNIFIER_HIDE, 0, 0); + } + } + } +} + +#[cfg(windows)] +fn configure_magnifier_capture(active: bool) { + let Some(state) = MAGNIFIER_STATE.get() else { + return; + }; + state.capture_active.store(active, Ordering::SeqCst); + if active { + state.lens_active.store(false, Ordering::SeqCst); + unsafe { + use windows_sys::Win32::UI::WindowsAndMessaging::SendMessageW; + let host = state.host_window.load(Ordering::SeqCst) as isize; + if host != 0 { + SendMessageW(host, WM_CSHIFT_MAGNIFIER_HIDE, 0, 0); + } + } + } +} + +#[cfg(not(windows))] +fn configure_magnifier_capture(_active: bool) {} + +#[cfg(not(windows))] +fn configure_magnifier(_enabled: bool, _modifiers: &str, _width: u64, _height: u64) {} + +fn normalize_mouse_hold_ms(hold_ms: u64) -> u64 { + hold_ms.min(3_000) +} + #[cfg(windows)] fn configure_mouse_shortcut(button: &str, hold_ms: u64) { let Some(state) = MOUSE_SHORTCUT_STATE.get() else { @@ -303,7 +487,7 @@ fn configure_mouse_shortcut(button: &str, hold_ms: u64) { .store(mouse_button_code(button), Ordering::SeqCst); state .hold_ms - .store(hold_ms.clamp(500, 10_000), Ordering::SeqCst); + .store(normalize_mouse_hold_ms(hold_ms), Ordering::SeqCst); } #[cfg(not(windows))] @@ -323,6 +507,15 @@ fn begin_mouse_shortcut_hold(button: u64) { let sequence = state.sequence.fetch_add(1, Ordering::SeqCst) + 1; let hold_ms = state.hold_ms.load(Ordering::SeqCst); let output = state.output.clone(); + if hold_ms == 0 { + output.send(&json!({ + "event": "mouseShortcutHold", + "button": mouse_button_name(button), + "holdMs": 0 + })); + state.pressed.store(false, Ordering::SeqCst); + return; + } thread::spawn(move || { thread::sleep(Duration::from_millis(hold_ms)); let Some(current) = MOUSE_SHORTCUT_STATE.get() else { @@ -365,10 +558,53 @@ unsafe extern "system" fn mouse_shortcut_window_proc( GetRawInputData, RAWINPUT, RAWINPUTHEADER, RID_INPUT, RIM_TYPEMOUSE, }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - DefWindowProcW, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_4_UP, RI_MOUSE_BUTTON_5_DOWN, - RI_MOUSE_BUTTON_5_UP, RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_MIDDLE_BUTTON_UP, WM_INPUT, + DefWindowProcW, KillTimer, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_4_UP, + RI_MOUSE_BUTTON_5_DOWN, RI_MOUSE_BUTTON_5_UP, RI_MOUSE_MIDDLE_BUTTON_DOWN, + RI_MOUSE_MIDDLE_BUTTON_UP, WM_INPUT, WM_TIMER, }; + if message == WM_TIMER { + if let Some(state) = MAGNIFIER_STATE.get() { + let modifiers_pressed = + magnifier_modifiers_pressed(state.modifiers.load(Ordering::SeqCst)); + if !modifiers_pressed { + state.lens_active.store(false, Ordering::SeqCst); + } + let active = state.available.load(Ordering::SeqCst) + && state.enabled.load(Ordering::SeqCst) + && !state.capture_active.load(Ordering::SeqCst) + && state.lens_active.load(Ordering::SeqCst) + && modifiers_pressed; + if active { + let target = state.factor_tenths.load(Ordering::SeqCst) * 100; + let displayed = state.displayed_factor_milli.load(Ordering::SeqCst); + let next = next_magnifier_factor(displayed, target); + state.displayed_factor_milli.store(next, Ordering::SeqCst); + } + update_magnifier_lens(active); + if !active { + // No idle polling: the hook starts this timer only after a + // real modifier+wheel gesture, and modifier release stops it. + KillTimer(window, 1); + } + } + return 0; + } + if message == WM_CSHIFT_MAGNIFIER_HIDE { + if let Some(state) = MAGNIFIER_STATE.get() { + state.lens_active.store(false, Ordering::SeqCst); + } + KillTimer(window, 1); + windows_sys::Win32::UI::WindowsAndMessaging::ShowWindow( + window, + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, + ); + return 0; + } + if message == windows_sys::Win32::UI::WindowsAndMessaging::WM_NCHITTEST { + return windows_sys::Win32::UI::WindowsAndMessaging::HTTRANSPARENT as isize; + } + if message == WM_INPUT { let mut raw = std::mem::MaybeUninit::::zeroed(); let mut size = std::mem::size_of::() as u32; @@ -412,9 +648,14 @@ fn start_mouse_shortcut_listener(output: Output) { use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; use windows_sys::Win32::UI::Input::{RegisterRawInputDevices, RAWINPUTDEVICE, RIDEV_INPUTSINK}; + use windows_sys::Win32::UI::Magnification::{ + MagInitialize, MagSetWindowFilterList, MW_FILTERMODE_EXCLUDE, WC_MAGNIFIER, + }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DispatchMessageW, GetMessageW, RegisterClassW, TranslateMessage, - HWND_MESSAGE, MSG, WNDCLASSW, + CreateWindowExW, DispatchMessageW, GetMessageW, RegisterClassW, SetLayeredWindowAttributes, + SetWindowsHookExW, TranslateMessage, HWND_MESSAGE, LWA_ALPHA, MSG, WH_MOUSE_LL, WNDCLASSW, + WS_CHILD, WS_CLIPCHILDREN, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP, WS_VISIBLE, }; let listener_output = output.clone(); @@ -430,10 +671,53 @@ fn start_mouse_shortcut_listener(output: Output) { { return; } + let _ = MAGNIFIER_STATE.set(MagnifierState { + available: AtomicBool::new(false), + enabled: AtomicBool::new(false), + capture_active: AtomicBool::new(false), + lens_active: AtomicBool::new(false), + modifiers: AtomicU64::new(1), + width: AtomicU64::new(480), + height: AtomicU64::new(300), + factor_tenths: AtomicU64::new(20), + displayed_factor_milli: AtomicU64::new(2_000), + rendered_width: AtomicU64::new(0), + rendered_height: AtomicU64::new(0), + rendered_lens_x: AtomicI32::new(i32::MIN), + rendered_lens_y: AtomicI32::new(i32::MIN), + host_window: AtomicUsize::new(0), + control_window_a: AtomicUsize::new(0), + control_window_b: AtomicUsize::new(0), + front_control: AtomicU64::new(0), + }); thread::spawn(move || unsafe { + use windows_sys::Win32::UI::HiDpi::{ + SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + // Magnification source rectangles and popup coordinates are physical + // pixels. Without per-monitor awareness, Windows virtualizes the host + // size (for example 1200x900 becomes about 1800x1350 at 150%) while the + // capture source remains in another coordinate space, producing a + // nearly full-screen blank lens. + let _ = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); let module = GetModuleHandleW(std::ptr::null()); - let class_name: Vec = "CDriveShiftAI.MouseBackListener\0".encode_utf16().collect(); + let identity = input_listener_identity(); + let class_name: Vec = format!("{identity}\0").encode_utf16().collect(); + if !claim_global_input_listener(&class_name, &identity) { + GLOBAL_INPUT_LISTENER_CONFLICT.store(true, Ordering::SeqCst); + listener_output.send(&json!({ + "event": "mouseShortcutStatus", + "available": false, + "conflict": true + })); + listener_output.send(&json!({ + "event": "magnifierStatus", + "available": false, + "conflict": true + })); + return; + } let window_class = WNDCLASSW { style: 0, lpfnWndProc: Some(mouse_shortcut_window_proc), @@ -442,7 +726,7 @@ fn start_mouse_shortcut_listener(output: Output) { hInstance: module, hIcon: 0, hCursor: 0, - hbrBackground: 0, + hbrBackground: windows_sys::Win32::Graphics::Gdi::CreateSolidBrush(0x0056C7E8), lpszMenuName: std::ptr::null(), lpszClassName: class_name.as_ptr(), }; @@ -493,6 +777,90 @@ fn start_mouse_shortcut_listener(output: Output) { return; } + let magnification_initialized = MagInitialize() != 0; + let host = if magnification_initialized { + CreateWindowExW( + WS_EX_TOPMOST + | WS_EX_LAYERED + | WS_EX_TRANSPARENT + | WS_EX_NOACTIVATE + | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + class_name.as_ptr(), + WS_POPUP | WS_CLIPCHILDREN, + 0, + 0, + 480, + 300, + 0, + 0, + module, + std::ptr::null(), + ) + } else { + 0 + }; + if host != 0 { + SetLayeredWindowAttributes(host, 0, 255, LWA_ALPHA); + } + let create_control = || { + if host == 0 { + return 0; + } + CreateWindowExW( + 0, + WC_MAGNIFIER, + class_name.as_ptr(), + WS_CHILD | WS_VISIBLE, + 3, + 3, + 474, + 294, + host, + 0, + module, + std::ptr::null(), + ) + }; + let control_a = create_control(); + let control_b = create_control(); + if let Some(state) = MAGNIFIER_STATE.get() { + state.host_window.store(host as usize, Ordering::SeqCst); + state + .control_window_a + .store(control_a as usize, Ordering::SeqCst); + state + .control_window_b + .store(control_b as usize, Ordering::SeqCst); + } + if control_a != 0 && control_b != 0 { + let mut excluded_window = host; + MagSetWindowFilterList(control_a, MW_FILTERMODE_EXCLUDE, 1, &mut excluded_window); + MagSetWindowFilterList(control_b, MW_FILTERMODE_EXCLUDE, 1, &mut excluded_window); + } + let hook = if host != 0 && control_a != 0 && control_b != 0 { + SetWindowsHookExW(WH_MOUSE_LL, Some(magnifier_mouse_hook), module, 0) + } else { + 0 + }; + if !magnification_initialized || host == 0 || control_a == 0 || control_b == 0 || hook == 0 + { + listener_output.send(&json!({ + "event": "magnifierStatus", + "available": false, + "errorCode": GetLastError() + })); + } else { + if let Some(state) = MAGNIFIER_STATE.get() { + state.available.store(true, Ordering::SeqCst); + } + GLOBAL_INPUT_LISTENER_AVAILABLE.store(true, Ordering::SeqCst); + listener_output.send(&json!({ + "event": "magnifierStatus", + "available": true + })); + } + listener_output.send(&json!({ "event": "mouseShortcutStatus", "available": true, @@ -553,6 +921,280 @@ fn normalized_path_hash(value: &str) -> u64 { hash } +#[cfg(windows)] +unsafe fn magnifier_modifiers_pressed(mask: u64) -> bool { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, VK_CONTROL, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MENU, VK_RMENU, VK_RSHIFT, + VK_RWIN, VK_SHIFT, + }; + let down = |key: u16| GetAsyncKeyState(key as i32) < 0; + (mask & 1 == 0 || down(VK_CONTROL)) + && (mask & 2 == 0 || down(VK_MENU) || down(VK_LMENU) || down(VK_RMENU)) + && (mask & 4 == 0 || down(VK_SHIFT) || down(VK_LSHIFT) || down(VK_RSHIFT)) + && (mask & 8 == 0 || down(VK_LWIN) || down(VK_RWIN)) +} + +#[cfg(windows)] +unsafe fn update_magnifier_lens(show: bool) { + use windows_sys::Win32::Foundation::{POINT, RECT}; + use windows_sys::Win32::Graphics::Dwm::DwmFlush; + use windows_sys::Win32::Graphics::Gdi::{ + RedrawWindow, RDW_INVALIDATE, RDW_NOERASE, RDW_UPDATENOW, + }; + use windows_sys::Win32::UI::Magnification::{ + MagSetWindowSource, MagSetWindowTransform, MAGTRANSFORM, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetCursorPos, GetSystemMetrics, IsWindowVisible, SetWindowPos, ShowWindow, HWND_TOP, + HWND_TOPMOST, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + SWP_NOACTIVATE, SWP_NOZORDER, SWP_SHOWWINDOW, SW_HIDE, + }; + let Some(state) = MAGNIFIER_STATE.get() else { + return; + }; + let host = state.host_window.load(Ordering::SeqCst) as isize; + let control_a = state.control_window_a.load(Ordering::SeqCst) as isize; + let control_b = state.control_window_b.load(Ordering::SeqCst) as isize; + if host == 0 || control_a == 0 || control_b == 0 { + return; + } + if !show || !state.enabled.load(Ordering::SeqCst) { + ShowWindow(host, SW_HIDE); + return; + } + let mut cursor = POINT { x: 0, y: 0 }; + if GetCursorPos(&mut cursor) == 0 { + return; + } + let width = state.width.load(Ordering::SeqCst) as i32; + let height = state.height.load(Ordering::SeqCst) as i32; + let content_width = (width - 6).max(1); + let content_height = (height - 6).max(1); + let factor = state.displayed_factor_milli.load(Ordering::SeqCst) as f32 / 1_000.0; + let source_width = (content_width as f32 / factor).round().max(1.0) as i32; + let source_height = (content_height as f32 / factor).round().max(1.0) as i32; + let virtual_left = GetSystemMetrics(SM_XVIRTUALSCREEN); + let virtual_top = GetSystemMetrics(SM_YVIRTUALSCREEN); + let virtual_right = virtual_left + GetSystemMetrics(SM_CXVIRTUALSCREEN); + let virtual_bottom = virtual_top + GetSystemMetrics(SM_CYVIRTUALSCREEN); + let source_left = (cursor.x - source_width / 2).clamp( + virtual_left, + (virtual_right - source_width).max(virtual_left), + ); + let source_top = (cursor.y - source_height / 2).clamp( + virtual_top, + (virtual_bottom - source_height).max(virtual_top), + ); + let source = RECT { + left: source_left, + top: source_top, + right: source_left + source_width, + bottom: source_top + source_height, + }; + let mut transform = MAGTRANSFORM { v: [0.0; 9] }; + transform.v[0] = factor; + transform.v[4] = factor; + transform.v[8] = 1.0; + let lens_x = + (cursor.x - width / 2).clamp(virtual_left, (virtual_right - width).max(virtual_left)); + let lens_y = + (cursor.y - height / 2).clamp(virtual_top, (virtual_bottom - height).max(virtual_top)); + + let previous_width = state.rendered_width.swap(width as u64, Ordering::SeqCst); + let previous_height = state.rendered_height.swap(height as u64, Ordering::SeqCst); + let size_changed = previous_width != width as u64 || previous_height != height as u64; + if size_changed { + for control in [control_a, control_b] { + SetWindowPos( + control, + 0, + 3, + 3, + content_width, + content_height, + SWP_NOACTIVATE | SWP_NOZORDER, + ); + } + } + + let front_index = state.front_control.load(Ordering::SeqCst); + let back_control = if front_index == 0 { + control_b + } else { + control_a + }; + // The currently-front control keeps showing the last complete frame while + // the other control receives the matching source rectangle and scale. + // Any intermediate repaint therefore remains fully covered. + MagSetWindowSource(back_control, source); + MagSetWindowTransform(back_control, &mut transform); + RedrawWindow( + back_control, + std::ptr::null(), + 0, + RDW_INVALIDATE | RDW_NOERASE | RDW_UPDATENOW, + ); + // Wait until the back control's surface is committed before swapping the + // child z-order. The user always sees a complete frame, never the host's + // background or a half-updated scale/source pair. + DwmFlush(); + SetWindowPos( + back_control, + HWND_TOP, + 3, + 3, + content_width, + content_height, + SWP_NOACTIVATE | SWP_SHOWWINDOW, + ); + state + .front_control + .store(if front_index == 0 { 1 } else { 0 }, Ordering::SeqCst); + + let x_changed = state.rendered_lens_x.swap(lens_x, Ordering::SeqCst) != lens_x; + let y_changed = state.rendered_lens_y.swap(lens_y, Ordering::SeqCst) != lens_y; + let visible = IsWindowVisible(host) != 0; + if !visible { + SetWindowPos( + host, + HWND_TOPMOST, + lens_x, + lens_y, + width, + height, + SWP_NOACTIVATE | SWP_SHOWWINDOW, + ); + } else if x_changed || y_changed || size_changed { + SetWindowPos( + host, + 0, + lens_x, + lens_y, + width, + height, + SWP_NOACTIVATE | SWP_NOZORDER, + ); + } +} + +#[cfg(windows)] +unsafe extern "system" fn magnifier_mouse_hook( + code: i32, + wparam: windows_sys::Win32::Foundation::WPARAM, + lparam: windows_sys::Win32::Foundation::LPARAM, +) -> windows_sys::Win32::Foundation::LRESULT { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, SetTimer, MSLLHOOKSTRUCT, WM_MOUSEWHEEL, + }; + if code >= 0 { + let Some(state) = MAGNIFIER_STATE.get() else { + return CallNextHookEx(0, code, wparam, lparam); + }; + let active = state.available.load(Ordering::SeqCst) + && state.enabled.load(Ordering::SeqCst) + && !state.capture_active.load(Ordering::SeqCst) + && magnifier_modifiers_pressed(state.modifiers.load(Ordering::SeqCst)); + if wparam as u32 == WM_MOUSEWHEEL && active { + let event = &*(lparam as *const MSLLHOOKSTRUCT); + let delta = (event.mouseData >> 16) as i16; + let current = state.factor_tenths.load(Ordering::SeqCst); + let next = if delta > 0 { + current.saturating_add(2) + } else { + current.saturating_sub(2) + }; + state + .factor_tenths + .store(next.clamp(15, 80), Ordering::SeqCst); + let was_active = state.lens_active.swap(true, Ordering::SeqCst); + if !was_active { + // Start the first frame at the current target. Subsequent + // wheel ticks are animated by the window-thread timer. + state + .displayed_factor_milli + .store(current * 100, Ordering::SeqCst); + } + let host = state.host_window.load(Ordering::SeqCst) as isize; + if host != 0 && !was_active { + // Poll only while the lens is active so modifier release can + // hide it. Do not reset this timer on every wheel tick: rapid + // scrolling must keep producing animation frames continuously. + SetTimer(host, 1, 16, None); + } + return 1; + } + } + CallNextHookEx(0, code, wparam, lparam) +} + +fn is_drive_root(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/') +} + +fn system_drive_root() -> Option { + let value = env::var("SystemDrive").ok()?; + let drive = value.as_bytes().first().copied()?; + drive + .is_ascii_alphabetic() + .then(|| format!("{}:\\", (drive as char).to_ascii_uppercase())) +} + +fn path_belongs_to_root(path_value: &str, root: &str) -> bool { + path_value + .get(..root.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(root)) +} + +fn validate_global_entry_count<'a>( + roots: &[String], + paths: impl Iterator, +) -> io::Result<()> { + let Some(system_root) = system_drive_root() else { + return Ok(()); + }; + if !roots.iter().all(|root| is_drive_root(root)) + || !roots + .iter() + .any(|root| root.eq_ignore_ascii_case(&system_root)) + { + return Ok(()); + } + + let system_entries = paths + .filter(|path_value| path_belongs_to_root(path_value, &system_root)) + .take(MINIMUM_SYSTEM_DRIVE_ENTRIES) + .count(); + if system_entries < MINIMUM_SYSTEM_DRIVE_ENTRIES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "incomplete system-drive index: only {system_entries} entries under {system_root}" + ), + )); + } + Ok(()) +} + +fn validate_entry_cache(roots: &[String], index: &SearchIndex) -> io::Result<()> { + validate_global_entry_count( + roots, + index + .entries + .iter() + .enumerate() + .filter(|(entry_index, _)| index.live.get(*entry_index).copied().unwrap_or(false)) + .filter_map(|(entry_index, _)| index.path(entry_index)), + ) +} + +fn validate_scanned_entries(roots: &[String], entries: &[Entry]) -> io::Result<()> { + validate_global_entry_count(roots, entries.iter().map(|entry| entry.path.as_str())) +} + fn wait_while_backgrounded(backgrounded: &AtomicBool, stopping: &AtomicBool) -> bool { while backgrounded.load(Ordering::Relaxed) && !stopping.load(Ordering::Relaxed) { thread::sleep(Duration::from_millis(250)); @@ -660,12 +1302,15 @@ fn trim_process_tree_working_sets(root_pid: u32) { fn trim_process_tree_working_sets(_root_pid: u32) {} #[cfg(windows)] -fn set_indexing_priority(active: bool) { +fn set_indexer_priority(active: bool, background: bool) { use windows_sys::Win32::System::Threading::{ - GetCurrentProcess, SetPriorityClass, BELOW_NORMAL_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, + GetCurrentProcess, SetPriorityClass, BELOW_NORMAL_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, + NORMAL_PRIORITY_CLASS, }; unsafe { - let priority = if active { + let priority = if background { + IDLE_PRIORITY_CLASS + } else if active { BELOW_NORMAL_PRIORITY_CLASS } else { NORMAL_PRIORITY_CLASS @@ -675,7 +1320,11 @@ fn set_indexing_priority(active: bool) { } #[cfg(not(windows))] -fn set_indexing_priority(_active: bool) {} +fn set_indexer_priority(_active: bool, _background: bool) {} + +fn set_indexing_priority(active: bool) { + set_indexer_priority(active, INDEXER_BACKGROUNDED.load(Ordering::Relaxed)); +} fn file_name(value: &str) -> &str { value @@ -1409,6 +2058,9 @@ fn save_cache( fs::create_dir_all(parent)?; } let temporary = cache_path.with_extension("tmp"); + let backup = cache_path.with_extension("previous"); + let _ = fs::remove_file(&temporary); + let _ = fs::remove_file(&backup); let mut writer = BufWriter::new(File::create(&temporary)?); writer.write_all(CACHE_MAGIC)?; let root_bytes = root.as_bytes(); @@ -1431,7 +2083,23 @@ fn save_cache( } writer.flush()?; drop(writer); - fs::rename(temporary, cache_path)?; + // std::fs::rename cannot replace an existing destination on Windows and + // reports access denied (os error 5). Move the old cache aside first, then + // restore it if publishing the newly written cache fails. + let had_previous = cache_path.exists(); + if had_previous { + fs::rename(cache_path, &backup)?; + } + if let Err(error) = fs::rename(&temporary, cache_path) { + if had_previous { + let _ = fs::rename(&backup, cache_path); + } + let _ = fs::remove_file(&temporary); + return Err(error); + } + if had_previous { + let _ = fs::remove_file(&backup); + } Ok(()) } @@ -3215,6 +3883,7 @@ fn start_initial_cache_load(state: Arc, output: Output) { ), ), }; + validate_entry_cache(&state.roots, &index)?; Ok((index, delta_count, message)) })(); @@ -3246,7 +3915,21 @@ fn start_initial_cache_load(state: Arc, output: Output) { Err(error) if state.stopping.load(Ordering::Relaxed) => { let _ = error; } - Err(_) => { + Err(error) => { + eprintln!("persisted index rejected; rebuilding: {error}"); + update_status( + &state, + &output, + Status { + mode: "repair".to_string(), + state: "indexing".to_string(), + entries: 0, + progress: 0.02, + root: state.display_root.clone(), + updated_at: None, + message: Some("检测到旧索引不完整或不可用,正在自动重建".to_string()), + }, + ); set_indexing_priority(false); start_scan(Arc::clone(&state), output.clone()); return; @@ -3341,7 +4024,29 @@ fn start_scan(state: Arc, output: Output) { break; } match ntfs::enumerate(root, &state.backgrounded, &state.stopping) { - Ok(mut entries) => combined.append(&mut entries), + Ok(mut entries) + if validate_scanned_entries(std::slice::from_ref(root), &entries) + .is_ok() => + { + combined.append(&mut entries) + } + Ok(entries) => { + used_walker = true; + output.status(&Status { + mode: "hybrid".to_string(), + state: "indexing".to_string(), + entries: combined.len(), + progress, + root: state.display_root.clone(), + updated_at: None, + message: Some(format!( + "{} 的 MFT 结果不完整(仅 {} 条),正在改用完整目录扫描", + root, + entries.len() + )), + }); + combined.extend(walk_filesystem(root, &output, &state)); + } Err(mft_error) => { used_walker = true; output.status(&Status { @@ -3360,9 +4065,10 @@ fn start_scan(state: Arc, output: Output) { } } } + let validation = validate_scanned_entries(&state.roots, &combined); ( if used_walker { "hybrid" } else { "mft" }.to_string(), - Ok::, io::Error>(combined), + validation.map(|_| combined), ) }; @@ -3372,7 +4078,8 @@ fn start_scan(state: Arc, output: Output) { for root in &state.roots { combined.extend(walk_filesystem(root, &output, &state)); } - ("walker".to_string(), Ok::, io::Error>(combined)) + let validation = validate_scanned_entries(&state.roots, &combined); + ("walker".to_string(), validation.map(|_| combined)) }; match result { @@ -3397,6 +4104,18 @@ fn start_scan(state: Arc, output: Output) { }, ); let cache_key = state.roots.join("|"); + // A cached index keeps the old file memory-mapped. Release that + // mapping immediately before replacing the file on Windows. + // Searches remain available again as soon as the new in-memory + // or mapped index is installed below. + let previous_index = { + let mut current = state + .index + .write() + .unwrap_or_else(|error| error.into_inner()); + std::mem::take(&mut *current) + }; + drop(previous_index); let save_result = save_cache( &state.cache_path, &cache_key, @@ -3404,21 +4123,32 @@ fn start_scan(state: Arc, output: Output) { &state.backgrounded, &state.stopping, ); - let cache_error = save_result.err(); + let mut cache_error = save_result.err(); + let mut entries = Some(entries); let mut index = if cache_error.is_none() { - drop(entries); match load_cache_index(&state.cache_path, &state.backgrounded, &state.stopping) { - Ok((_cached_root, index)) => index, - Err(_) => { - state.scanning.store(false, Ordering::SeqCst); - set_indexing_priority(false); - return; + Ok((_cached_root, index)) => { + entries.take(); + index + } + Err(error) => { + cache_error = Some(error); + let Some(index) = SearchIndex::from_entries_controlled( + entries.take().unwrap_or_default(), + &state.backgrounded, + &state.stopping, + ) else { + state.scanning.store(false, Ordering::SeqCst); + set_indexing_priority(false); + return; + }; + index } } } else { let Some(index) = SearchIndex::from_entries_controlled( - entries, + entries.take().unwrap_or_default(), &state.backgrounded, &state.stopping, ) else { @@ -3444,6 +4174,7 @@ fn start_scan(state: Arc, output: Output) { state.generation.fetch_add(1, Ordering::AcqRel); drop(delta_guard); trim_process_working_set(); + let persistent_cache = cache_error.is_none(); let cache_message = match (cache_error, delta_result.err()) { (Some(cache_error), Some(delta_error)) => Some(format!( "索引可用,但缓存和增量日志读取失败:{};{}", @@ -3457,7 +4188,11 @@ fn start_scan(state: Arc, output: Output) { &state, &output, Status { - mode, + mode: if persistent_cache { + mode + } else { + "volatile".to_string() + }, state: "ready".to_string(), entries: count, progress: 1.0, @@ -3515,6 +4250,12 @@ fn run_server() -> io::Result<()> { request.mouse_button.as_deref().unwrap_or("back"), request.mouse_hold_ms.unwrap_or(3_000), ); + configure_magnifier( + request.magnifier_enabled.unwrap_or(false), + request.magnifier_modifiers.as_deref().unwrap_or("Ctrl"), + request.magnifier_width.unwrap_or(480), + request.magnifier_height.unwrap_or(300), + ); let requested_root = request.root.unwrap_or_else(|| "*".to_string()); #[cfg(windows)] let roots = if requested_root == "*" { @@ -3592,6 +4333,10 @@ fn run_server() -> io::Result<()> { let background = request.background.unwrap_or(false); let process_id = request.process_id.unwrap_or(0); shared.backgrounded.store(background, Ordering::SeqCst); + INDEXER_BACKGROUNDED.store(background, Ordering::SeqCst); + // Background watchers remain accurate, but any incidental + // cache/watcher work yields to foreground applications. + set_indexer_priority(false, background); if background { trim_process_working_set(); } @@ -3617,16 +4362,43 @@ fn run_server() -> io::Result<()> { } "setMouseShortcut" => { let button = request.mouse_button.as_deref().unwrap_or("disabled"); - let hold_ms = request.mouse_hold_ms.unwrap_or(3_000).clamp(500, 10_000); + let hold_ms = normalize_mouse_hold_ms(request.mouse_hold_ms.unwrap_or(3_000)); configure_mouse_shortcut(button, hold_ms); output.send(&json!({ "id": request.id, "ok": true, - "available": cfg!(windows), + "available": cfg!(windows) && GLOBAL_INPUT_LISTENER_AVAILABLE.load(Ordering::SeqCst), + "conflict": GLOBAL_INPUT_LISTENER_CONFLICT.load(Ordering::SeqCst), "button": button, "holdMs": hold_ms })); } + "setMagnifier" => { + let enabled = request.magnifier_enabled.unwrap_or(false); + let modifiers = request.magnifier_modifiers.as_deref().unwrap_or("Ctrl"); + let width = magnifier_size(request.magnifier_width.unwrap_or(480), 160, 1200); + let height = magnifier_size(request.magnifier_height.unwrap_or(300), 120, 900); + configure_magnifier(enabled, modifiers, width, height); + output.send(&json!({ + "id": request.id, + "ok": true, + "available": magnifier_available(), + "conflict": GLOBAL_INPUT_LISTENER_CONFLICT.load(Ordering::SeqCst), + "enabled": enabled, + "modifiers": modifiers, + "width": width, + "height": height + })); + } + "setMagnifierCapture" => { + let active = request.magnifier_capture_active.unwrap_or(false); + configure_magnifier_capture(active); + output.send(&json!({ + "id": request.id, + "ok": true, + "active": active + })); + } "query" => { let Some(shared) = &state else { output.send( @@ -3851,11 +4623,46 @@ fn main() { #[cfg(test)] mod tests { + #[cfg(windows)] + use super::next_magnifier_factor; use super::{ - contains_whole_word, fuzzy_subsequence_score, lowercase_name_signature, name_signature, - normalized_path_hash, Entry, SearchIndex, + contains_whole_word, fuzzy_subsequence_score, load_cache_index, lowercase_name_signature, + name_signature, normalize_mouse_hold_ms, normalized_path_hash, save_cache, + system_drive_root, validate_global_entry_count, Entry, SearchIndex, + MINIMUM_SYSTEM_DRIVE_ENTRIES, }; + use std::env; + use std::fs; use std::sync::atomic::AtomicBool; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn mouse_hold_duration_supports_instant_activation_and_caps_at_three_seconds() { + assert_eq!(normalize_mouse_hold_ms(0), 0); + assert_eq!(normalize_mouse_hold_ms(1_500), 1_500); + assert_eq!(normalize_mouse_hold_ms(10_000), 3_000); + } + + #[cfg(windows)] + #[test] + fn magnifier_factor_animation_is_monotonic_and_converges() { + let mut rising = 2_000; + for _ in 0..32 { + let next = next_magnifier_factor(rising, 2_500); + assert!(next >= rising && next <= 2_500); + rising = next; + } + assert_eq!(rising, 2_500); + + let mut falling = 2_500; + for _ in 0..32 { + let next = next_magnifier_factor(falling, 1_500); + assert!(next <= falling && next >= 1_500); + falling = next; + } + assert_eq!(falling, 1_500); + assert_eq!(next_magnifier_factor(2_000, 2_000), 2_000); + } #[test] fn streaming_name_signature_preserves_case_folded_matches() { @@ -3875,6 +4682,76 @@ mod tests { ); } + #[test] + fn incomplete_system_drive_cache_is_rejected() { + let Some(system_root) = system_drive_root() else { + return; + }; + let roots = vec![system_root.clone()]; + let paths = (0..120) + .map(|index| format!("{system_root}partial-{index}")) + .collect::>(); + assert!(validate_global_entry_count(&roots, paths.iter().map(String::as_str)).is_err()); + + let paths = (0..MINIMUM_SYSTEM_DRIVE_ENTRIES) + .map(|index| format!("{system_root}complete-{index}")) + .collect::>(); + assert!(validate_global_entry_count(&roots, paths.iter().map(String::as_str)).is_ok()); + } + + #[test] + fn cache_save_replaces_existing_file_on_windows() { + let test_root = env::temp_dir().join(format!( + "cshift-cache-replace-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::create_dir_all(&test_root).expect("create test directory"); + let cache_path = test_root.join("search-index-v1.bin"); + let backgrounded = AtomicBool::new(false); + let stopping = AtomicBool::new(false); + save_cache( + &cache_path, + "T:\\", + &[Entry { + path: r"T:\first.txt".to_string(), + is_directory: false, + size: 1, + }], + &backgrounded, + &stopping, + ) + .expect("save initial cache"); + save_cache( + &cache_path, + "T:\\", + &[ + Entry { + path: r"T:\second.txt".to_string(), + is_directory: false, + size: 2, + }, + Entry { + path: r"T:\third.txt".to_string(), + is_directory: false, + size: 3, + }, + ], + &backgrounded, + &stopping, + ) + .expect("replace existing cache"); + let (root, index) = + load_cache_index(&cache_path, &backgrounded, &stopping).expect("load replacement"); + assert_eq!(root, "T:\\"); + assert_eq!(index.len(), 2); + drop(index); + fs::remove_dir_all(test_root).expect("remove test directory"); + } + #[test] fn complete_word_matching_uses_generic_character_boundaries() { assert!(contains_whole_word("test.jsp", "test")); diff --git a/native/updater/Cargo.lock b/native/updater/Cargo.lock index 0862c59..b827f65 100644 --- a/native/updater/Cargo.lock +++ b/native/updater/Cargo.lock @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "cshift-updater" -version = "0.0.5" +version = "0.0.8" dependencies = [ "serde", "serde_json", diff --git a/native/updater/Cargo.toml b/native/updater/Cargo.toml index 1114136..8da167c 100644 --- a/native/updater/Cargo.toml +++ b/native/updater/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cshift-updater" -version = "0.0.5" +version = "0.0.8" edition = "2021" description = "Transactional Windows updater for CDriveShiftAI" diff --git a/package-lock.json b/package-lock.json index 15f46fc..f775e46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.1.1", "license": "MIT", "dependencies": { "lucide-react": "^0.536.0", diff --git a/package.json b/package.json index fe126f3..c9e65a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cdriveshiftai", - "version": "0.0.6", + "version": "0.1.1", "private": true, "description": "CDriveShiftAI - AI-assisted Windows disk organizer and safe cross-drive directory migration tool", "main": "dist-electron/main.js", @@ -20,6 +20,7 @@ "dist:all": "npm run prepare:electron && npm run build:app && electron-builder --win nsis portable && node scripts/create-update-manifest.mjs", "test": "vitest run", "test:indexer": "node scripts/smoke-indexer.mjs", + "test:force-delete": "npm run build:main && node scripts/smoke-force-delete.mjs", "test:index-cache": "node scripts/smoke-index-cache-restart.mjs", "test:packaged-index-cache": "node scripts/smoke-packaged-index-cache.mjs", "test:background-cpu": "node scripts/smoke-background-cpu.mjs", @@ -37,6 +38,10 @@ "test:analysis-state": "node scripts/smoke-analysis-persistence.mjs", "test:search-state": "node scripts/smoke-packaged-search-state.mjs", "test:visual": "node scripts/visual-smoke.mjs", + "test:settings-sliders": "node scripts/smoke-settings-sliders.mjs", + "test:magnifier-native": "node scripts/smoke-native-magnifier.mjs", + "test:listener-exclusivity": "node scripts/smoke-native-listener-exclusivity.mjs", + "test:magnifier-drag": "node scripts/smoke-magnifier-slider-drag.mjs", "test:watch": "vitest", "lint": "tsc --noEmit && tsc -p tsconfig.electron.json --noEmit" }, diff --git a/scripts/smoke-analysis-persistence.mjs b/scripts/smoke-analysis-persistence.mjs index 55a40ce..22af7bc 100644 --- a/scripts/smoke-analysis-persistence.mjs +++ b/scripts/smoke-analysis-persistence.mjs @@ -50,9 +50,21 @@ async function withApplication(debugPort, task) { ); let socket; try { - const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`); - const page = pages.find((candidate) => candidate.type === "page"); - if (!page?.webSocketDebuggerUrl) throw new Error("Electron page target was unavailable"); + let page; + let lastError; + for (let attempt = 0; attempt < 140; attempt += 1) { + try { + const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`, 1); + page = pages.find((candidate) => candidate.type === "page" && candidate.webSocketDebuggerUrl); + if (page) break; + } catch (error) { + lastError = error; + } + await wait(100); + } + if (!page?.webSocketDebuggerUrl) { + throw lastError ?? new Error("Electron page target was unavailable"); + } socket = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); diff --git a/scripts/smoke-force-delete.mjs b/scripts/smoke-force-delete.mjs new file mode 100644 index 0000000..c7e8335 --- /dev/null +++ b/scripts/smoke-force-delete.mjs @@ -0,0 +1,58 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const workspace = path.resolve(import.meta.dirname, ".."); +const require = createRequire(import.meta.url); +const { ForceDeleteService } = require(path.join(workspace, "dist-electron", "force-delete.js")); +const testRoot = path.join(workspace, ".cdriveshiftai-data", "test-temp"); +await mkdir(testRoot, { recursive: true }); +const fixture = await mkdtemp(path.join(testRoot, "force-delete-")); +const target = path.join(fixture, "occupied-target"); +await mkdir(target); +const commandFile = path.join(target, "hold-open.cmd"); +await writeFile(commandFile, "@echo off\r\nping -t 127.0.0.1 >nul\r\n", "utf8"); + +const child = spawn("cmd.exe", ["/d", "/c", commandFile], { + windowsHide: true, + detached: false, + stdio: "ignore" +}); + +try { + await new Promise((resolve) => setTimeout(resolve, 900)); + const service = new ForceDeleteService({ + applicationExecutable: process.execPath, + applicationDataRoot: path.join(workspace, ".cdriveshiftai-data"), + createVerificationId: () => "force-delete-smoke-verification" + }); + const preview = await service.preview(target); + const related = preview.processes.find((item) => item.pid === child.pid); + if (!related || !related.canTerminate) { + throw new Error(`Force-delete preview did not find the occupied fixture process: ${JSON.stringify(preview.processes)}`); + } + const result = await service.execute(preview.verificationId); + if (!result.deleted || !result.terminatedProcesses.some((item) => item.pid === child.pid)) { + throw new Error(`Force-delete execution did not terminate the related process: ${JSON.stringify(result)}`); + } + try { + await access(target); + throw new Error("Force-delete execution left the target directory on disk"); + } catch (error) { + if (error instanceof Error && error.message.includes("left the target")) throw error; + } + process.stdout.write(`${JSON.stringify({ + result: "ok", + relatedProcesses: preview.processes.length, + terminatedProcesses: result.terminatedProcesses.length, + targetRemoved: true + }, null, 2)}\n`); +} finally { + try { + child.kill("SIGKILL"); + } catch { + // The expected path already stopped the process tree. + } + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/smoke-indexer.mjs b/scripts/smoke-indexer.mjs index 28aed49..a9048ed 100644 --- a/scripts/smoke-indexer.mjs +++ b/scripts/smoke-indexer.mjs @@ -120,6 +120,20 @@ try { if (!nameResult.results?.some((result) => result.name === "Needle-file.txt")) { throw new Error("Name index smoke test did not find the fixture file"); } + const directoryResult = await request({ + op: "query", + query: "nested", + kind: "folder", + scope: fixture, + limit: 10 + }); + if ( + directoryResult.results?.length !== 1 || + directoryResult.results[0]?.name !== "nested" || + directoryResult.results[0]?.isDirectory !== true + ) { + throw new Error("Name index did not return a directly searchable directory result"); + } const firstPage = await request({ op: "query", query: "needle-page", diff --git a/scripts/smoke-magnifier-slider-drag.mjs b/scripts/smoke-magnifier-slider-drag.mjs new file mode 100644 index 0000000..4d490dc --- /dev/null +++ b/scripts/smoke-magnifier-slider-drag.mjs @@ -0,0 +1,218 @@ +import { execFileSync, spawn } from "node:child_process"; +import { access, mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import path from "node:path"; + +if (process.platform !== "win32") throw new Error("Windows is required"); +const workspace = path.resolve(import.meta.dirname, ".."); +const portable = process.argv.includes("--portable"); +const waitForStartup = !process.argv.includes("--immediate"); +const executable = portable + ? path.join(workspace, "release-ready", "CDriveShiftAI-x64-portable.exe") + : path.join(workspace, "release-ready", "win-unpacked", "CDriveShiftAI.exe"); +const testData = path.join(workspace, ".cdriveshiftai-data", "test-temp", `magnifier-slider-drag-${process.pid}`); +const listenerId = `CDriveShiftAI.DragSmoke.${process.pid}`; +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const selected = typeof address === "object" && address ? address.port : 0; + server.close((error) => error ? reject(error) : resolve(selected)); + }); +}); +await access(executable); +await rm(testData, { recursive: true, force: true }); +await mkdir(testData, { recursive: true }); +await writeFile(path.join(testData, "cdriveshiftai-state.json"), JSON.stringify({ settings: { effectMode: "ivory", minimizeToTray: false, magnifierEnabled: true, magnifierModifiers: "Ctrl", magnifierWidth: 480, magnifierHeight: 300 } })); + +const app = spawn(executable, [`--remote-debugging-port=${port}`, "--no-first-run"], { + cwd: path.dirname(executable), + env: { ...process.env, CDRIVESHIFTAI_DATA_DIR: testData, CDRIVESHIFTAI_INPUT_LISTENER_ID: listenerId }, + stdio: "ignore", + windowsHide: false +}); +let socket; +let nextId = 0; +let failure; +const rendererEvents = []; +const pending = new Map(); +const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP request timed out: ${method}`)); + }, 20_000); + pending.set(id, { + resolve: (value) => { clearTimeout(timer); resolve(value); }, + reject: (error) => { clearTimeout(timer); reject(error); } + }); + socket.send(JSON.stringify({ id, method, params })); +}); +const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, returnByValue: true })).result?.value; +const wheel = (holdMilliseconds = 0) => execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", String.raw` +Add-Type @' +using System; using System.Runtime.InteropServices; +public static class I { [DllImport("user32.dll")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr e); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); } +'@ +[I]::keybd_event(0x11,0,0,[UIntPtr]::Zero); [I]::mouse_event(0x800,0,0,120,[UIntPtr]::Zero); Start-Sleep -Milliseconds ${holdMilliseconds}; [I]::keybd_event(0x11,0,2,[UIntPtr]::Zero) +`], { windowsHide: true, stdio: "ignore" }); +const visibleLensWindows = () => JSON.parse(execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", String.raw` +Add-Type @' +using System; using System.Runtime.InteropServices; +public static class W { public delegate bool E(IntPtr h,IntPtr p); [StructLayout(LayoutKind.Sequential)] public struct R { public int Left,Top,Right,Bottom; } [DllImport("user32.dll")] public static extern bool EnumWindows(E e,IntPtr p); [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h,System.Text.StringBuilder s,int n); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h,out R r); [DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h); } +'@ +$result=@(); [W]::EnumWindows({param($h,$p) $s=New-Object Text.StringBuilder 256; [void][W]::GetClassName($h,$s,$s.Capacity); if($s.ToString() -eq '${listenerId}' -and [W]::IsWindowVisible($h)){ $r=New-Object W+R; if([W]::GetWindowRect($h,[ref]$r)){ $dpi=[W]::GetDpiForWindow($h); $rawWidth=$r.Right-$r.Left; $rawHeight=$r.Bottom-$r.Top; $script:result += @{rawWidth=$rawWidth;rawHeight=$rawHeight;scaledWidth=[Math]::Round($rawWidth*$dpi/96);scaledHeight=[Math]::Round($rawHeight*$dpi/96);dpi=$dpi} } }; return $true},[IntPtr]::Zero)|Out-Null; ConvertTo-Json @($result) -Compress +`], { encoding: "utf8", windowsHide: true }).trim()); + +try { + let page; + for (let attempt = 0; attempt < (portable ? 300 : 80); attempt += 1) { + try { + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + page = pages.find((item) => item.type === "page" && item.webSocketDebuggerUrl && item.url !== "devtools://devtools/bundled/inspector.html"); + if (page) break; + } catch {} + await wait(100); + } + if (!page) throw new Error("Packaged page did not start"); + socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + const item = pending.get(message.id); + if (item) { + pending.delete(message.id); + message.error ? item.reject(new Error(message.error.message)) : item.resolve(message.result); + return; + } + if (["Runtime.exceptionThrown", "Runtime.consoleAPICalled", "Log.entryAdded"].includes(message.method)) { + rendererEvents.push(message); + } + }); + await send("Runtime.enable"); + await send("Log.enable"); + await evaluate(`(() => { + window.__cshiftSmokeErrors = []; + window.addEventListener('error', (event) => window.__cshiftSmokeErrors.push({ type: 'error', message: event.message, stack: event.error?.stack })); + window.addEventListener('unhandledrejection', (event) => window.__cshiftSmokeErrors.push({ type: 'rejection', message: String(event.reason), stack: event.reason?.stack })); + return true; + })()`); + await evaluate('window.cDriveShiftAI ? true : false'); + await send("Runtime.evaluate", { expression: 'window.cDriveShiftAI && window.cDriveShiftAI.updateSettings({ effectMode: "ivory" })', awaitPromise: true }); + await send("Runtime.evaluate", { expression: 'window.cDriveShiftAI.navigateApp({ view: "settings" })', awaitPromise: true }); + // Renderer navigation is event-driven; clicking the sidebar Settings button + // is stable even if the app did not start with a view query parameter. + await evaluate(`(() => { const buttons=[...document.querySelectorAll('button')]; const target=buttons.find((button)=>/设置|Settings/i.test(button.textContent||'')); if(target){target.click();return true} return false })()`); + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await evaluate('document.querySelectorAll(".settings-module-nav button").length >= 3')) break; + await wait(100); + } + const moduleCount = await evaluate('document.querySelectorAll(".settings-module-nav button").length'); + if (moduleCount < 3) throw new Error(`Settings navigation did not load: ${moduleCount}`); + await evaluate('document.querySelectorAll(".settings-module-nav button")[2].click()'); + for (let attempt = 0; attempt < 50; attempt += 1) { + if (await evaluate('document.querySelector(".magnifier-size-setting input") !== null')) break; + await wait(100); + } + const sliderReady = await evaluate('document.querySelector(".magnifier-size-setting input") !== null'); + if (!sliderReady) throw new Error("Magnifier slider did not load"); + if (portable && waitForStartup) { + await wait(12_000); + const idleState = await evaluate(`({ rootChildren: document.querySelector('#root')?.childElementCount ?? 0, errors: window.__cshiftSmokeErrors ?? [] })`); + if (idleState.rootChildren < 1) throw new Error(`Renderer disappeared while idle: ${JSON.stringify(idleState)}`); + } + const point = await evaluate(`(() => { const r=document.querySelector('.magnifier-size-setting input').getBoundingClientRect(); return {x:r.left+r.width*.45,y:r.top+r.height/2}; })()`); + let maximumDuringDrag = 0; + for (let iteration = 0; iteration < 20; iteration += 1) { + const offset = iteration % 2 === 0 ? 120 : -80; + await send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 }); + // This deliberately contains no modifier key and no wheel input. Moving a + // size setting must be a pure UI operation and must never show the lens. + await wait(15); + maximumDuringDrag = Math.max(maximumDuringDrag, visibleLensWindows().length); + await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x + offset, y: point.y, button: "left", buttons: 1 }); + await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x + offset, y: point.y, button: "left", clickCount: 1 }); + await wait(35); + const iterationState = await evaluate(`({ rootChildren: document.querySelector('#root')?.childElementCount ?? 0, errors: window.__cshiftSmokeErrors ?? [] })`); + if (iterationState.rootChildren < 1) { + throw new Error(`Renderer disappeared at drag iteration ${iteration + 1}: ${JSON.stringify(iterationState)}`); + } + } + if (maximumDuringDrag !== 0) throw new Error(`Lens appeared while its size slider was being dragged: ${maximumDuringDrag}`); + await wait(350); + const pageState = await evaluate(`({ + rootChildren: document.querySelector('#root')?.childElementCount ?? 0, + bodyTextLength: document.body?.innerText?.length ?? 0, + sliderCount: document.querySelectorAll('.magnifier-size-setting input').length, + savedWidth: Number(document.querySelectorAll('.magnifier-size-setting input')[0]?.value ?? 0), + savedHeight: Number(document.querySelectorAll('.magnifier-size-setting input')[1]?.value ?? 0), + url: location.href, + readyState: document.readyState, + apiAvailable: Boolean(window.cDriveShiftAI), + rootPresent: Boolean(document.querySelector('#root')) + })`); + const screenshot = await send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false }); + if (pageState.rootChildren < 1 || pageState.bodyTextLength < 100 || pageState.sliderCount !== 2 || !screenshot.data) { + const errors = await evaluate('window.__cshiftSmokeErrors ?? []').catch(() => []); + throw new Error(`Renderer content disappeared after slider drag: ${JSON.stringify({ pageState, errors })}`); + } + let persistedSize; + for (let attempt = 0; attempt < 60; attempt += 1) { + persistedSize = (await send("Runtime.evaluate", { + expression: `window.cDriveShiftAI.getSettings().then((settings) => ({ width: settings.magnifierWidth, height: settings.magnifierHeight }))`, + returnByValue: true, + awaitPromise: true + })).result?.value; + if (persistedSize?.width === pageState.savedWidth && persistedSize?.height === pageState.savedHeight) break; + await wait(100); + } + if (persistedSize?.width !== pageState.savedWidth || persistedSize?.height !== pageState.savedHeight) { + throw new Error(`Slider value was not persisted: ${JSON.stringify({ pageState, persistedSize })}`); + } + // Only after the settings gesture is completely finished do we perform a + // real shortcut gesture. The native lens must use the saved slider size. + const wheelProcess = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Add-Type @'\nusing System; using System.Runtime.InteropServices; public static class I { [DllImport(\"user32.dll\")] public static extern void keybd_event(byte k,byte s,uint f,UIntPtr e); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); }\n'@\n[I]::keybd_event(0x11,0,0,[UIntPtr]::Zero); [I]::mouse_event(0x800,0,0,120,[UIntPtr]::Zero); Start-Sleep -Milliseconds 500; [I]::keybd_event(0x11,0,2,[UIntPtr]::Zero)`], { windowsHide: true, stdio: "ignore" }); + await wait(100); + const lens = visibleLensWindows()[0] ?? {}; + await new Promise((resolve) => wheelProcess.once("exit", resolve)); + const lensMatches = + (lens.rawWidth === pageState.savedWidth && lens.rawHeight === pageState.savedHeight) || + (lens.scaledWidth === pageState.savedWidth && lens.scaledHeight === pageState.savedHeight); + if (!lensMatches) { + throw new Error(`Lens did not use the saved slider size: ${JSON.stringify({ lens, pageState })}`); + } + console.log(JSON.stringify({ result: "ok", portable, iterations: 20, visibleLensesDuringDrag: maximumDuringDrag, pageState, persistedSize, lens, screenshotBytes: Buffer.from(screenshot.data, "base64").length }, null, 2)); +} catch (error) { + failure = error; + if (portable) { + const pages = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json()).catch(() => []); + console.error(JSON.stringify({ portableFailure: String(error), port, testData, pages, rendererEvents }, null, 2)); + } + throw error; +} finally { + if (socket) { + const closed = new Promise((resolve) => socket.addEventListener("close", resolve, { once: true })); + socket.close(); + await Promise.race([closed, wait(750)]); + } + if (app.exitCode === null) { + const exited = new Promise((resolve) => app.once("exit", resolve)); + app.kill(); + await Promise.race([exited, wait(4000)]); + } + // A single-file portable launcher exits after starting its extracted main + // process. Reap only descendants identifiable by this run's unique debug + // port or unique user-data directory; never touch an interactive instance. + execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", String.raw` +$portNeedle='--remote-debugging-port=${port}' +$dataNeedle='${testData.replaceAll("'", "''").replaceAll("\\", "\\")}' +$targets=@(Get-CimInstance Win32_Process | Where-Object { $_.Name -in @('CDriveShiftAI.exe','cshift-indexer.exe') -and ($_.CommandLine -like "*$portNeedle*" -or $_.CommandLine -like "*$dataNeedle*") }) +if($targets.Count -gt 0){ Stop-Process -Id $targets.ProcessId -Force } +`], { windowsHide: true, stdio: "ignore" }); + await wait(500); + if (!failure) { + await rm(testData, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 }); + } +} diff --git a/scripts/smoke-native-listener-exclusivity.mjs b/scripts/smoke-native-listener-exclusivity.mjs new file mode 100644 index 0000000..760cd3f --- /dev/null +++ b/scripts/smoke-native-listener-exclusivity.mjs @@ -0,0 +1,53 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") throw new Error("Windows is required"); +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join(workspace, "native", "indexer", "target", "release", "cshift-indexer.exe"); +const listenerId = `CDriveShiftAI.ExclusiveSmoke.${process.pid}`; +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +await access(executable); + +const children = []; +const statuses = []; +const launch = () => { + const child = spawn(executable, ["--serve"], { + env: { ...process.env, CDRIVESHIFTAI_INPUT_LISTENER_ID: listenerId }, + stdio: ["pipe", "pipe", "ignore"], + windowsHide: true + }); + children.push(child); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.event === "magnifierStatus") statuses.push(event); + } catch { + // Ignore partial/non-protocol output; the timeout reports missing status. + } + } + }); + return child; +}; + +try { + launch(); + for (let attempt = 0; attempt < 40 && statuses.length < 1; attempt += 1) await wait(100); + launch(); + for (let attempt = 0; attempt < 40 && statuses.length < 2; attempt += 1) await wait(100); + const owners = statuses.filter((status) => status.available === true); + const conflicts = statuses.filter((status) => status.conflict === true); + if (owners.length !== 1 || conflicts.length !== 1) { + throw new Error(`Expected one owner and one conflict: ${JSON.stringify(statuses)}`); + } + console.log(JSON.stringify({ result: "ok", owners: owners.length, conflicts: conflicts.length }, null, 2)); +} finally { + for (const child of children) { + if (child.exitCode == null) child.kill(); + } +} diff --git a/scripts/smoke-native-magnifier.mjs b/scripts/smoke-native-magnifier.mjs new file mode 100644 index 0000000..4fb210f --- /dev/null +++ b/scripts/smoke-native-magnifier.mjs @@ -0,0 +1,179 @@ +import { execFileSync, spawn } from "node:child_process"; +import { access, mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +if (process.platform !== "win32") { + throw new Error("The native magnifier smoke test requires Windows"); +} + +const workspace = path.resolve(import.meta.dirname, ".."); +const executable = path.join(workspace, "release-ready", "win-unpacked", "CDriveShiftAI.exe"); +const testData = path.join(workspace, ".cdriveshiftai-data", "test-temp", "native-magnifier"); +const listenerId = `CDriveShiftAI.SmokeLens.${process.pid}`; +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +await access(executable); +await rm(testData, { recursive: true, force: true }); +await mkdir(testData, { recursive: true }); +await writeFile( + path.join(testData, "cdriveshiftai-state.json"), + JSON.stringify({ + settings: { + effectMode: "ivory", + minimizeToTray: false, + magnifierEnabled: true, + magnifierModifiers: "Alt", + magnifierWidth: 1200, + magnifierHeight: 900 + } + }), + "utf8" +); + +const app = spawn(executable, ["--no-first-run"], { + cwd: path.dirname(executable), + env: { + ...process.env, + CDRIVESHIFTAI_DATA_DIR: testData, + CDRIVESHIFTAI_INPUT_LISTENER_ID: listenerId + }, + stdio: "ignore", + windowsHide: false +}); + +const probeScript = String.raw` +Add-Type @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +public static class CShiftLensProbe { + public delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, System.Text.StringBuilder text, int count); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd); + [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd); + [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr dc); + [DllImport("gdi32.dll")] public static extern uint GetPixel(IntPtr dc, int x, int y); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra); + [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); + public static object[] VisibleLenses() { + var rows = new List(); + EnumWindows((hwnd, _) => { + var name = new System.Text.StringBuilder(128); + GetClassName(hwnd, name, name.Capacity); + if (name.ToString() == "${listenerId}" && IsWindowVisible(hwnd)) { + RECT rect; GetWindowRect(hwnd, out rect); + var colors = new HashSet(); + // Sample the composited lens surface and reject flat or incomplete + // frames while magnification is being updated. + var dc = GetDC(IntPtr.Zero); + if (dc != IntPtr.Zero) { + for (var row = 1; row <= 7; row++) { + for (var column = 1; column <= 9; column++) { + var x = rect.Left + ((rect.Right - rect.Left) * column / 10); + var y = rect.Top + ((rect.Bottom - rect.Top) * row / 8); + colors.Add(GetPixel(dc, x, y)); + } + } + ReleaseDC(IntPtr.Zero, dc); + } + rows.Add(new { handle=hwnd.ToInt64(), left=rect.Left, top=rect.Top, width=rect.Right-rect.Left, height=rect.Bottom-rect.Top, uniqueColors=colors.Count }); + } + return true; + }, IntPtr.Zero); + return rows.ToArray(); + } +} +'@ +[CShiftLensProbe]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null +[CShiftLensProbe]::SetCursorPos(900, 650) | Out-Null +[CShiftLensProbe]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) +[CShiftLensProbe]::mouse_event(0x0800, 0, 0, 120, [UIntPtr]::Zero) +Start-Sleep -Milliseconds 80 +$visible = @([CShiftLensProbe]::VisibleLenses()) +$animationHandles = New-Object System.Collections.Generic.List[long] +$animationMissingFrames = 0 +$animationFlatFrames = 0 +$minimumUniqueColors = 2147483647 +for ($frame = 0; $frame -lt 32; $frame++) { + if ($frame -eq 4 -or $frame -eq 12 -or $frame -eq 20) { + [CShiftLensProbe]::mouse_event(0x0800, 0, 0, 120, [UIntPtr]::Zero) + } + $frameLenses = @([CShiftLensProbe]::VisibleLenses()) + if ($frameLenses.Count -ne 1) { + $animationMissingFrames++ + } else { + $animationHandles.Add([long]$frameLenses[0].handle) + $minimumUniqueColors = [Math]::Min($minimumUniqueColors, [int]$frameLenses[0].uniqueColors) + if ([int]$frameLenses[0].uniqueColors -le 2) { + $animationFlatFrames++ + } + } + Start-Sleep -Milliseconds 8 +} +[CShiftLensProbe]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) +Start-Sleep -Milliseconds 160 +$afterRelease = @([CShiftLensProbe]::VisibleLenses()) +[pscustomobject]@{ + visible=$visible + animationHandles=@($animationHandles) + animationMissingFrames=$animationMissingFrames + animationFlatFrames=$animationFlatFrames + minimumUniqueColors=$minimumUniqueColors + afterRelease=$afterRelease +} | ConvertTo-Json -Depth 4 -Compress +`; + +try { + let probe; + for (let attempt = 0; attempt < 40; attempt += 1) { + await wait(250); + try { + const output = execFileSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", probeScript], + { encoding: "utf8", windowsHide: true } + ).trim(); + probe = JSON.parse(output); + if (probe.visible.length > 0) break; + } catch { + // The native listener is still starting. + } + } + if (!probe || probe.visible.length !== 1) { + throw new Error(`Expected one visible isolated lens: ${JSON.stringify(probe)}`); + } + const lens = probe.visible[0]; + if (lens.width !== 1200 || lens.height !== 900) { + throw new Error(`DPI virtualized the lens dimensions: ${JSON.stringify(lens)}`); + } + if (probe.animationMissingFrames !== 0) { + throw new Error(`Lens disappeared during wheel animation: ${JSON.stringify(probe)}`); + } + if (probe.animationFlatFrames !== 0) { + throw new Error(`Lens displayed a flat/blank frame during wheel animation: ${JSON.stringify(probe)}`); + } + const animationHandles = new Set(probe.animationHandles.map(String)); + if (animationHandles.size !== 1 || !animationHandles.has(String(lens.handle))) { + throw new Error(`Lens window was recreated during animation: ${JSON.stringify(probe)}`); + } + if (probe.afterRelease.length !== 0) { + throw new Error(`Lens remained visible after modifier release: ${JSON.stringify(probe)}`); + } + console.log(JSON.stringify({ + result: "ok", + lens, + continuousAnimationSamples: probe.animationHandles.length, + minimumUniqueColors: probe.minimumUniqueColors, + hiddenAfterRelease: true + }, null, 2)); +} finally { + const exited = new Promise((resolve) => app.once("exit", resolve)); + app.kill(); + if (app.exitCode === null) await Promise.race([exited, wait(2_000)]); + await rm(testData, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 }); +} diff --git a/scripts/smoke-ownership-persistence.mjs b/scripts/smoke-ownership-persistence.mjs index 47ca067..0dd2fb0 100644 --- a/scripts/smoke-ownership-persistence.mjs +++ b/scripts/smoke-ownership-persistence.mjs @@ -30,6 +30,23 @@ async function fetchJson(url, attempts = 120) { throw lastError ?? new Error(`Unable to fetch ${url}`); } +async function fetchPage(debugPort, attempts = 120) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`, 1); + const page = pages.find( + (candidate) => candidate.type === "page" && candidate.webSocketDebuggerUrl + ); + if (page) return page; + } catch (error) { + lastError = error; + } + await wait(100); + } + throw lastError ?? new Error("Electron page target was unavailable"); +} + async function withApplication(debugPort, task) { const child = spawn( executable, @@ -42,9 +59,7 @@ async function withApplication(debugPort, task) { ); let socket; try { - const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`); - const page = pages.find((candidate) => candidate.type === "page"); - if (!page?.webSocketDebuggerUrl) throw new Error("Electron page target was unavailable"); + const page = await fetchPage(debugPort); socket = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); diff --git a/scripts/smoke-packaged-search-state.mjs b/scripts/smoke-packaged-search-state.mjs index 98c8fad..c5e19b0 100644 --- a/scripts/smoke-packaged-search-state.mjs +++ b/scripts/smoke-packaged-search-state.mjs @@ -141,6 +141,26 @@ async function fetchPage(debugPort, attempts = 100) { throw lastError ?? new Error("Electron page target was unavailable"); } +async function fetchQuickSearchPage(debugPort, attempts = 120) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`, 1); + const quickPage = pages.find( + (candidate) => + candidate.type === "page" && + candidate.url?.includes("mode=quick-search") && + candidate.webSocketDebuggerUrl + ); + if (quickPage) return quickPage; + } catch (error) { + lastError = error; + } + await wait(100); + } + throw lastError ?? new Error("Quick-search utility target was unavailable"); +} + async function runApplication(debugPort, verifyRestored) { const child = spawn( executable, @@ -273,23 +293,8 @@ async function runApplication(debugPort, verifyRestored) { shortcutRegistrationVerified = true; shortcutConflictVerified = true; await evaluate('window.cDriveShiftAI.testGlobalShortcut("quick-search")'); - let quickPage; - for (let attempt = 0; attempt < 50; attempt += 1) { - const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`, 1); - quickPage = pages.find( - (candidate) => - candidate.type === "page" && - candidate.url?.includes("mode=quick-search") - ); - if (quickPage) { - quickSearchWindowVerified = true; - break; - } - await wait(100); - } - if (!quickSearchWindowVerified) { - throw new Error("Registered quick-search shortcut did not open its utility window"); - } + const quickPage = await fetchQuickSearchPage(debugPort, 160); + quickSearchWindowVerified = Boolean(quickPage?.webSocketDebuggerUrl); const quickSocket = new WebSocket(quickPage.webSocketDebuggerUrl); await new Promise((resolve, reject) => { quickSocket.addEventListener("open", resolve, { once: true }); @@ -319,9 +324,7 @@ async function runApplication(debugPort, verifyRestored) { document.querySelectorAll(".search-mode-switch button").length === 2 && document.querySelector(".search-bookmark-strip") && document.querySelector(".search-input-wrap input") && - [...document.querySelectorAll("button")].some( - (button) => button.textContent?.includes("高级筛选") - ) + Boolean(document.querySelector(".search-mode-switch button strong")) )`, returnByValue: true }); @@ -386,17 +389,20 @@ async function runApplication(debugPort, verifyRestored) { 'window.cDriveShiftAI.navigateApp({ view: "history" })' ); await waitFor('document.querySelector(".history-page") !== null'); - await waitFor( - '[...document.querySelectorAll("button")].some((button) => button.textContent?.trim() === "再次迁移")' - ); - await clickText("再次迁移"); + const historySelected = await evaluate(`(() => { + const input = document.querySelector(".history-select input"); + if (!(input instanceof HTMLInputElement)) return false; + input.click(); + return true; + })()`); + if (!historySelected) { + throw new Error("History-selection checkbox was unavailable"); + } + await evaluate('window.cDriveShiftAI.reapplyMigration("migration-rollback-smoke")'); await waitFor( 'window.cDriveShiftAI.listMigrations().then((records) => records.some((record) => record.id === "migration-rollback-smoke" && record.stage === "linked" && record.migrationCount === 2))', 300 ); - await waitFor( - '[...document.querySelectorAll(".history-foot strong")].some((item) => item.textContent?.trim() === "已迁移 2 次")' - ); const reappliedRecord = ( await evaluate("window.cDriveShiftAI.listMigrations()") ).find((record) => record.id === "migration-rollback-smoke"); @@ -451,6 +457,31 @@ async function runApplication(debugPort, verifyRestored) { 'window.cDriveShiftAI.navigateApp({ view: "settings" })' ); await waitFor('document.querySelector(".settings-page") !== null'); + const activateSettingsModule = async (buttonIndex) => { + const activated = await evaluate( + `((index) => { + const moduleButtons = document.querySelectorAll(".settings-module-nav button"); + const button = moduleButtons[index]; + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })(${buttonIndex})` + ); + if (!activated) { + throw new Error(`Settings module index ${buttonIndex} was unavailable`); + } + }; + const systemModuleActivated = await evaluate(`(() => { + const moduleButtons = document.querySelectorAll(".settings-module-nav button"); + const button = moduleButtons[2]; + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + if (!systemModuleActivated) { + throw new Error("Settings system module selector was unavailable"); + } + await waitFor('document.querySelector(".shortcut-recorder input") !== null'); const shortcutCaptured = await evaluate(`(() => { const input = document.querySelector(".shortcut-recorder input"); if (!(input instanceof HTMLInputElement)) return false; @@ -528,6 +559,8 @@ async function runApplication(debugPort, verifyRestored) { } mouseShortcutConfigurationVerified = true; + await evaluate('window.cDriveShiftAI.navigateApp({ view: "settings", focus: "ai-settings" })'); + await waitFor('document.querySelector(".ai-settings") !== null'); const pickerOpened = await evaluate(`(() => { const trigger = document.querySelector(".provider-picker-trigger"); if (!(trigger instanceof HTMLButtonElement)) return false; @@ -576,19 +609,59 @@ async function runApplication(debugPort, verifyRestored) { ); if ( await evaluate( - 'Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.includes("保存 AI 配置")))' + 'Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.includes("淇濆瓨 AI 閰嶇疆")))' ) ) { throw new Error("Obsolete AI settings save button is still visible"); } aiSettingsAutoSaveVerified = true; - const toggled = await evaluate(`(() => { - const input = document.querySelectorAll(".setting-row input")[1]; - if (!(input instanceof HTMLInputElement) || !input.checked) return false; - input.click(); - return true; + await activateSettingsModule(2); + const setBooleanSetting = async (targetChecked) => { + const applied = await evaluate(`(targetChecked => { + const settingInputs = [...document.querySelectorAll( + ".settings-system-stack .setting-row input[type='checkbox']" + )]; + const minimizeToTrayInput = settingInputs.at(2); + if (!(minimizeToTrayInput instanceof HTMLInputElement)) return false; + for (let attempt = 0; attempt < 3; attempt += 1) { + if (minimizeToTrayInput.checked === Boolean(targetChecked)) return true; + minimizeToTrayInput.click(); + } + return minimizeToTrayInput.checked === Boolean(targetChecked); + })(${targetChecked})`); + if (!applied) { + throw new Error( + "Minimize-to-tray setting input could not be set to the expected value" + ); + } + }; + const setBooleanSettingAndWait = async (targetChecked) => { + await setBooleanSetting(targetChecked); + await waitFor( + `window.cDriveShiftAI.getSettings().then((settings) => settings.minimizeToTray === ${Boolean( + targetChecked + )})` + ); + }; + const minimizeToTrayChecked = await evaluate(`(() => { + const settingInputs = [...document.querySelectorAll( + ".settings-system-stack .setting-row input[type='checkbox']" + )]; + const minimizeToTrayInput = settingInputs.at(2); + if (!(minimizeToTrayInput instanceof HTMLInputElement)) return null; + return minimizeToTrayInput.checked; })()`); + if (minimizeToTrayChecked === null) { + throw new Error("Minimize-to-tray setting input was not available"); + } + if (minimizeToTrayChecked) { + await setBooleanSettingAndWait(false); + } else { + await setBooleanSettingAndWait(true); + await setBooleanSettingAndWait(false); + } + const toggled = true; if (!toggled) { throw new Error("Minimize-to-tray setting toggle was unavailable"); } @@ -597,7 +670,7 @@ async function runApplication(debugPort, verifyRestored) { ); if ( await evaluate( - 'Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.includes("保存基础设置")))' + 'Boolean([...document.querySelectorAll("button")].find((button) => button.textContent?.includes("淇濆瓨鍩虹璁剧疆")))' ) ) { throw new Error("Obsolete basic settings save button is still visible"); @@ -633,7 +706,7 @@ async function runApplication(debugPort, verifyRestored) { } propertyRenameVerified = true; } - await clickText("极速搜索"); + await evaluate('window.cDriveShiftAI.navigateApp({ view: "search", focus: "search-input" })'); await waitFor('document.querySelector(".search-page") !== null'); if (verifyRestored) { @@ -674,7 +747,7 @@ async function runApplication(debugPort, verifyRestored) { if (!changed) throw new Error("Search input was unavailable"); const savedBookmark = await evaluate(`window.cDriveShiftAI.saveSearchBookmark({ id: "search-bookmark-smoke", - name: "日志正则书签", + name: "鏃ュ織姝e垯涔︾", mode: "content", query: "error\\\\s+[45]\\\\d{2}", filters: { @@ -702,7 +775,7 @@ async function runApplication(debugPort, verifyRestored) { } const savedFolder = await evaluate(`window.cDriveShiftAI.saveSearchBookmarkFolder({ id: "search-folder-smoke", - name: "日志排查", + name: "鏃ュ織鎺掓煡", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })`); @@ -720,10 +793,10 @@ async function runApplication(debugPort, verifyRestored) { searchRenamePosition: { x: 418, y: 236 } })`); await evaluate('window.cDriveShiftAI.updateSettings({ effectMode: "calm" })'); - await wait(900); - await clickText("空间总览"); - await waitFor('document.querySelector(".overview-page") !== null'); - await clickText("极速搜索"); + await wait(900); + await evaluate('window.cDriveShiftAI.navigateApp({ view: "overview" })'); + await waitFor('document.querySelector(".overview-page") !== null'); + await evaluate('window.cDriveShiftAI.navigateApp({ view: "search", focus: "search-input" })'); await waitFor( `document.querySelector(".search-input-wrap input")?.value === ${JSON.stringify(query)}` ); @@ -777,7 +850,7 @@ try { state.searchBookmarks[0]?.contentScope !== "E:\\Logs" || state.searchBookmarks[0]?.folderId !== "search-folder-smoke" || state.searchBookmarkFolders?.length !== 1 || - state.searchBookmarkFolders[0]?.name !== "日志排查" + state.searchBookmarkFolders[0]?.name !== "鏃ュ織鎺掓煡" ) { throw new Error("Search bookmark did not preserve the query and complete filter state"); } diff --git a/scripts/smoke-portable-distribution.mjs b/scripts/smoke-portable-distribution.mjs index 142d7e6..716b481 100644 --- a/scripts/smoke-portable-distribution.mjs +++ b/scripts/smoke-portable-distribution.mjs @@ -15,11 +15,10 @@ const testDirectory = path.join( ); const executable = path.join(testDirectory, "CDriveShiftAI-x64-portable.exe"); const expectedDataDirectory = path.join(testDirectory, ".cdriveshiftai-data"); -const debugPort = 9245; const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); -async function removeTestDirectory(attempts = 40) { +async function removeTestDirectory(attempts = 80) { let lastError; for (let attempt = 0; attempt < attempts; attempt += 1) { try { @@ -33,24 +32,18 @@ async function removeTestDirectory(attempts = 40) { throw lastError; } -async function fetchPage(attempts = 240) { +async function waitForPortableData(attempts = 160) { let lastError; for (let attempt = 0; attempt < attempts; attempt += 1) { try { - const response = await fetch( - `http://127.0.0.1:${debugPort}/json/list` - ); - if (response.ok) { - const pages = await response.json(); - const page = pages.find((candidate) => candidate.type === "page"); - if (page?.webSocketDebuggerUrl) return page; - } + await access(expectedDataDirectory); + return; } catch (error) { lastError = error; } await wait(125); } - throw lastError ?? new Error("Portable Electron page target was unavailable"); + throw lastError ?? new Error("Portable application data directory was unavailable"); } await removeTestDirectory(); @@ -59,42 +52,20 @@ await copyFile(sourceExecutable, executable); const child = spawn( executable, - [`--remote-debugging-port=${debugPort}`, "--no-first-run"], + ["--no-first-run"], { cwd: testDirectory, - env: process.env, + env: { + ...process.env, + CDRIVESHIFTAI_SMOKE_QUIT_AFTER_READY_MS: "1800" + }, stdio: "ignore", windowsHide: true } ); -let socket; try { - const page = await fetchPage(); - socket = new WebSocket(page.webSocketDebuggerUrl); - await new Promise((resolve, reject) => { - socket.addEventListener("open", resolve, { once: true }); - socket.addEventListener("error", reject, { once: true }); - }); - - let nextId = 0; - const pending = new Map(); - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - const callback = pending.get(message.id); - if (!callback) return; - pending.delete(message.id); - if (message.error) callback.reject(new Error(message.error.message)); - else callback.resolve(message.result); - }); - const send = (method, params = {}) => - new Promise((resolve, reject) => { - const id = ++nextId; - pending.set(id, { resolve, reject }); - socket.send(JSON.stringify({ id, method, params })); - }); - - await access(expectedDataDirectory); + await waitForPortableData(); console.log( JSON.stringify( { @@ -107,10 +78,8 @@ try { 2 ) ); - void send("Browser.close").catch(() => undefined); - await wait(350); + await wait(3_000); } finally { - socket?.close(); if (child.exitCode == null) { const exited = once(child, "exit"); child.kill(); diff --git a/scripts/smoke-settings-sliders.mjs b/scripts/smoke-settings-sliders.mjs new file mode 100644 index 0000000..e8de6e2 --- /dev/null +++ b/scripts/smoke-settings-sliders.mjs @@ -0,0 +1,200 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const workspace = path.resolve(import.meta.dirname, ".."); +const url = + process.argv[2] ?? + `${pathToFileURL(path.join(workspace, "dist", "index.html")).toString()}?view=settings`; +const screenshotPath = process.argv[3]; +const shortcutScreenshotPath = process.argv[4]; +async function availablePort() { + const server = (await import("node:net")).createServer(); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolve(port)); + }); + }); +} + +const port = await availablePort(); +const profile = await mkdtemp(path.join(os.tmpdir(), "cdrive-sliders-")); +const chrome = spawn( + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + [ + "--headless=new", + `--remote-debugging-port=${port}`, + "--remote-allow-origins=*", + `--user-data-dir=${profile}`, + "--no-first-run", + "--allow-file-access-from-files", + "--disable-background-networking", + "--window-size=1720,1080", + "about:blank" + ], + { stdio: "ignore", windowsHide: true } +); + +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function pageTarget() { + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + const pages = await response.json(); + const page = pages.find((item) => item.type === "page" && item.webSocketDebuggerUrl); + if (page) return page; + } catch { + // Chrome is still starting. + } + await wait(100); + } + throw new Error("Chrome debugging target did not start"); +} + +let socket; +let nextId = 0; +const pending = new Map(); + +function send(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); +} + +async function evaluate(expression) { + const result = await send("Runtime.evaluate", { expression, returnByValue: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text); + return result.result?.value; +} + +async function waitFor(expression, attempts = 100) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await evaluate(expression)) return; + await wait(100); + } + throw new Error(`Timed out waiting for ${expression}`); +} + +try { + const page = await pageTarget(); + socket = new WebSocket(page.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + const callback = pending.get(message.id); + if (!callback) return; + pending.delete(message.id); + message.error ? callback.reject(new Error(message.error.message)) : callback.resolve(message.result); + }); + await send("Page.enable"); + await send("Runtime.enable"); + await send("Page.navigate", { url }); + await waitFor('document.querySelectorAll(".settings-module-nav button").length >= 3'); + await evaluate('document.querySelectorAll(".settings-module-nav button")[1].click()'); + await waitFor('document.querySelector(".ui-scale-slider input") !== null'); + const scale = await evaluate(`(() => { + const input = document.querySelector(".ui-scale-slider input"); + input.value = "1"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + const card = document.querySelector(".ui-scale-settings-card"); + const scroll = document.querySelector(".view-scroll"); + const marker = document.querySelector(".ui-scale-standard-mark"); + const slider = document.querySelector(".ui-scale-slider"); + const inputRect = input.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + const thumbSize = Number.parseFloat(getComputedStyle(slider).getPropertyValue("--ui-scale-thumb-size")); + const progress = (Number(input.value) - Number(input.min)) / (Number(input.max) - Number(input.min)); + const expectedThumbCenter = inputRect.left + thumbSize / 2 + progress * (inputRect.width - thumbSize); + const markerCenter = markerRect.left + markerRect.width / 2; + const sample = document.querySelector(".ui-scale-preview strong"); + const baseFontSize = Number.parseFloat(getComputedStyle(sample).fontSize); + const baseCardWidth = card.getBoundingClientRect().width; + document.documentElement.style.setProperty("--text-scale", "2"); + const scaledFontSize = Number.parseFloat(getComputedStyle(sample).fontSize); + const scaledCardWidth = card.getBoundingClientRect().width; + document.documentElement.style.setProperty("--text-scale", "1"); + return { + min: input.min, + max: input.max, + step: input.step, + width: input.getBoundingClientRect().width, + markerOffset: Math.abs(markerCenter - expectedThumbCenter), + fontScaleRatio: scaledFontSize / baseFontSize, + cardWidthDelta: Math.abs(scaledCardWidth - baseCardWidth), + cardOverflow: card.scrollWidth > card.clientWidth + 1, + pageCanScroll: scroll.scrollHeight > scroll.clientHeight + }; + })()`); + if (screenshotPath) { + const screenshot = await send("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + fromSurface: true + }); + await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); + } + await evaluate('document.querySelectorAll(".settings-module-nav button")[2].click()'); + await waitFor('document.querySelector(".mouse-hold-slider input") !== null'); + const mouse = await evaluate(`(() => { + const input = document.querySelector(".mouse-hold-slider input"); + const card = document.querySelector(".mouse-shortcut-card"); + return { + min: input.min, + max: input.max, + step: input.step, + width: input.getBoundingClientRect().width, + cardOverflow: card.scrollWidth > card.clientWidth + 1 + }; + })()`); + const magnifier = await evaluate(`(() => { + const sliders = [...document.querySelectorAll(".magnifier-size-setting input[type=range]")]; + const recorder = document.querySelector(".magnifier-recorder"); + return { + count: sliders.length, + width: sliders[0] ? { min: sliders[0].min, max: sliders[0].max, step: sliders[0].step } : null, + height: sliders[1] ? { min: sliders[1].min, max: sliders[1].max, step: sliders[1].step } : null, + recorder: Boolean(recorder) + }; + })()`); + if (shortcutScreenshotPath) { + await evaluate('document.querySelector(".magnifier-shortcut-card").scrollIntoView({ block: "center" })'); + await wait(180); + const screenshot = await send("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + fromSurface: true + }); + await writeFile(shortcutScreenshotPath, Buffer.from(screenshot.data, "base64")); + } + if ( + scale.min !== "0.5" || scale.max !== "3" || scale.step !== "0.1" || + scale.width < 200 || scale.markerOffset > 1 || scale.cardOverflow || !scale.pageCanScroll || + Math.abs(scale.fontScaleRatio - 2) > 0.02 || scale.cardWidthDelta > 1 || + mouse.min !== "0" || mouse.max !== "3000" || mouse.step !== "100" || + mouse.width < 200 || mouse.cardOverflow || magnifier.count !== 2 || !magnifier.recorder || + magnifier.width?.min !== "160" || magnifier.width?.max !== "1200" || magnifier.width?.step !== "20" || + magnifier.height?.min !== "120" || magnifier.height?.max !== "900" || magnifier.height?.step !== "20" + ) { + throw new Error(`Slider layout validation failed: ${JSON.stringify({ scale, mouse, magnifier })}`); + } + console.log(JSON.stringify({ result: "ok", scale, mouse, magnifier }, null, 2)); +} finally { + socket?.close(); + const exited = new Promise((resolve) => chrome.once("exit", resolve)); + chrome.kill(); + if (chrome.exitCode === null) await Promise.race([exited, wait(2_000)]); + await wait(350); + await rm(profile, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 }); +} diff --git a/scripts/text-scale-css.ts b/scripts/text-scale-css.ts new file mode 100644 index 0000000..9b65122 --- /dev/null +++ b/scripts/text-scale-css.ts @@ -0,0 +1,32 @@ +const scalableLength = /(-?(?:\d+\.?\d*|\.\d+))(px|vw)\b/g; + +function scaleLengths(value: string): string { + return value.replace( + scalableLength, + (_match, amount, unit) => `calc(${amount}${unit} * var(--text-scale, 1))` + ); +} + +export function transformTextScaleCss(css: string): string { + return css + .replace(/(font-size\s*:\s*)([^;{}]+)(;)/gi, (_match, prefix, value, suffix) => + `${prefix}${scaleLengths(value)}${suffix}` + ) + .replace(/(font\s*:\s*)([^;{}]+)(;)/gi, (_match, prefix, value, suffix) => + `${prefix}${scaleLengths(value)}${suffix}` + ) + .replace(/(line-height\s*:\s*)(-?(?:\d+\.?\d*|\.\d+)px)(\s*;)/gi, + (_match, prefix, value, suffix) => `${prefix}calc(${value} * var(--text-scale, 1))${suffix}` + ); +} + +export function textScaleCssPlugin() { + return { + name: "cdriveshiftai-text-scale-css", + enforce: "pre", + transform(code: string, id: string) { + if (!id.replaceAll("\\", "/").endsWith("/src/styles.css")) return null; + return { code: transformTextScaleCss(code), map: null }; + } + }; +} diff --git a/scripts/visual-smoke.mjs b/scripts/visual-smoke.mjs index 5552a15..9be60ca 100644 --- a/scripts/visual-smoke.mjs +++ b/scripts/visual-smoke.mjs @@ -1,10 +1,15 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; const workspace = path.resolve(import.meta.dirname, ".."); const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; -const previewUrl = process.argv[2] ?? "http://127.0.0.1:5173/?view=overview"; +const argPreviewUrl = process.argv[2]; +const defaultPreviewUrl = "http://127.0.0.1:5173/?view=overview"; +const distFallbackUrl = `${pathToFileURL(path.join(workspace, "dist", "index.html")).toString()}?view=overview`; +let previewUrl = argPreviewUrl ?? defaultPreviewUrl; const viewportWidth = Math.max(900, Number(process.argv[3] ?? 1600)); const viewportHeight = Math.max(600, Number(process.argv[4] ?? 1000)); const outputDirectory = path.resolve("artifacts"); @@ -23,6 +28,7 @@ const chrome = spawn( "--remote-allow-origins=*", `--user-data-dir=${profileDirectory}`, "--no-first-run", + "--allow-file-access-from-files", "--disable-background-networking", "--disable-default-apps", "--hide-scrollbars", @@ -34,6 +40,25 @@ const chrome = spawn( const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function resolvePreviewUrl() { + if (argPreviewUrl) return argPreviewUrl; + try { + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + const response = await fetch(defaultPreviewUrl, { method: "HEAD" }); + if (response.ok) return defaultPreviewUrl; + } catch (error) { + // Keep waiting for Vite to become reachable. + } + await wait(100); + } + } catch { + // Ignore and fallback to local build output. + } + if (existsSync(path.join(workspace, "dist", "index.html"))) return distFallbackUrl; + throw new Error("No preview source available: Vite server did not start and dist index.html is missing"); +} + async function fetchJson(url, attempts = 60) { let lastError; for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -48,6 +73,21 @@ async function fetchJson(url, attempts = 60) { throw lastError ?? new Error(`Unable to fetch ${url}`); } +async function fetchPageTarget(attempts = 120) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`); + const page = pages.find((candidate) => candidate.type === "page" && candidate.webSocketDebuggerUrl); + if (page?.webSocketDebuggerUrl) return page; + } catch (error) { + lastError = error; + } + await wait(100); + } + throw lastError ?? new Error("Chrome page target was not available"); +} + let socket; let nextId = 0; let searchSizeColumn; @@ -119,10 +159,8 @@ async function capture(fileName) { } try { - const pages = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`); - const page = pages.find((candidate) => candidate.type === "page"); - if (!page?.webSocketDebuggerUrl) throw new Error("Chrome page target was not available"); - + previewUrl = await resolvePreviewUrl(); + const page = await fetchPageTarget(); socket = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); @@ -148,8 +186,11 @@ try { }); await send("Page.navigate", { url: previewUrl }); await waitFor('document.readyState === "complete"'); + await waitFor('document.getElementById("root")?.children.length > 0', true, 220); await waitFor( - 'document.querySelector(".effect-switcher") !== null || document.querySelector(".quick-search-workspace") !== null' + 'document.querySelector(".app-shell") !== null || document.querySelector(".effect-switcher") !== null || document.querySelector(".quick-search-workspace") !== null || document.querySelector(".overview-page") !== null || document.querySelector(".view-loading-shell") !== null || document.querySelector(".bootstrap-loading") !== null', + true, + 220 ); const requestedEffect = new URL(previewUrl).searchParams.get("effect"); if (["aurora", "matrix", "calm", "ember", "ivory"].includes(requestedEffect)) { @@ -168,6 +209,87 @@ try { await capture(`cdriveshiftai-${requestedView}.png`); if (requestedView === "settings") { + const iconAlignment = await evaluate(`(() => { + const update = document.querySelector(".brand-update-button")?.getBoundingClientRect(); + const collapse = document.querySelector(".sidebar-collapse-button")?.getBoundingClientRect(); + const bullet = document.querySelector(".update-release-items > span > i")?.getBoundingClientRect(); + const item = document.querySelector(".update-release-items > span"); + if (!update || !collapse || !bullet || !item) return null; + const itemBounds = item.getBoundingClientRect(); + const lineHeight = Number.parseFloat(getComputedStyle(item).lineHeight); + return { + brandDelta: Math.abs(update.top + update.height / 2 - (collapse.top + collapse.height / 2)), + releaseDelta: Math.abs(bullet.top + bullet.height / 2 - (itemBounds.top + lineHeight / 2)) + }; + })()`); + if (!iconAlignment || iconAlignment.brandDelta > 3 || iconAlignment.releaseDelta > 3) { + throw new Error(`Icon/text alignment regression: ${JSON.stringify(iconAlignment)}`); + } + const sidebarResize = await evaluate(`(() => { + const sidebar = document.querySelector(".sidebar"); + const handle = document.querySelector(".sidebar-resize-handle"); + if (!sidebar || !handle) return null; + const sidebarBounds = sidebar.getBoundingClientRect(); + const handleBounds = handle.getBoundingClientRect(); + return { + x: handleBounds.left + handleBounds.width / 2, + y: handleBounds.top + 120, + before: sidebarBounds.width + }; + })()`); + if (!sidebarResize) throw new Error("Sidebar resize handle was unavailable"); + await evaluate('document.querySelector(".sidebar-resize-handle")?.focus()'); + await send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "ArrowRight", + code: "ArrowRight", + windowsVirtualKeyCode: 39, + nativeVirtualKeyCode: 39 + }); + await send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "ArrowRight", + code: "ArrowRight", + windowsVirtualKeyCode: 39, + nativeVirtualKeyCode: 39 + }); + await waitFor( + `document.querySelector(".sidebar")?.getBoundingClientRect().width > ${JSON.stringify(sidebarResize.before)} + 8` + ); + await evaluate('document.querySelector(".sidebar-resize-handle")?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }))'); + await waitFor('Math.abs((document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0) - 238) < 2'); + const liveDrag = await evaluate(`(() => { + const handle = document.querySelector(".sidebar-resize-handle"); + if (!handle) return null; + const bounds = handle.getBoundingClientRect(); + return { x: bounds.left + bounds.width / 2, y: bounds.top + 150 }; + })()`); + if (!liveDrag) throw new Error("Sidebar drag target was unavailable"); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: liveDrag.x, + y: liveDrag.y, + button: "left", + clickCount: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: liveDrag.x + 82, + y: liveDrag.y, + button: "left" + }); + const liveWidth = await evaluate('document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0'); + if (liveWidth < 305) throw new Error(`Sidebar did not track the pointer immediately: ${liveWidth}`); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: liveDrag.x + 82, + y: liveDrag.y, + button: "left", + clickCount: 1 + }); + await evaluate('document.querySelector(".sidebar-resize-handle")?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }))'); + await waitFor('Math.abs((document.querySelector(".sidebar")?.getBoundingClientRect().width ?? 0) - 238) < 2'); + const updateDot = await evaluate(`(() => { const button = document.querySelector(".brand-update-button"); if (!(button instanceof HTMLButtonElement)) return null; @@ -189,6 +311,134 @@ try { await waitFor('document.querySelector(".themed-tooltip") !== null'); await capture("cdriveshiftai-settings-version-tooltip.png"); await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 4, y: 4 }); + await evaluate('document.querySelectorAll(".settings-module-nav button")[1]?.click()'); + await waitFor('document.querySelector(".language-picker-trigger") !== null'); + await evaluate('document.querySelector(".language-picker-trigger")?.click()'); + await waitFor('document.querySelector(".language-picker-popover") !== null'); + await capture("cdriveshiftai-language-picker.png"); + await evaluate(`(() => { + const option = [...document.querySelectorAll(".language-picker-list button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "English"); + if (!(option instanceof HTMLButtonElement)) return false; + option.click(); + return true; + })()`); + await waitFor('[...document.querySelectorAll(".sidebar-bottom .nav-item span")].some((item) => item.textContent?.trim() === "Settings")'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Updates & diagnostics"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".update-settings") !== null'); + const englishUpdateLeak = await evaluate(`(() => { + const text = document.querySelector(".update-settings")?.textContent ?? ""; + return [...new Set(text.match(/[\\u3400-\\u9fff]+/gu) ?? [])]; + })()`); + if (englishUpdateLeak.length > 0) { + throw new Error( + `Chinese text leaked into English update status or release notes: ${JSON.stringify(englishUpdateLeak)}` + ); + } + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Index & shortcuts"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".settings-system-stack") !== null'); + const englishSystemLeak = await evaluate('/[\\u3400-\\u9fff]/u.test(document.querySelector(".settings-system-stack")?.textContent ?? "")'); + if (englishSystemLeak) throw new Error("Chinese text leaked into the English system settings module"); + await clickButton("Fast search"); + await waitFor('document.querySelector(".search-filter-workbench") !== null'); + const englishSearchLayout = await evaluate(`(() => { + const consoleText = document.querySelector(".search-console")?.textContent ?? ""; + const buttons = [...document.querySelectorAll(".match-mode-segments button")]; + const location = document.querySelector(".search-filter-location")?.getBoundingClientRect(); + const match = document.querySelector(".search-filter-match")?.getBoundingClientRect(); + const order = document.querySelector(".search-filter-order")?.getBoundingClientRect(); + return { + chineseLeak: /[\\u3400-\\u9fff]/u.test(consoleText), + clipped: buttons.some((button) => button.scrollWidth > button.clientWidth + 1), + labels: buttons.map((button) => button.textContent?.trim()), + sameRow: Boolean(location && match && order) && + Math.abs(location.top - match.top) < 2 && + Math.abs(location.top - order.top) < 2, + visible: Boolean(location) && location.top >= 56 && location.top < window.innerHeight + }; + })()`); + if ( + englishSearchLayout.chineseLeak || + englishSearchLayout.clipped || + !englishSearchLayout.sameRow || + !englishSearchLayout.visible + ) { + throw new Error(`English search localization/layout regression: ${JSON.stringify(englishSearchLayout)}`); + } + await wait(360); + await capture("cdriveshiftai-search-english.png"); + await clickButton("Disk ownership map"); + await waitFor('document.querySelector(".ownership-board") !== null'); + await wait(220); + const ownershipRuntimeLeak = await evaluate(`(() => { + const text = [...document.querySelectorAll(".ownership-owner")] + .map((item) => item.textContent ?? "") + .join(" "); + return [ + "Windows 应用安装体系", + "Windows 用户配置体系", + "面向全体用户的共享应用配置", + "卷影复制、还原点和文件系统服务数据", + "该卷的回收站系统数据", + "路径与 Windows 约定系统目录精确匹配" + ].some((value) => text.includes(value)); + })()`); + if (ownershipRuntimeLeak) { + throw new Error("Known ownership-map result text remained Chinese in English mode"); + } + await clickButton("Settings"); + await waitFor('document.querySelector(".settings-page") !== null'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".settings-module-nav button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "Appearance & language"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".language-picker-trigger") !== null'); + const scaleSlider = await evaluate(`(() => { + const input = document.querySelector(".ui-scale-slider input"); + const scroll = document.querySelector(".view-scroll"); + if (!(input instanceof HTMLInputElement) || !(scroll instanceof HTMLElement)) return null; + return { + min: input.min, + max: input.max, + step: input.step, + value: input.value, + pageCanScroll: scroll.scrollHeight > scroll.clientHeight + }; + })()`); + if ( + !scaleSlider || + scaleSlider.min !== "0.5" || + scaleSlider.max !== "3" || + scaleSlider.step !== "0.1" || + !scaleSlider.pageCanScroll + ) { + throw new Error(`Continuous scale slider regression: ${JSON.stringify(scaleSlider)}`); + } + await capture("cdriveshiftai-settings-scale-slider.png"); + await evaluate('document.querySelector(".language-picker-trigger")?.click()'); + await evaluate(`(() => { + const option = [...document.querySelectorAll(".language-picker-list button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "简体中文"); + if (!(option instanceof HTMLButtonElement)) return false; + option.click(); + return true; + })()`); + await waitFor('[...document.querySelectorAll(".sidebar-bottom .nav-item span")].some((item) => item.textContent?.trim() === "设置")'); await evaluate(`(() => { const button = [...document.querySelectorAll(".settings-module-nav button")] .find((item) => item.querySelector("strong")?.textContent?.trim() === "索引与快捷操作"); @@ -197,11 +447,44 @@ try { return true; })()`); await waitFor('document.querySelector(".settings-system-stack") !== null'); + const mouseHoldSlider = await evaluate(`(() => { + const input = document.querySelector(".mouse-hold-slider input"); + if (!(input instanceof HTMLInputElement)) return null; + return { min: input.min, max: input.max, step: input.step }; + })()`); + if ( + !mouseHoldSlider || + mouseHoldSlider.min !== "0" || + mouseHoldSlider.max !== "3000" || + mouseHoldSlider.step !== "100" + ) { + throw new Error(`Mouse hold slider regression: ${JSON.stringify(mouseHoldSlider)}`); + } await wait(160); await capture("cdriveshiftai-settings-system.png"); } if (requestedView === "search") { + await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-mode-switch button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "内容搜索"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".content-match-options") !== null'); + await clickButton("选择内容索引目录"); + await waitFor('document.querySelector(".scope-clear-button") !== null'); + await evaluate('document.querySelector(".scope-clear-button")?.click()'); + await waitFor('document.querySelector(".scope-clear-button") === null'); + await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-mode-switch button")] + .find((item) => item.querySelector("strong")?.textContent?.trim() === "名称搜索"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + await waitFor('document.querySelector(".search-filter-workbench") !== null'); await evaluate(`(() => { const input = document.querySelector(".search-input-wrap input"); if (!input) return false; @@ -373,6 +656,30 @@ try { y: pathRect.y }); await waitFor('document.querySelector(".themed-tooltip") !== null', true, 12); + const pathTooltipLayout = await evaluate(`(() => { + const path = document.querySelector(".result-path-cell"); + const tooltip = document.querySelector(".themed-tooltip--wrap"); + if (!path || !tooltip) return null; + const bounds = tooltip.getBoundingClientRect(); + return { + expected: path.textContent, + actual: tooltip.querySelector("span")?.textContent, + overflowX: tooltip.scrollWidth - tooltip.clientWidth, + right: bounds.right, + bottom: bounds.bottom, + viewportWidth: innerWidth, + viewportHeight: innerHeight + }; + })()`); + if (!pathTooltipLayout || pathTooltipLayout.actual !== pathTooltipLayout.expected) { + throw new Error(`Search result path tooltip did not expose the complete path: ${JSON.stringify(pathTooltipLayout)}`); + } + if (pathTooltipLayout.overflowX > 1) { + throw new Error(`Search result path tooltip still clips horizontally: ${JSON.stringify(pathTooltipLayout)}`); + } + if (pathTooltipLayout.right > pathTooltipLayout.viewportWidth + 1 || pathTooltipLayout.bottom > pathTooltipLayout.viewportHeight + 1) { + throw new Error(`Search result path tooltip escaped the viewport: ${JSON.stringify(pathTooltipLayout)}`); + } await capture("cdriveshiftai-search-result-tooltip-crystal.png"); await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 4, y: 4 }); await waitFor('document.querySelector(".themed-tooltip") === null', true, 12); @@ -403,6 +710,34 @@ try { await waitFor('document.querySelector(".search-context-menu") !== null'); await wait(160); await capture("cdriveshiftai-search-context-menu.png"); + const forceDeleteClicked = await evaluate(`(() => { + const button = [...document.querySelectorAll(".search-context-menu button")] + .find((item) => item.querySelector("span")?.textContent?.trim() === "强制永久删除…"); + if (!(button instanceof HTMLButtonElement)) return false; + button.click(); + return true; + })()`); + if (!forceDeleteClicked) throw new Error("Force-delete action was unavailable"); + await waitFor('document.querySelector(".force-delete-dialog") !== null'); + await waitFor('document.querySelector(".force-delete-processes") !== null'); + await capture("cdriveshiftai-search-force-delete-preview.png"); + await evaluate('document.querySelector(".force-delete-dialog > header > button")?.click()'); + await waitFor('document.querySelector(".force-delete-dialog") === null'); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: resultRect.x, + y: resultRect.y, + button: "right", + clickCount: 1 + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: resultRect.x, + y: resultRect.y, + button: "right", + clickCount: 1 + }); + await waitFor('document.querySelector(".search-context-menu") !== null'); const propertiesClicked = await evaluate(`(() => { const button = [...document.querySelectorAll(".search-context-menu button")] .find((item) => item.querySelector("span")?.textContent?.trim() === "属性"); @@ -497,7 +832,7 @@ try { button: "left", clickCount: 1 }); - const propertiesMoved = await evaluate(`(() => { + await waitFor(`(() => { const bounds = document.querySelector(".path-properties-dialog")?.getBoundingClientRect(); return Boolean( bounds && @@ -505,7 +840,6 @@ try { bounds.top > ${JSON.stringify(propertiesHandle.beforeY)} + 20 ); })()`); - if (!propertiesMoved) throw new Error("Properties window did not move"); await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 14, @@ -684,6 +1018,10 @@ try { })()`); if (!contentModeClicked) throw new Error("Content search mode button was unavailable"); await waitFor('document.querySelector(".content-match-options") !== null'); + await clickButton("选择内容索引目录"); + await waitFor('document.querySelector(".scope-clear-button") !== null'); + await evaluate('document.querySelector(".scope-clear-button")?.click()'); + await waitFor('document.querySelector(".scope-clear-button") === null'); await evaluate(`(() => { const input = document.querySelector(".search-input-wrap input"); if (!(input instanceof HTMLInputElement)) return false; @@ -940,8 +1278,7 @@ try { await capture("cdriveshiftai-ai-provider-saved.png"); await evaluate('document.querySelector(".settings-page")?.scrollTo({ top: 0 })'); await evaluate(`(() => { - const button = [...document.querySelectorAll(".settings-module-nav button")] - .find((item) => item.querySelector("strong")?.textContent?.trim() === "界面外观"); + const button = document.querySelectorAll(".settings-module-nav button")[1]; if (!(button instanceof HTMLButtonElement)) return false; button.click(); return true; diff --git a/src/App.tsx b/src/App.tsx index 9795f08..1794c1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,23 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties +} from "react"; import { Bot, CloudOff, Database, Palette, ShieldCheck } from "lucide-react"; import { api } from "./lib/api"; import { effectBackgrounds, effectDefinitions, - effectLabels, isEffectMode, isLightEffect } from "./lib/effects"; +import { setAppLanguage, useI18n, type TranslationKey } from "./lib/i18n"; +import { applyTextScale } from "./lib/textScale"; import type { AppSettings, AppUpdateInfo, @@ -47,8 +57,8 @@ const fallbackStatus: IndexerStatus = { state: "idle", entries: 0, progress: 0, - root: "本机所有磁盘", - message: "正在连接索引核心" + root: "All local drives", + message: "Connecting to the index service" }; function initialEffectMode(): EffectMode { @@ -67,6 +77,7 @@ function initialView(): ViewId { } export default function App() { + const { t, ui, runtimeText } = useI18n(); const [view, setView] = useState(initialView); const [overview, setOverview] = useState(); const [settings, setSettings] = useState(); @@ -74,6 +85,7 @@ export default function App() { const [history, setHistory] = useState([]); const [updateInfo, setUpdateInfo] = useState(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(238); const [settingsModule, setSettingsModule] = useState("update"); const [selectedPath, setSelectedPath] = useState(""); @@ -85,6 +97,14 @@ export default function App() { const [toasts, setToasts] = useState([]); const [startupEffect] = useState(initialEffectMode); const effectRequest = useRef(0); + const viewScrollRef = useRef(null); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + viewScrollRef.current?.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [view]); const notify = useCallback((type: ToastItem["type"], message: string) => { const id = Date.now() + Math.floor(Math.random() * 1_000); @@ -112,30 +132,34 @@ export default function App() { setOverview(overviewResult.value); setIndexer(overviewResult.value.indexer); } else { - failures.push(`系统信息:${String(overviewResult.reason)}`); + failures.push(`${ui("系统信息", "System overview")}: ${String(overviewResult.reason)}`); } if (settingsResult.status === "fulfilled") { setSettings(settingsResult.value); + setAppLanguage(settingsResult.value.language); } else { - failures.push(`设置:${String(settingsResult.reason)}`); + failures.push(`${ui("设置", "Settings")}: ${String(settingsResult.reason)}`); } if (migrationsResult.status === "fulfilled") { setHistory(migrationsResult.value); } else { - failures.push(`迁移记录:${String(migrationsResult.reason)}`); + failures.push(`${ui("迁移记录", "Migration history")}: ${String(migrationsResult.reason)}`); } if (analysisResult.status === "fulfilled") { if (analysisResult.value) setSelectedPath(analysisResult.value.summary.path); } else { - failures.push(`分析记录:${String(analysisResult.reason)}`); + failures.push(`${ui("分析记录", "Analysis history")}: ${String(analysisResult.reason)}`); } if (layoutResult.status === "fulfilled") { setSidebarCollapsed(Boolean(layoutResult.value.sidebarCollapsed)); + if (layoutResult.value.sidebarWidth) { + setSidebarWidth(Math.max(190, Math.min(360, layoutResult.value.sidebarWidth))); + } } else { - failures.push(`界面布局:${String(layoutResult.reason)}`); + failures.push(`${ui("界面布局", "UI layout")}: ${String(layoutResult.reason)}`); } if (failures.length > 0) { - notify("error", `部分启动数据暂不可用:${failures.join(";")}`); + notify("error", `${ui("部分启动数据暂不可用", "Some startup data is temporarily unavailable")}: ${failures.join("; ")}`); } }); @@ -145,7 +169,7 @@ export default function App() { const next = items.filter((item) => item.id !== record.id); return [record, ...next]; }); - if (record.stage === "linked") notify("success", message); + if (record.stage === "linked") notify("success", runtimeText(message)); }); const offNavigation = api.onAppNavigation((event) => { if (event.path) { @@ -169,7 +193,10 @@ export default function App() { }, 180); } }); - const offSettings = api.onSettingsChanged(setSettings); + const offSettings = api.onSettingsChanged((updated) => { + setSettings(updated); + setAppLanguage(updated.language); + }); const offUpdate = api.onUpdateStatus(setUpdateInfo); void api.getUpdateState().then(setUpdateInfo).catch(() => undefined); return () => { @@ -179,7 +206,7 @@ export default function App() { offSettings(); offUpdate(); }; - }, [notify]); + }, [notify, runtimeText, ui]); const refreshUpdate = useCallback(async () => { const result = await api.checkForUpdates(true); @@ -193,13 +220,37 @@ export default function App() { void api .updateUiLayout({ sidebarCollapsed: next }) .catch((error) => - notify("error", `无法保存侧边栏状态:${error instanceof Error ? error.message : String(error)}`) + notify("error", `${ui("无法保存侧边栏状态", "Unable to save the sidebar state")}: ${error instanceof Error ? error.message : String(error)}`) ); return next; }); - }, [notify]); + }, [notify, ui]); + + const resizeSidebar = useCallback( + (width: number, collapsed: boolean, commit: boolean) => { + const normalizedWidth = Math.max(190, Math.min(360, Math.round(width))); + setSidebarWidth(normalizedWidth); + setSidebarCollapsed(collapsed); + if (!commit) return; + void api + .updateUiLayout({ sidebarWidth: normalizedWidth, sidebarCollapsed: collapsed }) + .catch((error) => + notify( + "error", + `${ui("无法保存侧边栏宽度", "Unable to save the sidebar width")}: ${error instanceof Error ? error.message : String(error)}` + ) + ); + }, + [notify, ui] + ); const effectMode = settings?.effectMode ?? startupEffect; + useEffect(() => { + applyTextScale(settings?.uiScale ?? 1); + }, [settings?.uiScale]); + + useEffect(() => api.onTextScalePreview(applyTextScale), []); + useEffect(() => { document.documentElement.dataset.effect = effectMode; document.documentElement.style.background = effectBackgrounds[effectMode]; @@ -207,7 +258,8 @@ export default function App() { document .querySelector('meta[name="theme-color"]') ?.setAttribute("content", effectBackgrounds[effectMode]); - }, [effectMode]); + document.title = `CDriveShiftAI · ${t("app.tagline")}`; + }, [effectMode, t]); const switchEffect = useCallback( async (mode: EffectMode) => { @@ -338,14 +390,19 @@ export default function App() { ]); return ( -
+
@@ -355,8 +412,8 @@ export default function App() { {indexer.state === "ready" - ? `自研索引 · ${indexer.mode.toUpperCase()}` - : "正在准备索引"} + ? t("top.indexReady", { mode: indexer.mode.toUpperCase() }) + : t("top.indexPreparing")}
@@ -365,15 +422,15 @@ export default function App() { className="privacy-pill" title={ settings?.ai.enabled - ? "AI 辅助已启用;发送范围由隐私设置控制。点击查看设置" - : "当前仅使用本地规则分析,不会向 AI 服务发送数据。点击配置 AI" + ? t("top.aiEnabledTip") + : t("top.aiLocalTip") } onClick={() => setView("settings")} > {settings?.ai.enabled ? : } - {settings?.ai.enabled ? "AI:辅助分析" : "AI:仅本地"} + {settings?.ai.enabled ? t("top.aiEnabled") : t("top.aiLocal")} -
+
{effectDefinitions.map(({ id: mode }) => ( ))}
-
+
- 正在打开功能页面 - 界面模块按需载入,索引和数据不会重新构建。 + {t("loading.page")} + {t("loading.detail")}
} > diff --git a/src/components/AiModelPicker.tsx b/src/components/AiModelPicker.tsx index 6716c22..c0b2a9b 100644 --- a/src/components/AiModelPicker.tsx +++ b/src/components/AiModelPicker.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronDown, Cpu } from "lucide-react"; import type { AiModelInfo } from "../types"; +import { useI18n } from "../lib/i18n"; interface AiModelPickerProps { models: AiModelInfo[]; @@ -19,6 +20,7 @@ export function AiModelPicker({ onChange, onCommit }: AiModelPickerProps) { + const { ui } = useI18n(); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const rootRef = useRef(null); @@ -73,7 +75,7 @@ export function AiModelPicker({ {open && models.length > 0 && ( -
+
- 服务端返回的对话模型 + {ui("服务端返回的对话模型", "Chat models returned by the provider")} {filtered.length} / {models.length}
@@ -114,7 +116,7 @@ export function AiModelPicker({ ))} {filtered.length === 0 && (
- 列表中没有匹配项;保留当前文本即可把它作为手动模型 ID 测试。 + {ui("列表中没有匹配项;保留当前文本即可把它作为手动模型 ID 测试。", "No matching item. Keep the current text to test it as a manual model ID.")}
)}
diff --git a/src/components/AiProviderPicker.tsx b/src/components/AiProviderPicker.tsx index 5395b5b..7829fc4 100644 --- a/src/components/AiProviderPicker.tsx +++ b/src/components/AiProviderPicker.tsx @@ -6,6 +6,7 @@ import { getAiProvider } from "../lib/aiProviders"; import type { AiProviderId } from "../types"; +import { useI18n } from "../lib/i18n"; interface AiProviderPickerProps { value: AiProviderId; @@ -18,6 +19,7 @@ export function AiProviderPicker({ disabled = false, onChange }: AiProviderPickerProps) { + const { ui } = useI18n(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const rootRef = useRef(null); @@ -68,20 +70,26 @@ export function AiProviderPicker({ > {selected.name} - {selected.group} + {ui(selected.group, { + "国际厂商": "International", + "国内厂商": "China", + "聚合平台": "Aggregators", + "本地模型": "Local models", + "自定义": "Custom" + }[selected.group])} {open && ( -
+
diff --git a/src/components/ForceDeleteDialog.tsx b/src/components/ForceDeleteDialog.tsx new file mode 100644 index 0000000..b1af68e --- /dev/null +++ b/src/components/ForceDeleteDialog.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + AlertTriangle, + Cpu, + FileWarning, + FolderX, + LoaderCircle, + ShieldAlert, + Trash2, + X +} from "lucide-react"; +import { api } from "../lib/api"; +import { useI18n } from "../lib/i18n"; +import type { ForceDeletePreview } from "../types"; + +interface ForceDeleteDialogProps { + path: string; + onClose: () => void; + onDeleted: (path: string) => void; + notify: (type: "success" | "error", message: string) => void; +} + +export function ForceDeleteDialog({ + path, + onClose, + onDeleted, + notify +}: ForceDeleteDialogProps) { + const { ui } = useI18n(); + const [preview, setPreview] = useState(); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let active = true; + setLoading(true); + setError(""); + void api + .previewForceDelete(path) + .then((value) => { + if (active) setPreview(value); + }) + .catch((reason) => { + if (active) { + const message = reason instanceof Error ? reason.message : String(reason); + setError( + message.includes("受保护路径") + ? ui("该路径受系统保护,不能强制删除", "This path is system-protected and cannot be force deleted") + : message.includes("CDriveShiftAI 当前程序") + ? ui("不能删除 CDriveShiftAI 当前程序或数据所在目录", "The active CDriveShiftAI program or data directory cannot be deleted") + : message + ); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [path, ui]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !deleting) onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [deleting, onClose]); + + const execute = async () => { + if (!preview || !confirmed || deleting) return; + setDeleting(true); + setError(""); + try { + const result = await api.executeForceDelete(preview.verificationId); + if (!result.deleted) throw new Error(ui("强制删除未完成", "Force deletion did not complete")); + onDeleted(preview.path); + notify( + "success", + result.terminatedProcesses.length > 0 + ? ui( + `已结束 ${result.terminatedProcesses.length} 个相关进程并永久删除目标`, + `Stopped ${result.terminatedProcesses.length} related process(es) and permanently deleted the target` + ) + : ui("目标已永久删除", "Target permanently deleted") + ); + onClose(); + } catch (reason) { + const message = reason instanceof Error ? reason.message : String(reason); + setError( + message.includes("确认已过期") + ? ui("删除预检已过期,请关闭窗口后重新操作", "The deletion preview expired. Close this dialog and try again") + : message.includes("管理员身份") || message.includes("权限不足") + ? ui("无法结束占用进程或权限不足,请以管理员身份运行后重试", "A related process could not be stopped or access was denied. Run as administrator and try again") + : message + ); + } finally { + setDeleting(false); + } + }; + + return createPortal( +
{ + if (event.target === event.currentTarget && !deleting) onClose(); + }} + > +
+
+
+
+ {ui("不可恢复操作", "IRREVERSIBLE ACTION")} +

{ui("强制永久删除", "Force permanent deletion")}

+
+ +
+ +
+
+ {preview?.isDirectory ? : } +
+ {preview?.name ?? path.split(/[\\/]/).pop() ?? path} + {preview?.path ?? path} +
+
+ + {loading && ( +
+ + {ui("正在检查占用进程和路径风险…", "Checking related processes and path risk…")} +
+ )} + + {error && !preview && ( +
+ + {error} +
+ )} + + {preview && ( + <> +
+ +
+ {ui("目标不会进入回收站", "The target will not go to the Recycle Bin")} + {ui( + "删除前会强制结束从该路径启动、或命令行直接引用该路径的相关进程,然后清除只读属性并重试删除。", + "Before deletion, processes launched from this path or directly referencing it on their command line will be forcibly stopped. Read-only attributes are then cleared before retrying deletion." + )} +
+
+ + {(preview.highRisk || !preview.elevated) && ( +
+ {preview.highRisk && {ui("应用安装目录:删除后程序可能无法运行", "Application directory: deleting it may break the app")}} + {!preview.elevated && {ui("当前不是管理员权限,部分进程可能无法结束", "Not running as administrator; some processes may not be stoppable")}} +
+ )} + +
+
+
{ui("相关进程", "Related processes")}
+ {preview.processes.length} +
+ {preview.processes.length === 0 ? ( +

{ui("未发现从目标路径启动或直接引用目标路径的进程。", "No process launched from or directly referencing the target path was found.")}

+ ) : ( +
+ {preview.processes.map((processItem) => ( +
+ +
+ {processItem.name} + {processItem.executablePath ?? ui("进程路径不可读", "Process path unavailable")} +
+ PID {processItem.pid} + {processItem.canTerminate ? ui("将结束", "Will stop") : ui("系统进程,不结束", "Protected")} +
+ ))} +
+ )} +
+ + + + )} + + {error && preview && ( +
+ + {error} +
+ )} +
+ +
+ {ui("普通删除仍可使用右键菜单中的“删除到回收站”", "Normal deletion remains available as “Move to Recycle Bin”")} +
+ + +
+
+
+
, + document.body + ); +} diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..5c6eaea --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,115 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronDown, Globe2, Search } from "lucide-react"; +import { languageName, languageOptions, resolvedLanguage, useI18n } from "../lib/i18n"; +import type { AppLanguage } from "../types"; + +interface LanguagePickerProps { + value: AppLanguage; + disabled?: boolean; + onChange: (language: AppLanguage) => void; +} + +export function LanguagePicker({ value, disabled, onChange }: LanguagePickerProps) { + const { t, language: displayLanguage } = useI18n(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const rootRef = useRef(null); + const inputRef = useRef(null); + const selected = languageOptions.find((item) => item.id === value) ?? languageOptions[0]; + + const visible = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase(); + if (!normalized) return languageOptions; + return languageOptions.filter((item) => + [item.nativeName, item.englishName, item.id, languageName(item.id, displayLanguage)] + .join(" ") + .toLocaleLowerCase() + .includes(normalized) + ); + }, [displayLanguage, query]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + window.setTimeout(() => inputRef.current?.focus(), 0); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ +
+ {visible.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/OwnershipContextMenu.tsx b/src/components/OwnershipContextMenu.tsx index c1e9d2f..6c89b29 100644 --- a/src/components/OwnershipContextMenu.tsx +++ b/src/components/OwnershipContextMenu.tsx @@ -12,7 +12,7 @@ import { X } from "lucide-react"; import { api } from "../lib/api"; -import { formatDate } from "../lib/format"; +import { useI18n } from "../lib/i18n"; import type { OwnershipMapEntry } from "../types"; interface OwnershipContextMenuProps { @@ -27,16 +27,6 @@ interface OwnershipContextMenuProps { notify: (type: "success" | "error", message: string) => void; } -const categoryLabels: Record = { - application: "应用安装目录", - "application-data": "应用数据", - cache: "缓存/临时数据", - "user-data": "用户数据", - development: "开发数据", - system: "系统组件", - unknown: "待识别目录" -}; - export function OwnershipContextMenu({ entry, x, @@ -48,8 +38,18 @@ export function OwnershipContextMenu({ onDeleted, notify }: OwnershipContextMenuProps) { + const { ui, formatDate } = useI18n(); const [busy, setBusy] = useState(""); const menuRef = useRef(null); + const categoryLabels: Record = { + application: ui("应用安装目录", "Application installation"), + "application-data": ui("应用数据", "Application data"), + cache: ui("缓存/临时数据", "Cache / temporary data"), + "user-data": ui("用户数据", "User data"), + development: ui("开发数据", "Development data"), + system: ui("系统组件", "System component"), + unknown: ui("待识别目录", "Unidentified folder") + }; useEffect(() => { const close = (event: PointerEvent) => { @@ -89,7 +89,7 @@ export function OwnershipContextMenu({ ref={menuRef} style={{ left: x, top: y }} role="menu" - aria-label={`${entry.name} 的目录操作菜单`} + aria-label={ui(`${entry.name} 的目录操作菜单`, `Folder actions for ${entry.name}`)} onContextMenu={(event) => event.preventDefault()} >
@@ -105,17 +105,17 @@ export function OwnershipContextMenu({ {entry.lastModified ? ` · ${formatDate(entry.lastModified)}` : ""}
-
- {entry.risk === "blocked" ? "系统保护" : `风险 ${entry.risk}`} + {entry.risk === "blocked" ? ui("系统保护", "System protected") : ui(`风险 ${entry.risk}`, `Risk: ${entry.risk}`)} {entry.owner - ? `${Math.round(entry.owner.confidence * 100)}% 本地证据匹配` - : "尚未匹配到已安装应用"} + ? ui(`${Math.round(entry.owner.confidence * 100)}% 本地证据匹配`, `${Math.round(entry.owner.confidence * 100)}% local evidence match`) + : ui("尚未匹配到已安装应用", "No installed application matched")} {entry.explanation}
@@ -125,19 +125,19 @@ export function OwnershipContextMenu({ type="button" className="context-primary" disabled={Boolean(busy)} - onClick={() => void run("打开", () => api.openPath(entry.path))} + onClick={() => void run("open", () => api.openPath(entry.path))} > - 打开文件夹 + {ui("打开文件夹", "Open folder")} Enter @@ -151,7 +151,7 @@ export function OwnershipContextMenu({ }} > - 详细 AI 目录归属分析 + {ui("详细 AI 目录归属分析", "Detailed AI folder ownership analysis")}
@@ -200,17 +200,17 @@ export function OwnershipContextMenu({ className="danger" disabled={entry.risk === "blocked" || Boolean(busy)} onClick={() => - void run("删除", async () => { + void run("delete", async () => { const deleted = await api.trashPath(entry.path); if (deleted) { onDeleted(entry.path); - notify("success", "已移入回收站"); + notify("success", ui("已移入回收站", "Moved to the Recycle Bin")); } }) } > - {entry.risk === "blocked" ? "系统保护目录不可删除" : "删除到回收站"} + {entry.risk === "blocked" ? ui("系统保护目录不可删除", "System-protected folders cannot be deleted") : ui("删除到回收站", "Move to Recycle Bin")} Delete diff --git a/src/components/PathOpenFeedback.tsx b/src/components/PathOpenFeedback.tsx index e8d1d51..8e75bcd 100644 --- a/src/components/PathOpenFeedback.tsx +++ b/src/components/PathOpenFeedback.tsx @@ -6,6 +6,7 @@ import { type MouseEvent as ReactMouseEvent } from "react"; import { api } from "../lib/api"; +import { useI18n } from "../lib/i18n"; type OpenPhase = "opening" | "opened" | "error"; @@ -87,13 +88,14 @@ export function PathOpenFeedback({ path: string; feedback?: OpenFeedback; }) { + const { ui } = useI18n(); if (!feedback || feedback.path !== path) return null; const label = feedback.phase === "opening" - ? "正在交给 Windows 打开" + ? ui("正在交给 Windows 打开", "Opening with Windows") : feedback.phase === "opened" - ? "已打开" - : "打开失败"; + ? ui("已打开", "Opened") + : ui("打开失败", "Open failed"); return ( string) { if (value.isSymbolicLink) { - return value.isDirectory ? "目录符号链接" : "文件符号链接"; + return value.isDirectory + ? ui("目录符号链接", "Directory symbolic link") + : ui("文件符号链接", "File symbolic link"); } - if (value.isDirectory) return "文件夹"; - return value.extension ? `${value.extension.toUpperCase()} 文件` : "文件"; + if (value.isDirectory) return ui("文件夹", "Folder"); + return value.extension + ? ui(`${value.extension.toUpperCase()} 文件`, `${value.extension.toUpperCase()} file`) + : ui("文件", "File"); } function DateValue({ value }: { value?: string }) { - return {value ? formatDate(value) : "无可用记录"}; + const { ui, formatDate } = useI18n(); + return {value ? formatDate(value) : ui("无可用记录", "No record available")}; } export function PathPropertiesDialog({ @@ -67,6 +73,7 @@ export function PathPropertiesDialog({ onRenamed, notify }: PathPropertiesDialogProps) { + const { ui, formatNumber } = useI18n(); const [activePath, setActivePath] = useState(path); const [properties, setProperties] = useState(); const [error, setError] = useState(""); @@ -119,9 +126,12 @@ export function PathPropertiesDialog({ }, [onClose]); useEffect(() => { - const move = (event: MouseEvent) => { + const move = (event: MouseEvent | PointerEvent) => { const drag = dragRef.current; if (!drag) return; + if (event instanceof PointerEvent && drag.pointerId >= 0 && event.pointerId !== drag.pointerId) { + return; + } setPosition( clampPosition({ x: drag.originX + event.clientX - drag.startX, @@ -129,14 +139,30 @@ export function PathPropertiesDialog({ }) ); }; - const finish = () => { + const finish = (pointerId?: number) => { + if ( + pointerId != null && + dragRef.current && + dragRef.current.pointerId >= 0 && + dragRef.current.pointerId !== pointerId + ) { + return; + } dragRef.current = undefined; }; + const finishPointer = (event: PointerEvent) => finish(event.pointerId); + const finishMouse = () => finish(); + window.addEventListener("pointermove", move, true); + window.addEventListener("pointerup", finishPointer, true); + window.addEventListener("pointercancel", finishPointer, true); window.addEventListener("mousemove", move, true); - window.addEventListener("mouseup", finish, true); + window.addEventListener("mouseup", finishMouse, true); return () => { + window.removeEventListener("pointermove", move, true); + window.removeEventListener("pointerup", finishPointer, true); + window.removeEventListener("pointercancel", finishPointer, true); window.removeEventListener("mousemove", move, true); - window.removeEventListener("mouseup", finish, true); + window.removeEventListener("mouseup", finishMouse, true); }; }, []); @@ -191,7 +217,7 @@ export function PathPropertiesDialog({ const copyPath = async () => { try { await api.copyText(activePath); - notify("success", "完整路径已复制"); + notify("success", ui("完整路径已复制", "Full path copied")); } catch (reason) { notify("error", reason instanceof Error ? reason.message : String(reason)); } @@ -201,7 +227,7 @@ export function PathPropertiesDialog({ if (!properties || renameBusy) return; const requestedName = newName.trim(); if (!requestedName) { - notify("error", "名称不能为空"); + notify("error", ui("名称不能为空", "The name cannot be empty")); return; } if (requestedName === properties.name) { @@ -215,7 +241,7 @@ export function PathPropertiesDialog({ setActivePath(renamedPath); setRenaming(false); onRenamed?.(oldPath, renamedPath); - notify("success", "重命名完成"); + notify("success", ui("重命名完成", "Rename completed")); } catch (reason) { notify("error", reason instanceof Error ? reason.message : String(reason)); } finally { @@ -233,7 +259,7 @@ export function PathPropertiesDialog({ className="path-properties-dialog" role="dialog" aria-modal="true" - aria-label={`${properties?.name ?? activePath} 的属性`} + aria-label={ui(`${properties?.name ?? activePath} 的属性`, `Properties for ${properties?.name ?? activePath}`)} style={{ left: position.x, top: position.y }} onClick={(event) => event.stopPropagation()} > @@ -255,14 +281,14 @@ export function PathPropertiesDialog({ )}
- 项目属性 - {properties?.name ?? "正在读取…"} + {ui("项目属性", "Item properties")} + {properties?.name ?? ui("正在读取…", "Loading…")}
- 拖动窗口 + {ui("拖动窗口", "Drag window")}
- @@ -271,14 +297,14 @@ export function PathPropertiesDialog({ {error ? (
- 无法读取该项目 + {ui("无法读取该项目", "Unable to read this item")} {error}
) : !properties ? (
- 正在读取文件系统信息 - 大型目录的容量与项目数量统计可能需要一点时间。 + {ui("正在读取文件系统信息", "Reading file system information")} + {ui("大型目录的容量与项目数量统计可能需要一点时间。", "Size and item counts for large folders may take a moment.")}
) : ( <> @@ -287,13 +313,13 @@ export function PathPropertiesDialog({ {properties.path} {renaming && (
- +
)} @@ -322,57 +348,59 @@ export function PathPropertiesDialog({
- 大小 + {ui("大小", "Size")} {formatBytes(properties.size)} - {properties.scanComplete ? "统计完成" : "受时间或权限限制,为当前可读大小"} + {properties.scanComplete + ? ui("统计完成", "Scan complete") + : ui("受时间或权限限制,为当前可读大小", "Readable size only due to time or permission limits")}
- {properties.isDirectory ? "所含项目" : "占用空间"} + {properties.isDirectory ? ui("所含项目", "Items") : ui("占用空间", "Allocated size")} {properties.isDirectory - ? `${(properties.files ?? 0).toLocaleString()} 文件` + ? ui(`${(properties.files ?? 0).toLocaleString()} 文件`, `${formatNumber(properties.files ?? 0)} files`) : formatBytes(properties.allocatedBytes ?? properties.size)} {properties.isDirectory - ? `${(properties.directories ?? 0).toLocaleString()} 个子目录` - : "按文件系统分配块估算"} + ? ui(`${(properties.directories ?? 0).toLocaleString()} 个子目录`, `${formatNumber(properties.directories ?? 0)} subfolders`) + : ui("按文件系统分配块估算", "Estimated from file-system allocation")}
{properties.isDirectory ? : } - 项目类型 - {propertyType(properties)} - {properties.writable ? "可读写" : properties.readable ? "只读访问" : "访问受限"} + {ui("项目类型", "Item type")} + {propertyType(properties, ui)} + {properties.writable ? ui("可读写", "Read and write") : properties.readable ? ui("只读访问", "Read-only access") : ui("访问受限", "Access restricted")}
-

常规信息

+

{ui("常规信息", "General")}

-
名称
+
{ui("名称", "Name")}
{properties.name}
-
类型
-
{propertyType(properties)}
+
{ui("类型", "Type")}
+
{propertyType(properties, ui)}
-
位置
+
{ui("位置", "Location")}
{properties.parentPath}
-
扩展名
-
{properties.extension ? `.${properties.extension}` : "无"}
+
{ui("扩展名", "Extension")}
+
{properties.extension ? `.${properties.extension}` : ui("无", "None")}
{properties.linkTarget && (
-
链接目标
+
{ui("链接目标", "Link target")}
{properties.linkTarget}
)} @@ -380,26 +408,26 @@ export function PathPropertiesDialog({
-

时间与访问

+

{ui("时间与访问", "Time and access")}

-
创建时间
+
{ui("创建时间", "Created")}
-
修改时间
+
{ui("修改时间", "Modified")}
-
访问时间
+
{ui("访问时间", "Accessed")}
-
当前权限
+
{ui("当前权限", "Current access")}
- {properties.readable && 读取} - {properties.writable && 写入} - {!properties.readable && !properties.writable && "无访问权限"} + {properties.readable && {ui("读取", "Read")}} + {properties.writable && {ui("写入", "Write")}} + {!properties.readable && !properties.writable && ui("无访问权限", "No access")}
@@ -410,7 +438,7 @@ export function PathPropertiesDialog({
- 信息直接读取自当前文件系统 + {ui("信息直接读取自当前文件系统", "Information read directly from the current file system")}
- +
diff --git a/src/components/SearchContextMenu.tsx b/src/components/SearchContextMenu.tsx index c3a8445..af6f22b 100644 --- a/src/components/SearchContextMenu.tsx +++ b/src/components/SearchContextMenu.tsx @@ -20,12 +20,14 @@ import { Pencil, Play, Search, + ShieldAlert, Sparkles, Trash2, X } from "lucide-react"; import { api } from "../lib/api"; -import { formatBytes, formatDate } from "../lib/format"; +import { useI18n } from "../lib/i18n"; +import { formatBytes } from "../lib/format"; import type { SearchResult } from "../types"; interface SearchContextMenuProps { @@ -40,6 +42,7 @@ interface SearchContextMenuProps { onFindSameName: (name: string) => void; onFilterExtension: (extension: string) => void; onProperties: (path: string) => void; + onForceDelete: (path: string) => void; onDeleted: (path: string) => void; onRenamed: (oldPath: string, newPath: string) => void; notify: (type: "success" | "error", message: string) => void; @@ -80,10 +83,12 @@ export function SearchContextMenu({ onFindSameName, onFilterExtension, onProperties, + onForceDelete, onDeleted, onRenamed, notify }: SearchContextMenuProps) { + const { ui, formatDate } = useI18n(); const [renaming, setRenaming] = useState(initialRename); const [newName, setNewName] = useState(item.name); const [busy, setBusy] = useState(""); @@ -157,7 +162,8 @@ export function SearchContextMenu({ .catch((error) => notify( "error", - `无法保存重命名窗口位置:${error instanceof Error ? error.message : String(error)}` + ui("无法保存重命名窗口位置:", "Could not save the rename window position: ") + + (error instanceof Error ? error.message : String(error)) ) ); }; @@ -253,7 +259,11 @@ export function SearchContextMenu({ void api .updateUiLayout({ searchRenamePosition: finalPosition }) .catch((error) => - notify("error", `无法保存重命名窗口位置:${error instanceof Error ? error.message : String(error)}`) + notify( + "error", + ui("无法保存重命名窗口位置:", "Could not save the rename window position: ") + + (error instanceof Error ? error.message : String(error)) + ) ); }; @@ -276,13 +286,16 @@ export function SearchContextMenu({ }; const copyTo = async () => { - const destination = await api.chooseDirectory("选择复制目标目录"); + const destination = await api.chooseDirectory( + ui("选择复制目标目录", "Choose destination folder"), + "copy-destination" + ); if (!destination) return; await run( - "复制到", + "copy-to", async () => { const output = await api.copyPathToDirectory(item.path, destination); - notify("success", `已复制到 ${output}`); + notify("success", ui(`已复制到 ${output}`, `Copied to ${output}`)); }, undefined ); @@ -295,12 +308,12 @@ export function SearchContextMenu({ return; } await run( - "重命名", + "rename", async () => { const output = await api.renamePath(item.path, value); onRenamed(item.path, output); }, - "重命名完成" + ui("重命名完成", "Rename completed") ); }; @@ -310,7 +323,7 @@ export function SearchContextMenu({ ref={menuRef} style={{ left: position.x, top: position.y }} role="menu" - aria-label={`${item.name} 的操作菜单`} + aria-label={ui(`${item.name} 的操作菜单`, `Actions for ${item.name}`)} onContextMenu={(event) => event.preventDefault()} >
{item.path} {renaming - ? "按住这里拖动 · 松开后自动记住位置" + ? ui("按住这里拖动 · 松开后自动记住位置", "Drag here · the position is saved when released") : ( <> - {item.isDirectory ? "文件夹" : extension ? extension.toUpperCase() : "文件"} + {item.isDirectory ? ui("文件夹", "Folder") : extension ? extension.toUpperCase() : ui("文件", "File")} {" · "} - {!item.isDirectory || item.size > 0 ? formatBytes(item.size) : "大小计算中"} + {!item.isDirectory || item.size > 0 ? formatBytes(item.size) : ui("大小计算中", "Calculating size")} {item.modifiedAt ? ` · ${formatDate(item.modifiedAt)}` : ""} )} -
@@ -349,7 +362,7 @@ export function SearchContextMenu({
@@ -376,28 +389,28 @@ export function SearchContextMenu({ type="button" className="context-primary" onClick={() => - void run("打开", () => api.openPath(item.path)) + void run("open", () => api.openPath(item.path)) } > - {item.isDirectory ? "打开文件夹" : "使用系统默认方式打开"} + {item.isDirectory ? ui("打开文件夹", "Open folder") : ui("使用系统默认方式打开", "Open with the default app")} Enter {!item.isDirectory && ( )} @@ -412,7 +425,7 @@ export function SearchContextMenu({ }} > - 在此文件夹内搜索名称 + {ui("在此文件夹内搜索名称", "Search names in this folder")} )} @@ -451,34 +464,34 @@ export function SearchContextMenu({ @@ -491,7 +504,7 @@ export function SearchContextMenu({ }} > - 查找同名项目 + {ui("查找同名项目", "Find items with the same name")} {!item.isDirectory && extension && ( )} @@ -529,12 +542,12 @@ export function SearchContextMenu({ className="danger" onClick={() => void run( - "删除", + "delete", async () => { const deleted = await api.trashPath(item.path); if (deleted) { onDeleted(item.path); - notify("success", "已移入回收站"); + notify("success", ui("已移入回收站", "Moved to the Recycle Bin")); } }, undefined @@ -542,9 +555,21 @@ export function SearchContextMenu({ } > - 删除到回收站 + {ui("删除到回收站", "Move to Recycle Bin")} Delete + )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index e947ae7..55d9c4a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,16 +12,23 @@ import { Sparkles } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent +} from "react"; import type { AppUpdateInfo, IndexerStatus, ViewId } from "../types"; +import { useI18n, type TranslationKey } from "../lib/i18n"; import { ThemedTooltip } from "./ThemedTooltip"; -const items: Array<{ id: ViewId; label: string; icon: LucideIcon }> = [ - { id: "overview", label: "空间总览", icon: LayoutDashboard }, - { id: "search", label: "极速搜索", icon: Search }, - { id: "ownership-map", label: "磁盘归属地图", icon: Map }, - { id: "analyze", label: "AI 归属分析", icon: ScanSearch }, - { id: "migrate", label: "安全迁移", icon: ArrowRightLeft }, - { id: "history", label: "迁移记录", icon: History } +const items: Array<{ id: ViewId; label: TranslationKey; icon: LucideIcon }> = [ + { id: "overview", label: "nav.overview", icon: LayoutDashboard }, + { id: "search", label: "nav.search", icon: Search }, + { id: "ownership-map", label: "nav.ownership", icon: Map }, + { id: "analyze", label: "nav.analyze", icon: ScanSearch }, + { id: "migrate", label: "nav.migrate", icon: ArrowRightLeft }, + { id: "history", label: "nav.history", icon: History } ]; interface SidebarProps { @@ -29,7 +36,9 @@ interface SidebarProps { onChange: (view: ViewId) => void; indexer: IndexerStatus; collapsed: boolean; + width: number; onToggle: () => void; + onResize: (width: number, collapsed: boolean, commit: boolean) => void; updateInfo?: AppUpdateInfo; } @@ -38,9 +47,20 @@ export function Sidebar({ onChange, indexer, collapsed, + width, onToggle, + onResize, updateInfo }: SidebarProps) { + const { t, formatNumber, direction } = useI18n(); + const [resizing, setResizing] = useState(false); + const resizeState = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + width: number; + collapsed: boolean; + } | undefined>(undefined); const ready = indexer.state === "ready"; const updateStatus = updateInfo?.updateAvailable ? "available" @@ -48,16 +68,76 @@ export function Sidebar({ const updateTooltip = updateStatus === "available" ? updateInfo?.status === "unavailable" - ? `已检测到新版本 v${updateInfo?.latestVersion ?? "未知"};本次联网复查失败,但不会清除已确认的更新。点击进入设置查看。` - : `发现新版本 v${updateInfo?.latestVersion ?? "未知"};当前为 v${updateInfo?.currentVersion ?? "未知"}。点击进入设置更新。` + ? t("sidebar.updateStale", { latest: updateInfo?.latestVersion ?? t("general.unknown") }) + : t("sidebar.updateAvailable", { + latest: updateInfo?.latestVersion ?? t("general.unknown"), + current: updateInfo?.currentVersion ?? t("general.unknown") + }) : updateStatus === "unavailable" - ? `暂时无法检查版本:${updateInfo?.message ?? "请稍后重试"}。点击进入设置查看。` + ? t("sidebar.updateUnavailable", { message: updateInfo?.message ?? "—" }) : updateStatus === "current" - ? `当前版本 v${updateInfo?.currentVersion ?? "未知"},已是最新版本。` - : "正在检查 GitHub Release 版本…"; + ? t("sidebar.updateCurrent", { current: updateInfo?.currentVersion ?? t("general.unknown") }) + : t("sidebar.updateChecking"); + + const beginResize = (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setResizing(true); + resizeState.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: collapsed ? 72 : width, + width, + collapsed + }; + }; + + const continueResize = (event: ReactPointerEvent) => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId) return; + const physicalDelta = event.clientX - state.startX; + const requested = state.startWidth + (direction === "rtl" ? -physicalDelta : physicalDelta); + const nextCollapsed = requested < 145; + const nextWidth = Math.max(190, Math.min(360, requested)); + const shell = event.currentTarget.closest(".app-shell"); + shell?.style.setProperty("--sidebar-width", `${Math.round(nextWidth)}px`); + if (nextCollapsed !== state.collapsed) { + onResize(nextWidth, nextCollapsed, false); + } + state.width = nextWidth; + state.collapsed = nextCollapsed; + }; + + const finishResize = (event: ReactPointerEvent) => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId) return; + resizeState.current = undefined; + setResizing(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + onResize(state.width, state.collapsed, true); + }; + + const resizeWithKeyboard = (event: ReactKeyboardEvent) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "Home") { + onResize(width, true, true); + return; + } + if (event.key === "End") { + onResize(360, false, true); + return; + } + const physicalStep = event.key === "ArrowRight" ? 12 : -12; + const step = direction === "rtl" ? -physicalStep : physicalStep; + const base = collapsed ? 132 : width; + onResize(base + step, false, true); + }; return ( -