diff --git a/docs/context-menu-parsing-logic.md b/docs/context-menu-parsing-logic.md new file mode 100644 index 0000000..4cd3ebb --- /dev/null +++ b/docs/context-menu-parsing-logic.md @@ -0,0 +1,449 @@ +# ContextMaster 右键菜单解析完整逻辑 + +## 目录 +1. [架构概览](#架构概览) +2. [完整数据流](#完整数据流) +3. [核心模块详解](#核心模块详解) +4. [Shell Ext 名称解析链](#shell-ext-名称解析链) +5. [缓存机制](#缓存机制) +6. [启用/禁用逻辑](#启用禁用逻辑) + +--- + +## 架构概览 + +ContextMaster 的右键菜单解析采用分层架构,从 UI 到底层注册表操作分为 6 个主要层次: + +```mermaid +graph TB + A[Renderer Layer
mainPage.ts - 用户交互] -->|IPC| B[IPC Handlers
Electron IPC 通信层] + B --> C[MenuManagerService.ts
菜单管理服务
业务逻辑、缓存控制、事务管理] + C --> D[RegistryService.ts
注册表服务
Classic Shell + Shell Ext 解析、缓存] + D --> E[PowerShellBridge.ts
PowerShell 脚本
注册表枚举 + DLL 版本信息采集] + D --> F[ShellExtNameResolver.ts
名称解析器
多级回退链 + 过滤 + 翻译] + F --> G[Win32Shell.ts
koffi FFI
SHLoadIndirectString] +``` + +> **关键设计决策**:名称解析逻辑全部在 TypeScript 侧(ShellExtNameResolver),PS 脚本只负责数据采集(注册表原始值 + DLL FileVersionInfo)。 + +--- + +## 完整数据流 + +### 1. 用户请求菜单列表的完整流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant R as Renderer mainPage.ts + participant I as IPC Handlers + participant M as MenuManagerService + participant S as RegistryService + participant P as PowerShellBridge + participant N as ShellExtNameResolver + participant W as Win32Shell + + User->>R: 点击场景导航 + R->>R: 检查 Renderer 缓存 (2min TTL) + alt 缓存未命中 + R->>I: REGISTRY_GET_ITEMS + I->>M: getMenuItems(scene) + M->>M: 检查缓存 (5min TTL) / in-flight 去重 + alt 缓存未命中 + M->>S: getMenuItems(scene) + S->>S: 检查 RegistryCache (30s TTL) + alt 缓存未命中 + par 并行执行 PowerShell + S->>P: buildGetItemsScript() → Classic Shell + and + S->>P: buildGetShellExtItemsScript() → Shell Ext + end + P-->>S: 原始注册表数据 + DLL FileVersionInfo + S->>N: resolveClassicName() / resolveExtName() + N->>W: resolveIndirect(@dll,-id) + W-->>N: 解析后的本地化名称 + N-->>S: 最终显示名称 + S->>S: cleanDisplayName (去除加速键) + S->>S: 写入 RegistryCache + end + S-->>M: MenuItemEntry[] + M->>M: 写入 MenuManager 缓存 + end + M-->>I: MenuItemEntry[] + I-->>R: MenuItemEntry[] + R->>R: 渲染列表 + end +``` + +### 2. 启动初始化流程 + +``` +app.whenReady() + → initServices() + → new Win32Shell() // koffi FFI 初始化,获取 UI 语言 + → new ShellExtNameResolver() // 注入 Win32Shell + → new CommandStoreIndex() // 空索引 + → new RegistryService() // 注入 resolver + cmdStoreIndex + → ps.execute(buildCommandStoreScript()) // 异步构建 CommandStore 索引 + → registerSystemHandlers() // IPC 注册(含诊断通道) + → createWindow() + → preloadAllScenes() // 串行预热各场景 +``` + +--- + +## 核心模块详解 + +### 1. RegistryService — 注册表服务 + +**文件**: `src/main/services/RegistryService.ts` + +**职责**: 协调 Classic Shell 和 Shell Ext 条目读取、调用 ShellExtNameResolver 解析名称、缓存管理、启用/禁用操作、事务回滚。 + +**注册表路径映射**: + +| 场景 | Classic Shell | Shell Extension | +|------|-------------|----------------| +| Desktop | `HKCR\DesktopBackground\Shell` | `HKCR\DesktopBackground\shellex\ContextMenuHandlers` | +| File | `HKCR\*\shell` | `HKCR\*\shellex\ContextMenuHandlers` | +| Folder | `HKCR\Directory\shell` | `HKCR\Directory\shellex\ContextMenuHandlers` | +| Drive | `HKCR\Drive\shell` | `HKCR\Drive\shellex\ContextMenuHandlers` | +| DirectoryBackground | `HKCR\Directory\Background\shell` | `HKCR\Directory\Background\shellex\ContextMenuHandlers` | +| RecycleBin | `HKCR\CLSID\{645FF040-...}\shell` | `HKCR\CLSID\{645FF040-...}\shellex\ContextMenuHandlers` | + +### 2. PowerShellBridge — PowerShell 桥接 + +**文件**: `src/main/services/PowerShellBridge.ts` + +**职责**: 构建 PowerShell 脚本、执行并解析 JSON、并发控制(信号量 max 3)、提权执行(UAC)。 + +**关键脚本**: + +#### buildGetItemsScript — Classic Shell 条目采集 + +返回 `PsRawClassicItem[]`,字段: + +| 字段 | 来源 | 说明 | +|------|------|------| +| `subKeyName` | 子键名 | 注册表键名 | +| `rawMUIVerb` | `MUIVerb` 值 | 可能为 @dll,-id 间接字符串 | +| `rawDefault` | `(Default)` 值 | 默认显示名 | +| `rawLocalizedDisplayName` | `LocalizedDisplayName` 值 | 本地化显示名 | +| `rawIcon` | `Icon` 值 | 图标路径 | +| `isEnabled` | `LegacyDisable` 是否为 null | 启用状态 | +| `command` | `command` 子键默认值 | 执行命令 | +| `registryKey` | 拼接的完整路径 | 注册表相对路径 | + +#### buildGetShellExtItemsScript — Shell Ext 条目采集 + +返回 `PsRawShellExtItem[]`,字段: + +| 字段 | 来源 | 说明 | +|------|------|------| +| `handlerKeyName` | 子键名 | 可能带 `-` 前缀(禁用标记) | +| `cleanName` | handlerKeyName 去 `-` 前缀 | 回退名称 | +| `defaultVal` | handler key 的 `(Default)` 值 | 通常是 CLSID 或间接字符串 | +| `actualClsid` | 从 handlerKeyName/defaultVal 推导 | CLSID 标识符 | +| `clsidLocalizedString` | `CLSID\{clsid}\LocalizedString` | 间接字符串或明文 | +| `clsidMUIVerb` | `CLSID\{clsid}\MUIVerb` | 间接字符串或明文 | +| `clsidDefault` | `CLSID\{clsid}\(Default)` | CLSID 默认名称 | +| `dllPath` | `CLSID\{clsid}\InprocServer32\(Default)` | 已展开环境变量 | +| `dllFileDescription` | DLL 的 `FileVersionInfo.FileDescription` | .NET 采集,UI 语言 | +| `dllProductName` | DLL 的 `FileVersionInfo.ProductName` | .NET 采集,UI 语言 | +| `progIdName` | `CLSID\{clsid}\ProgID` → `HKCR\{ProgID}\(Default)` | 应用程序名 | +| `siblingMUIVerb` | `HKCR\{type}\shell\{cleanName}\MUIVerb` | 同级 shell key | +| `registryKey` | 拼接的完整路径 | 注册表相对路径 | + +> **DLL 版本信息采集**:使用 PS 的 `[System.Diagnostics.FileVersionInfo]::GetVersionInfo()` 读取 FileDescription 和 ProductName,**天然支持 UI 语言**,无需 koffi FFI。`dllPath` 在 PS 中通过 `[Environment]::ExpandEnvironmentVariables()` 展开。 + +#### buildCommandStoreScript — CommandStore 索引构建 + +扫描 `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\CommandStore\shell`,返回 `{clsid, muiverb}[]`。启动时异步执行一次,存入 `CommandStoreIndex`。 + +### 3. ShellExtNameResolver — 名称解析器 + +**文件**: `src/main/services/ShellExtNameResolver.ts` + +**职责**: 解析 Classic Shell 和 Shell Ext 显示名称、标准动词翻译、泛型名称过滤、CommandStore 索引管理。 + +#### Classic Shell 名称解析 (`resolveClassicName`) + +``` +1. rawMUIVerb → @/ms-resource: → SHLoadIndirectString (间接) 或直接返回 (明文) +2. rawDefault → @/ms-resource: → SHLoadIndirectString 或直接返回 +3. rawLocalizedDisplayName → 同上 +4. 标准动词翻译 → translateStandardVerb(subKeyName) +5. subKeyName → 最终兜底 +``` + +#### Shell Ext 名称解析 (`resolveExtName`) — 见下一节完整解析链 + +#### 泛型名称过滤 (`isGenericName`) + +6 组正则规则(Group A-D),过滤无信息量的 COM/Shell 技术描述: + +| 规则组 | 匹配示例 | 说明 | +|--------|---------|------| +| Group A | `context menu`, `shell extension`, `外壳服务对象`, `*.dll`, `microsoft windows *` | COM/Shell 技术内部描述 | +| Group B | `* Class` | COM 类名后缀 | +| Group C | `TODO:`, ``, `n/a`, `none`, `unknown` | 占位符/无效值 | +| Group D | `^(a\|an\|the) ` 开头句子, `^(...)$` 括号包裹 | 句子描述/调试标记 | + +#### 标准动词翻译 (`translateStandardVerb`) + +35 个标准 shell 动词的中英文翻译表:`open→打开`, `edit→编辑`, `runas→以管理员身份运行`, `sendto→发送到`, `pintotaskbar→固定到任务栏` 等。 + +语言选择由 `GetUserDefaultUILanguage()` 决定(中文 Window → `'zh'`,其他 → `'en'`)。 + +#### CommandStore 索引 (`CommandStoreIndex`) + +- `buildFromData()`: 从 PS 返回的 `{clsid, muiverb}[]` 构建 CLSID→MUIVerb 映射 +- `get(clsid)`: 大小写不敏感查找 +- `size`: 当前条目数 +- 启动时异步构建,不影响首次加载 + +### 4. Win32Shell — Windows API 封装 + +**文件**: `src/main/services/Win32Shell.ts` + +**职责**: 封装 `SHLoadIndirectString` (shlwapi.dll) 通过 koffi FFI,获取用户 UI 语言。 + +**接口** (`IWin32Shell`): + +```typescript +interface IWin32Shell { + resolveIndirect(source: string): string | null; + readonly uiLanguage: 'zh' | 'en'; +} +``` + +`resolveIndirect` 实现: +``` +1. 检查 source 是否以 @ 或 ms-resource: 开头 +2. Buffer.alloc(2048) 分配输出缓冲区 +3. koffi 调用 SHLoadIndirectString(source, buf, 1024, null) +4. HRESULT=0 → buf.toString('utf16le') 读取结果 +5. 缓存结果(间接字符串 → 解析后名称) +``` + +> **注意**:DLL 版本信息读取从 Win32Shell 移除。原 koffi 实现的 `GetFileVersionInfo` 历经多次修复仍不稳定(解构失败 / 参数数量不匹配 / segfault),改为 PS 内联 `.NET FileVersionInfo` 采集。 + +--- + +## Shell Ext 名称解析链 + +完整的多级回退链,按优先级分为三个阶段: + +### Phase A: 间接格式(最高优先级,返回系统语言名称) + +| Level | 数据源 | 处理方式 | +|-------|--------|---------| +| 0 | handler `defaultVal` @/ms-resource: | `SHLoadIndirectString` | +| 1-indirect | `CLSID.LocalizedString` @/ms-resource: | `SHLoadIndirectString` | +| 1.3-indirect | sibling shell key `MUIVerb` @/ms-resource: | `SHLoadIndirectString` | +| 1.5-indirect | `CLSID.MUIVerb` @/ms-resource: | `SHLoadIndirectString` | + +### Phase B: Windows 本地化机制(次优先级) + +| Level | 数据源 | 说明 | +|-------|--------|------| +| 1.7 | CommandStore 反向索引 | ExplorerCommandHandler CLSID → MUIVerb,解析 `@/ms-resource:` | +| 1.6 | ProgID 链 | `CLSID.ProgID` → `HKCR\{ProgID}\(Default)` → 应用程序名 | + +### Phase C: Plain text 回退(最低优先级,可能是英文) + +| Level | 数据源 | 处理方式 | +|-------|--------|---------| +| 1-plain | `CLSID.LocalizedString` 明文 | `isUselessPlain` 过滤 | +| 1.3-plain | sibling shell key `MUIVerb` 明文 | `isUselessPlain` 过滤 | +| 1.5-plain | `CLSID.MUIVerb` 明文 | `isUselessPlain` 过滤 | +| 2 | `CLSID.(Default)` | `isUselessPlain` 过滤 | +| 2.5 | DLL `FileDescription` → `ProductName` | `isGenericName` 过滤 + 不等于 fallback | +| 3 | handler `defaultVal` 明文 | `isUselessPlain` 过滤 | + +### 最终兜底 + +| 步骤 | 处理 | +|------|------| +| 标准动词翻译 | `translateStandardVerb(cleanName)` — open→打开 等 | +| Fallback | `cleanName` — 注册表键名 | + +### 代表案例 + +| 扩展 | 解析路径 | 结果 | +|------|---------|------| +| Open With (`{09799AFB-...}`) | Level 0: `@shell32.dll,-8510` → SHLoadIndirectString | "打开方式" | +| Taskband Pin (`{90AA3A4E-...}`) | Level 1.7: CommandStore `@shell32.dll,-37423` → resolveIndirect | "固定到任务栏" | +| Portable Devices (`{D6791A63-...}`) | Level 1: `@wpdshext.dll,-511` → SHLoadIndirectString | "便携设备菜单" | +| YunShellExt (百度网盘) | Level 1.6: ProgID → "百度网盘" (预期) 或 Level 2.5: DLL ProductName | TBD | +| BitLocker | Level 0: `@fvewiz.dll,-971` → SHLoadIndirectString | "更改 BitLocker 密码" | +| gvim | Level 1.3: sibling `MUIVerb` "用Vim编辑" | "用Vim编辑" | +| 标准动词 (find/open/edit) | Fallback: `translateStandardVerb` | "搜索/打开/编辑" | + +--- + +## 缓存机制 + +### 三级缓存架构 + +``` +1. Renderer Cache (mainPage.ts) + ├─ TTL: 2 分钟 + ├─ stale-while-revalidate (剩余 <30s 时后台刷新) + └─ 场景隔离 + +2. MenuManager Cache (MenuManagerService.ts) + ├─ TTL: 5 分钟 + ├─ in-flight 去重 (防止并发重复请求) + └─ 场景隔离 + +3. Registry Cache (RegistryService.ts via RegistryCache.ts) + ├─ TTL: 30 秒 + ├─ 场景隔离 + └─ 命中/未命中/淘汰统计 +``` + +### 缓存失效时机 + +- 单个条目切换 (enable/disable) → `invalidateCache(scene)` +- 批量操作 → `invalidateAllCache()` +- 备份还原 → `invalidateAllCache()` +- 应用启动 → 无缓存,首次加载 + preloadAllScenes 预热 + +--- + +## 启用/禁用逻辑 + +### Classic Shell 条目 + +``` +启用: 删除 LegacyDisable 字符串值 +禁用: 设置 LegacyDisable = "" (空字符串) +``` + +### Shell Ext 条目 + +``` +启用: 重命名键 "-Name" → "Name" +禁用: 重命名键 "Name" → "-Name" +registryKey 不变 (已归一化为不带 '-' 前缀) +``` + +### 事务与回滚 + +```typescript +// 批量操作前: createRollbackPoint(items) +// → 记录所有条目原始 isEnabled 状态 +// 逐条执行 +// 成功: commitTransaction() → 清除回滚数据 +// 失败: rollback() → 逐条恢复原始状态 +``` + +--- + +## 数据结构 + +### 关键接口 + +```typescript +// PS 返回的 Classic Shell 原始数据 +interface PsRawClassicItem { + subKeyName: string; + rawMUIVerb: string | null; + rawDefault: string | null; + rawLocalizedDisplayName: string | null; + rawIcon: string | null; + isEnabled: boolean; + command: string; + registryKey: string; +} + +// PS 返回的 Shell Ext 原始数据 +interface PsRawShellExtItem { + handlerKeyName: string; + cleanName: string; + defaultVal: string; + isEnabled: boolean; + actualClsid: string; + clsidLocalizedString: string | null; + clsidMUIVerb: string | null; + clsidDefault: string | null; + dllPath: string | null; + dllFileDescription: string | null; + dllProductName: string | null; + progIdName: string | null; + siblingMUIVerb: string | null; + registryKey: string; +} + +// 最终输出的菜单条目 +interface MenuItemEntry { + id: number; + name: string; + command: string; + iconPath: string | null; + isEnabled: boolean; + source: string; + menuScene: MenuScene; + registryKey: string; + type: MenuItemType; // System | Custom | ShellExt + dllPath?: string | null; +} + +enum MenuItemType { + System, // 系统内置 (无 command) + Custom, // 自定义 (有 command) + ShellExt, // Shell 扩展 (COM) +} +``` + +--- + +## 诊断与调试 + +### IPC 诊断通道 (`sys:diagnose`) + +设置页隐藏诊断按钮,点击后调用 IPC 返回: + +```json +{ + "koffiAvailable": true, + "resolveIndirectResult": "固定到任务栏", + "uiLanguage": "zh", + "cmdStoreSize": 67 +} +``` + +### ResolveTrace 日志 + +每次 `getMenuItems` 输出每条 ShellExt 条目的完整解析信息(`[ResolveTrace]` 前缀): + +``` +[ResolveTrace] File | "固定到任务栏" ← cleanName="{90AA3A4E-...}" + clsid=... dll=C:\...\shell32.dll clsidDef="Taskband Pin" + clsidLS="" clsidMUI="" progId="" dllDesc="" dllProd="" + siblingMUI="" defVal="Taskband Pin" +``` + +--- + +## 性能优化 + +1. **并发控制**: PowerShellBridge 信号量 (max 3),支持 high/normal 优先级 +2. **缓存分层**: 三级缓存,TTL 递减 (30s → 5min → 2min) +3. **并行读取**: Classic + ShellExt PS 脚本并行执行 +4. **Stale-while-revalidate**: Renderer 缓存命中后台刷新 +5. **预加载**: 启动时串行预热所有 6 个场景 +6. **in-flight 去重**: 避免并发重复请求 +7. **间接字符串缓存**: Win32Shell 内对 `resolveIndirect` 结果无限缓存 + +--- + +## 错误处理 + +1. **单条失败不影响整体**: RegistryService 逐条 try-catch 保护 +2. **PS 失败非致命**: ShellExt 读取失败返回 `[]`,记录 warn 日志 +3. **名称解析失败回退**: 多层 fallback 链,最终回到 cleanName +4. **koffi 初始化失败降级**: `koffiAvailable=false`,`resolveIndirect` 直接返回 null +5. **事务回滚机制**: 批量操作失败自动回滚 +6. **IPC 统一错误包装**: `IpcResult = { success: true, data } | { success: false, error }` diff --git a/docs/shell-extension-name-resolution.md b/docs/shell-extension-name-resolution.md new file mode 100644 index 0000000..5a8ad82 --- /dev/null +++ b/docs/shell-extension-name-resolution.md @@ -0,0 +1,279 @@ +# Shell 扩展名称解析策略 + +## 概述 + +右键菜单 Shell 扩展(`shellex\ContextMenuHandlers`)的名称来源复杂:有的扩展通过 CLSID 的 `LocalizedString` 注册本地化名称,有的只有 DLL 的 `FileDescription`,还有的同时在 `shell` 键注册了 verb。 + +多级回退策略的目标是:**优先使用最精确的本地化名称,对无信息量的字符串过滤并降级,最终兜底到键名**。 + +CmHelper(编译缓存于 `%LOCALAPPDATA%\ContextMaster\CmHelper.dll`)提供两个核心能力: +1. `ResolveIndirect`:调用 `SHLoadIndirectString`,解析 `@dll,-id` 和 `ms-resource:` 格式的间接字符串 +2. `GetLocalizedVerStrings`:读取 DLL 的版本资源并按 UI 语言排序,返回 `[FileDescription, ProductName]` + +当 CmHelper 编译失败时(如 .NET SDK 不可用),这两个路径被阻断,系统退化到 `FileVersionInfo`(仅支持当前线程 locale)和键名兜底。 + +--- + +## 完整解析流程 + +```mermaid +flowchart TD + Start([开始: 处理一个 ContextMenuHandlers 子键]) --> L0 + + L0["Level 0: directName 间接格式\n(@dll,-id 或 ms-resource:)"] + L0 --> |"CmHelper.ResolveIndirect 成功且 ≥2 字符"| Return0([返回本地化名称]) + L0 --> |失败或非间接格式| L1 + + L1["Level 1: CLSID.LocalizedString\n(专为 Shell 扩展设计的字段)"] + L1 --> |"间接格式 → ResolveIndirect"| L1a{成功?} + L1a --> |是| Return1([返回本地化名称]) + L1a --> |否| L1b + L1 --> |"plain 字符串 → Test-IsUselessPlain"| L1b{有用?} + L1b --> |是| Return1b([返回 plain 名称]) + L1b --> |否 或 CLSID 不存在| L13 + + L13["Level 1.3: Sibling Shell Key MUIVerb\n(HKCR:\\type\\shell\\keyName\\MUIVerb)"] + L13 --> |"$shellPath 存在且 sibling key 有 MUIVerb"| L13a{间接格式?} + L13a --> |是 → ResolveIndirect| Return13a([返回本地化名称]) + L13a --> |"否 → Test-IsUselessPlain"| Return13b{有用?} + Return13b --> |是| Return13c([返回 plain 名称]) + Return13b --> |否| L15 + L13 --> |"$shellPath 为空或无 sibling key"| L15 + + L15["Level 1.5: CLSID.MUIVerb\n(部分扩展通过此键注册显示名)"] + L15 --> |"间接格式 → ResolveIndirect"| L15a{成功?} + L15a --> |是| Return15([返回本地化名称]) + L15a --> |否| L15b + L15 --> |"plain 字符串 → Test-IsUselessPlain"| L15b{有用?} + L15b --> |是| Return15b([返回 plain 名称]) + L15b --> |否| L17 + + L17["Level 1.7: CommandStore 反向索引\n(ExplorerCommandHandler = CLSID → MUIVerb)"] + L17 --> |"cmdStoreVerbs 中存在该 CLSID"| Return17([返回已解析的 MUIVerb]) + L17 --> |不存在| L2 + + L2["Level 2: CLSID 默认值 (Default)\n(可靠、ASCII-safe)"] + L2 --> |"Test-IsUselessPlain 通过"| Return2([返回 CLSID 默认值]) + L2 --> |过滤或为空| L25 + + L25["Level 2.5: InprocServer32 DLL\nFileDescription / ProductName"] + L25 --> |"GetLocalizedVerStrings 或 FileVersionInfo\n长度 2-64 且 Test-IsGenericName 通过"| Return25([返回 DLL 描述]) + L25 --> |均失败| L3 + + L3["Level 3: directName plain 字符串\n(优先 CLSID 本地化后再用英文名)"] + L3 --> |"Test-IsUselessPlain 通过"| Return3([返回 directName]) + L3 --> |过滤或为空| Fallback + + Fallback([fallback: 键名]) +``` + +--- + +## 各 Level 详解 + +### Level 0 — directName 间接格式 + +**触发条件**:键的默认值(当键名是 CLSID 格式时使用默认值作为 directName)以 `@` 或 `ms-resource:` 开头。 + +**数据来源**:handler key 的默认值(非 CLSID 格式字符串)。 + +**过滤规则**:`CmHelper.ResolveIndirect` 成功且结果 ≥ 2 字符。 + +**示例**:`{...CLSID...}` 键的默认值为 `@shell32.dll,-8510` → "打开方式"。 + +--- + +### Level 1 — CLSID.LocalizedString + +**触发条件**:CLSID 路径下存在 `LocalizedString` 值。 + +**数据来源**:`HKCR\CLSID\{...}\LocalizedString` + +**过滤规则**: +- 间接格式:`ResolveIndirect` 成功且 ≥ 2 字符 +- plain 字符串:`Test-IsUselessPlain`(含等于键名、泛型描述等检查) + +**设计说明**:`LocalizedString` 是 Windows Shell 专为右键菜单扩展设计的字段,自动支持多语言,是最可靠的本地化来源。`FriendlyTypeName` 已从解析链中移除(它描述 COM 类型,如"外壳服务对象",非用户可见名称)。 + +--- + +### Level 1.3 — Sibling Shell Key MUIVerb *(新增)* + +**触发条件**: +1. `$shellexPath` 以 `\shellex\ContextMenuHandlers` 结尾(`$shellPath` 非空) +2. `HKCR\\shell\` 路径存在 + +**数据来源**:`HKCR\\shell\\MUIVerb`,其中 `` 由 `$shellexPath` 推导,`` 为当前处理的键名(fallback)。 + +**过滤规则**:与 Level 1.5 相同(间接格式走 `ResolveIndirect`,plain 走 `Test-IsUselessPlain`)。 + +**设计说明**:部分扩展(如 gvim)既通过 `shellex\ContextMenuHandlers` 注册 COM 处理器,又通过 `shell\gvim` 注册 static verb,后者的 `MUIVerb` 就是菜单实际显示的文字。此 Level 不依赖 CmHelper 即可获取 plain MUIVerb,提供了一条不受 CmHelper 编译状态影响的可靠路径。 + +**代表案例**: +| 案例 | shellex 路径 | sibling shell 路径 | MUIVerb | +|------|------------|-------------------|---------| +| gvim | `HKCR:\*\shellex\ContextMenuHandlers\gvim` | `HKCR:\*\shell\gvim` | `用Vim编辑` | + +--- + +### Level 1.5 — CLSID.MUIVerb + +**触发条件**:CLSID 路径下存在 `MUIVerb` 值。 + +**数据来源**:`HKCR\CLSID\{...}\MUIVerb` + +**过滤规则**:同 Level 1(间接/plain 分别处理)。 + +--- + +### Level 1.7 — CommandStore 反向索引 + +**触发条件**:CommandStore 预建索引中存在该 CLSID(即该 CLSID 作为某 verb 的 `ExplorerCommandHandler`)。 + +**数据来源**:`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\CommandStore\shell\*\ExplorerCommandHandler` → 对应 verb 的 `MUIVerb`(已在预建时解析)。 + +**设计说明**:适用于通过 `ImplementsVerbs` 注册的新式 shell 扩展(如 Taskband Pin),这类扩展在 CLSID 自身不设置 `LocalizedString`,而是通过 CommandStore 关联到具有 `MUIVerb` 的 verb 定义。 + +--- + +### Level 2 — CLSID 默认值 + +**触发条件**:`HKCR\CLSID\{...}` 的默认值非空。 + +**数据来源**:CLSID 主键的 `(Default)` 值。 + +**过滤规则**:`Test-IsUselessPlain`(含等于键名检查,避免开发者用键名作为 COM 类描述)。 + +--- + +### Level 2.5 — InprocServer32 DLL 版本信息 + +**触发条件**:`HKCR\CLSID\{...}\InprocServer32` 存在且 DLL 文件可访问。 + +**数据来源**: +1. `CmHelper.GetLocalizedVerStrings`:优先使用 UI 语言对应的 Translation 条目(返回 `[FileDescription, ProductName]`) +2. 降级:`System.Diagnostics.FileVersionInfo::GetVersionInfo`(使用线程 locale) + +**过滤规则**: +- 长度:≥ 2 且 ≤ 64 字符 +- `Test-IsGenericName`:排除所有泛型描述(含 Group A–D) + +**代表案例**: +| 案例 | DLL | FileDescription | +|------|-----|----------------| +| YunShellExt | YunShellExt64.dll | 阿里云盘 | +| WinRAR | rarext.dll | WinRAR shell extension → 被 Group A 过滤 | + +--- + +### Level 3 — directName plain 字符串 + +**触发条件**:`directName` 非空且非间接格式(不以 `@`/`ms-resource:` 开头)。 + +**数据来源**:handler key 的默认值(通常是英文名称,如 "Edit with Notepad++")。 + +**过滤规则**:`Test-IsUselessPlain`(含等于键名检查:如果英文名就是键名本身,无额外信息量则过滤)。 + +**设计说明**:Level 3 在 CLSID 查询链之后,确保优先使用本地化名称;仅当所有 CLSID 来源均失败时,才使用英文 directName。 + +--- + +### Fallback — 键名 + +当所有 Level 均失败时,返回 `$fallback`(处理程序键名),如 `gvim`、`YunShellExt`。 + +--- + +## 过滤函数说明 + +### Test-IsGenericName + +判断字符串是否为无意义的泛型描述,返回 `$true` 表示应过滤: + +| 规则组 | 匹配示例 | 说明 | +|--------|---------|------| +| Group A | `context menu`、`shell extension`、`外壳服务对象` | COM/Shell 技术内部描述 | +| Group A | `Vim Shell Extension` | "* Shell Extension" 后缀(COM 类描述) | +| Group A | `microsoft windows *` | 系统内部描述 | +| Group A | `*.dll` | 文件名(非友好名称) | +| Group B | `* Class` | COM 类名(如 `PcyybContextMenu Class`) | +| Group C | `TODO: ` | 未完成的占位符 | +| Group C | `` | 尖括号模板占位符 | +| Group C | `n/a`、`none`、`unknown` | 通用无效值 | +| Group D | `A small project for the context menu of gvim!` | 冠词(a/an/the)开头的句子 | +| Group D | `(调试)`、`(Debug)` | 括号完全包裹的调试/临时标记 | + +### Test-IsUselessPlain + +在 `Test-IsGenericName` 基础上额外检查: +- 字符串为空或长度 < 2 +- 字符串(不区分大小写)等于键名(fallback)—— 开发者用键名作占位符 + +--- + +## 代表案例分析 + +### gvim — CmHelper 失败时的完整降级链 + +``` +Level 0 : @gvimext.dll,-101 → CmHelper 失败 → skip +Level 1 : CLSID.LocalizedString 不存在 → skip +Level 1.3: HKCR:\*\shell\gvim\MUIVerb = "用Vim编辑" → 返回 ✓ +``` + +若 Level 1.3 也失败(sibling key 不存在): +``` +Level 1.5: CLSID.MUIVerb 不存在 → skip +Level 1.7: CommandStore 无索引 → skip +Level 2 : CLSID.Default = "gvim" → 等于键名 → Test-IsUselessPlain 过滤 +Level 2.5: gvimext.dll FileDescription = "Vim Shell Extension" → Group A 过滤 +Level 3 : directName 不存在 → skip +Fallback : "gvim" +``` + +### Open With(打开方式)— LocalizedString 路径 + +``` +Level 0 : @shell32.dll,-8510 → CmHelper 正常 → "打开方式" ✓ +``` + +CmHelper 失败时: +``` +Level 1 : CLSID.LocalizedString = @shell32.dll,-8510 → CmHelper 失败 → skip +Level 1.3: 无 sibling shell key → skip +Level 1.5: CLSID.MUIVerb 若有 "(调试)" → Group D 括号过滤 → skip +Level 2.5: shell32.dll FileDescription = "Windows Shell Common Dll" → Group A 过滤 +Fallback : 键名 +``` + +### YunShellExt — DLL 路径 + +``` +Level 1 : CLSID.LocalizedString 不存在 → skip +Level 1.3: 无 sibling shell key → skip +Level 1.5: CLSID.MUIVerb 不存在 → skip +Level 2 : CLSID.Default 不存在 → skip +Level 2.5: YunShellExt64.dll FileDescription = "阿里云盘" ✓ +``` + +### Taskband Pin — CommandStore 路径 + +``` +Level 1 : CLSID.LocalizedString 不存在 → skip +Level 1.3: 无 sibling shell key → skip +Level 1.5: CLSID.MUIVerb 不存在 → skip +Level 1.7: cmdStoreVerbs[CLSID] = "固定到任务栏" ✓ +``` + +--- + +## CmHelper 编译与缓存 + +CmHelper 是一个 C# 类,在脚本运行时动态编译(或从缓存加载)。缓存路径:`%LOCALAPPDATA%\ContextMaster\CmHelper.dll`。 + +**版本校验**:加载 DLL 后立即检查 `[CmHelper]::Ver == "2026.3"`,不匹配时重新编译。这确保代码变更后自动更新缓存。 + +**编译失败时的降级行为**: +- `ResolveIndirect` 不可用 → Level 0、Level 1(间接格式)、Level 1.3(间接格式)、Level 1.5(间接格式)失败 +- `GetLocalizedVerStrings` 不可用 → Level 2.5 降级为 `FileVersionInfo`(仅当前 locale) +- 其余 Level(plain 字符串路径)不受影响 diff --git a/package.json b/package.json index 9e5aea6..7416673 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "better-sqlite3": "^11.0.0", "electron-log": "^5.2.0", "i18next": "^25.8.18", - "i18next-http-backend": "^3.0.2" + "i18next-http-backend": "^3.0.2", + "koffi": "^2.16.1" }, "devDependencies": { "@electron-forge/cli": "^7.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3c9547..625940f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: i18next-http-backend: specifier: ^3.0.2 version: 3.0.2(encoding@0.1.13) + koffi: + specifier: ^2.16.1 + version: 2.16.1 devDependencies: '@electron-forge/cli': specifier: ^7.6.0 @@ -2010,6 +2013,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + koffi@2.16.1: + resolution: {integrity: sha512-0Ie6CfD026dNfWSosDw9dPxPzO9Rlyo0N8m5r05S8YjytIpuilzMFDMY4IDy/8xQsTwpuVinhncD+S8n3bcYZQ==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -5493,6 +5499,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + koffi@2.16.1: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9d37343..280b9e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,5 @@ onlyBuiltDependencies: - electron - electron-winstaller - esbuild + - koffi - lzma-native diff --git a/src/main/index.ts b/src/main/index.ts index 926c28b..e4256af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,13 @@ import { app, BrowserWindow } from 'electron'; import path from 'path'; import { initLogger } from './utils/logger'; +import log from './utils/logger'; import { getDatabase, closeDatabase } from './data/Database'; import { PowerShellBridge } from './services/PowerShellBridge'; +import { MenuScene } from '../shared/enums'; import { RegistryService } from './services/RegistryService'; +import { ShellExtNameResolver, CommandStoreIndex } from './services/ShellExtNameResolver'; +import { Win32Shell } from './services/Win32Shell'; import { OperationRecordRepo } from './data/repositories/OperationRecordRepo'; import { BackupSnapshotRepo } from './data/repositories/BackupSnapshotRepo'; import { OperationHistoryService } from './services/OperationHistoryService'; @@ -54,27 +58,49 @@ function createWindow(): void { } } -function initServices(): void { +function initServices(): MenuManagerService { const db = getDatabase(); const ps = new PowerShellBridge(); - const registry = new RegistryService(ps); + const win32Shell = new Win32Shell(); + const resolver = new ShellExtNameResolver(win32Shell, win32Shell.uiLanguage); + const cmdStoreIndex = new CommandStoreIndex(); + const registry = new RegistryService(ps, resolver, cmdStoreIndex); const opRepo = new OperationRecordRepo(db); const bkRepo = new BackupSnapshotRepo(db); const history = new OperationHistoryService(opRepo); const menuManager = new MenuManagerService(registry, history); const backup = new BackupService(bkRepo, menuManager, history); + // 异步构建 CommandStore 索引(不阻塞启动) + ps.execute>(ps.buildCommandStoreScript()) + .then(entries => { + cmdStoreIndex.buildFromData(entries); + log.info(`[Init] CommandStore index built: ${entries.length} entries`); + }) + .catch(e => log.warn('[Init] CommandStore index build failed:', e)); + registerRegistryHandlers(menuManager); registerHistoryHandlers(history, menuManager); registerBackupHandlers(backup); - registerSystemHandlers(); + registerSystemHandlers(win32Shell, () => cmdStoreIndex.size); + return menuManager; } app.whenReady().then(() => { initLogger(); - initServices(); + const menuManager = initServices(); createWindow(); + // 串行预热:Desktop 优先,其余依次执行,避免饱和 PS 槽导致用户请求等待 + void (async () => { + await menuManager.getMenuItems(MenuScene.Desktop).catch(e => log.warn('[Preload] Desktop failed:', e)); + const rest = Object.values(MenuScene).filter(s => s !== MenuScene.Desktop) as MenuScene[]; + for (const s of rest) { + await menuManager.getMenuItems(s).catch(() => null); + } + log.info('[Preload] All scenes preloaded'); + })(); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/src/main/ipc/backup.ts b/src/main/ipc/backup.ts index 6863ee9..675a9b3 100644 --- a/src/main/ipc/backup.ts +++ b/src/main/ipc/backup.ts @@ -43,7 +43,8 @@ export function registerBackupHandlers(backup: BackupService): void { IPC.BACKUP_EXPORT, wrapHandler(async (event: Electron.IpcMainInvokeEvent, snapshotId: number) => { log.info(`[Backup] Exporting backup: snapshotId=${snapshotId}`); - const win = BrowserWindow.fromWebContents(event.sender)!; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) throw new Error('当前窗口已关闭,无法进行文件操作'); await backup.exportBackup(snapshotId, win); return true; }) @@ -53,7 +54,8 @@ export function registerBackupHandlers(backup: BackupService): void { IPC.BACKUP_IMPORT, wrapHandler(async (event: Electron.IpcMainInvokeEvent) => { log.info('[Backup] Importing backup'); - const win = BrowserWindow.fromWebContents(event.sender)!; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) throw new Error('当前窗口已关闭,无法进行文件操作'); return backup.importBackup(win); }) ); diff --git a/src/main/ipc/registry.ts b/src/main/ipc/registry.ts index 0f655cb..d6efea3 100644 --- a/src/main/ipc/registry.ts +++ b/src/main/ipc/registry.ts @@ -11,7 +11,7 @@ export function registerRegistryHandlers(menuManager: MenuManagerService): void IPC.REGISTRY_GET_ITEMS, wrapHandler((_event: unknown, scene: MenuScene) => { log.debug(`[Registry] Getting items for scene: ${scene}`); - return menuManager.getMenuItems(scene); + return menuManager.getMenuItems(scene, false, 'high'); }) ); diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 4b596a4..58de76b 100644 --- a/src/main/ipc/system.ts +++ b/src/main/ipc/system.ts @@ -5,10 +5,14 @@ import { IPC } from '../../shared/ipc-channels'; import { isAdmin, restartAsAdmin } from '../utils/AdminHelper'; import { wrapHandler } from '../utils/ipcWrapper'; import log, { getLogDir } from '../utils/logger'; +import type { IWin32Shell } from '../services/Win32Shell'; const execFileAsync = promisify(execFile); -export function registerSystemHandlers(): void { +export function registerSystemHandlers( + win32Shell?: IWin32Shell, + getCmdStoreSize?: () => number, +): void { ipcMain.handle( IPC.SYS_IS_ADMIN, wrapHandler(() => { @@ -114,6 +118,39 @@ export function registerSystemHandlers(): void { }) ); + ipcMain.handle( + IPC.SYS_DIAGNOSE, + wrapHandler(() => { + const result: Record = { + koffiAvailable: false, + resolveIndirectResult: null, + resolveIndirectError: null, + uiLanguage: 'unknown', + cmdStoreSize: getCmdStoreSize ? getCmdStoreSize() : 0, + }; + + if (!win32Shell) { + result.resolveIndirectError = 'Win32Shell not injected'; + return result; + } + + result.koffiAvailable = true; + result.uiLanguage = win32Shell.uiLanguage; + + // 测试 SHLoadIndirectString + try { + const resolved = win32Shell.resolveIndirect('@shell32.dll,-37423'); + result.resolveIndirectResult = resolved; + log.info(`[Diagnose] resolveIndirect test: "${resolved}"`); + } catch (e) { + result.resolveIndirectError = String(e); + log.error('[Diagnose] resolveIndirect test failed:', e); + } + + return result; + }) + ); + ipcMain.handle( IPC.WIN_MINIMIZE, wrapHandler(() => { diff --git a/src/main/services/BackupService.ts b/src/main/services/BackupService.ts index 9e1425b..db2fd2c 100644 --- a/src/main/services/BackupService.ts +++ b/src/main/services/BackupService.ts @@ -25,11 +25,9 @@ export class BackupService { async createBackup(name: string, type = BackupType.Manual): Promise { const start = Date.now(); - const allItems: MenuItemEntry[] = []; - for (const scene of Object.values(MenuScene)) { - const items = await this.menuManager.getMenuItems(scene); - allItems.push(...items); - } + const scenes = Object.values(MenuScene) as MenuScene[]; + const itemsByScene = await Promise.all(scenes.map((s) => this.menuManager.getMenuItems(s))); + const allItems: MenuItemEntry[] = itemsByScene.flat(); const jsonData = JSON.stringify(allItems); const checksum = createHash('sha256').update(jsonData).digest('hex'); @@ -95,8 +93,9 @@ export class BackupService { async deleteBackup(id: number): Promise { const snapshot = this.repo.findById(id); + if (!snapshot) throw new Error(`备份快照不存在: id=${id}`); this.repo.delete(id); - log.warn(`[Backup] Deleted backup: id=${id}, name=${snapshot?.name ?? 'unknown'}`); + log.warn(`[Backup] Deleted backup: id=${id}, name=${snapshot.name}`); } getAllBackups(): BackupSnapshot[] { @@ -155,6 +154,15 @@ export class BackupService { const filePath = filePaths[0]; const jsonData = await fs.readFile(filePath, 'utf-8'); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonData); + } catch { + throw new Error('导入文件不是有效的 JSON 格式'); + } + if (!Array.isArray(parsed)) throw new Error('导入文件格式无效:必须是数组'); + const checksum = createHash('sha256').update(jsonData).digest('hex'); const snapshot = this.repo.insert({ diff --git a/src/main/services/MenuManagerService.ts b/src/main/services/MenuManagerService.ts index 43d664b..fc532a8 100644 --- a/src/main/services/MenuManagerService.ts +++ b/src/main/services/MenuManagerService.ts @@ -13,32 +13,46 @@ const CACHE_TTL = 5 * 60 * 1000; export class MenuManagerService { private cache = new Map(); + private inFlight = new Map>(); constructor( private readonly registry: RegistryService, private readonly history: OperationHistoryService ) {} - async getMenuItems(scene: MenuScene, forceRefresh = false): Promise { + async getMenuItems(scene: MenuScene, forceRefresh = false, priority: 'high' | 'normal' = 'normal'): Promise { if (!forceRefresh) { const cached = this.cache.get(scene); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { log.debug(`[MenuManager] Cache hit for scene: ${scene}`); return cached.items; } + const existing = this.inFlight.get(scene); + if (existing) { + log.debug(`[MenuManager] In-flight hit for scene: ${scene}`); + return existing; + } } log.debug(`[MenuManager] Loading items for scene: ${scene} (forceRefresh: ${forceRefresh})`); const start = Date.now(); - const items = await this.registry.getMenuItems(scene); - const elapsed = Date.now() - start; - - if (elapsed > 100) { - log.info(`[MenuManager] Loaded ${items.length} items for ${scene} in ${elapsed}ms`); - } + const promise = this.registry.getMenuItems(scene, priority) + .then((items) => { + const elapsed = Date.now() - start; + if (elapsed > 100) { + log.info(`[MenuManager] Loaded ${items.length} items for ${scene} in ${elapsed}ms`); + } + this.cache.set(scene, { items, timestamp: Date.now() }); + this.inFlight.delete(scene); + return items; + }) + .catch((e) => { + this.inFlight.delete(scene); + throw e; + }); - this.cache.set(scene, { items, timestamp: Date.now() }); - return items; + this.inFlight.set(scene, promise); + return promise; } invalidateCache(scene?: MenuScene): void { @@ -51,11 +65,49 @@ export class MenuManagerService { } } + /** + * 获取所有场景的菜单条目(并行加载) + */ + async getAllMenuItems(): Promise> { + const scenes = Object.values(MenuScene) as MenuScene[]; + const results = await Promise.all( + scenes.map(async (scene) => { + try { + const items = await this.registry.getMenuItems(scene); + return { scene, items, success: true }; + } catch (e) { + log.error(`Failed to load scene ${scene}:`, e); + return { scene, items: [] as MenuItemEntry[], success: false }; + } + }) + ); + + const allItems: Record = { + [MenuScene.Desktop]: [], + [MenuScene.File]: [], + [MenuScene.Folder]: [], + [MenuScene.Drive]: [], + [MenuScene.DirectoryBackground]: [], + [MenuScene.RecycleBin]: [], + }; + + for (const result of results) { + allItems[result.scene] = result.items; + } + + return allItems; + } + async enableItem(item: MenuItemEntry): Promise<{ newRegistryKey?: string }> { if (item.isEnabled) return {}; const result = await this.registry.setItemEnabled(item.registryKey, true); if (result.newRegistryKey) item.registryKey = result.newRegistryKey; item.isEnabled = true; + + // 操作成功后清除对应场景的缓存 + this.registry.invalidateCache(item.menuScene); + log.debug(`Cache invalidated for scene ${item.menuScene} after enabling ${item.name}`); + this.history.recordOperation( OperationType.Enable, item.name, @@ -72,6 +124,11 @@ export class MenuManagerService { const result = await this.registry.setItemEnabled(item.registryKey, false); if (result.newRegistryKey) item.registryKey = result.newRegistryKey; item.isEnabled = false; + + // 操作成功后清除对应场景的缓存 + this.registry.invalidateCache(item.menuScene); + log.debug(`Cache invalidated for scene ${item.menuScene} after disabling ${item.name}`); + this.history.recordOperation( OperationType.Disable, item.name, @@ -95,10 +152,14 @@ export class MenuManagerService { const targets = items.filter((i) => !i.isEnabled); if (!targets.length) return; + // 收集需要清除缓存的场景 + const affectedScenes = new Set(); + this.registry.createRollbackPoint(targets); try { for (const item of targets) { await this.enableItem(item); + affectedScenes.add(item.menuScene); } this.registry.commitTransaction(); this.cache.clear(); @@ -112,10 +173,14 @@ export class MenuManagerService { const targets = items.filter((i) => i.isEnabled); if (!targets.length) return; + // 收集需要清除缓存的场景 + const affectedScenes = new Set(); + this.registry.createRollbackPoint(targets); try { for (const item of targets) { await this.disableItem(item); + affectedScenes.add(item.menuScene); } this.registry.commitTransaction(); this.cache.clear(); @@ -124,4 +189,34 @@ export class MenuManagerService { throw new Error(`批量禁用失败,已回滚: ${(e as Error).message}`); } } + + /** + * 后台预热所有场景(fire-and-forget,依赖 PowerShellBridge 信号量控制并发) + * 结果写入内存缓存,供后续 IPC 请求直接命中 + */ + async preloadAllScenes(): Promise { + const scenes = Object.values(MenuScene) as MenuScene[]; + await Promise.all( + scenes.map((scene) => + this.getMenuItems(scene).catch((e) => + log.warn(`[MenuManager] Preload failed for ${scene}:`, e) + ) + ) + ); + log.info('[MenuManager] All scenes preloaded'); + } + + /** + * 获取缓存统计信息 + */ + getCacheStats(): ReturnType { + return this.registry.getCacheStats(); + } + + /** + * 打印缓存统计日志 + */ + logCacheStats(): void { + this.registry.logCacheStats(); + } } diff --git a/src/main/services/OperationHistoryService.ts b/src/main/services/OperationHistoryService.ts index f5e93ce..814eb90 100644 --- a/src/main/services/OperationHistoryService.ts +++ b/src/main/services/OperationHistoryService.ts @@ -75,15 +75,11 @@ export class OperationHistoryService { } function determineSceneFromRegistryKey(registryKey: string): MenuScene { + if (registryKey.includes('Directory\\Background')) return MenuScene.DirectoryBackground; if (registryKey.includes('DesktopBackground')) return MenuScene.Desktop; - if (registryKey.includes('*\\')) return MenuScene.File; - if ( - registryKey.includes('Directory\\shell') && - !registryKey.includes('Directory\\Background') - ) - return MenuScene.Folder; + if (registryKey.includes('CLSID\\{645FF040')) return MenuScene.RecycleBin; if (registryKey.includes('Drive\\shell')) return MenuScene.Drive; - if (registryKey.includes('Directory\\Background')) return MenuScene.DirectoryBackground; - if (registryKey.includes('CLSID')) return MenuScene.RecycleBin; - return MenuScene.File; + if (registryKey.includes('Directory\\shell')) return MenuScene.Folder; + if (registryKey.includes('*\\')) return MenuScene.File; + throw new Error(`无法从注册表路径确定场景: ${registryKey}`); } diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index c017406..ed74446 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -13,29 +13,79 @@ const PWSH7_PATH = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; const PS_EXE = fs.existsSync(PWSH7_PATH) ? PWSH7_PATH : 'powershell.exe'; export class PowerShellBridge { + private pending = 0; + private maxConcurrent = 3; + private readonly waitQueue: Array<() => void> = []; + + private slotWaitTimeoutMs = 30000; + + private async acquireSlot(priority: 'high' | 'normal' = 'normal'): Promise { + if (this.pending < this.maxConcurrent) { + this.pending++; + return; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this.waitQueue.indexOf(cb); + if (idx !== -1) this.waitQueue.splice(idx, 1); + reject(new Error('PowerShell 任务等待超时,请稍后重试')); + }, this.slotWaitTimeoutMs); + const cb = () => { + clearTimeout(timer); + this.pending++; + resolve(); + }; + if (priority === 'high') this.waitQueue.unshift(cb); + else this.waitQueue.push(cb); + }); + } + + private releaseSlot(): void { + this.pending--; + const next = this.waitQueue.shift(); + if (next) next(); + } + + setMaxConcurrent(n: number): void { + this.maxConcurrent = Math.max(1, n); + // 立即唤醒队列中符合新上限的 waiter + while (this.pending < this.maxConcurrent && this.waitQueue.length > 0) { + const next = this.waitQueue.shift(); + if (next) { next(); } + } + } + /** * 执行 PowerShell 脚本并将 stdout 解析为 JSON + * 信号量限制最多 maxConcurrent 个进程并发,其余排队等待 */ - async execute(script: string): Promise { - log.debug('[PS] execute:', script.substring(0, 200)); - const { stdout, stderr } = await execFileAsync( - PS_EXE, - ['-NonInteractive', '-NoProfile', '-OutputFormat', 'Text', '-Command', script], - { maxBuffer: 10 * 1024 * 1024, timeout: 30000 } - ); + async execute(script: string, priority: 'high' | 'normal' = 'normal'): Promise { + await this.acquireSlot(priority); + try { + log.debug('[PS] execute:', script.substring(0, 200)); + // 强制 PS 输出 UTF-8 编码,避免中文乱码 + const utf8Script = '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n' + script; + const { stdout, stderr } = await execFileAsync( + PS_EXE, + ['-NonInteractive', '-NoProfile', '-OutputFormat', 'Text', '-Command', utf8Script], + { maxBuffer: 10 * 1024 * 1024, timeout: 30000 } + ); - if (stderr) { - log.warn('[PS] stderr:', stderr); - } + if (stderr) { + log.warn('[PS] stderr:', stderr); + } - const trimmed = stdout.trim(); - if (!trimmed) return [] as unknown as T; + const trimmed = stdout.trim(); + if (!trimmed) return [] as unknown as T; - try { - return JSON.parse(trimmed) as T; - } catch (e) { - log.error('[PS] JSON parse error. stdout:', trimmed.substring(0, 500)); - throw new Error(`PowerShell 输出 JSON 解析失败: ${String(e)}`); + try { + return JSON.parse(trimmed) as T; + } catch (e) { + log.error('[PS] JSON parse error. stdout:', trimmed.substring(0, 500)); + throw new Error(`PowerShell 输出 JSON 解析失败: ${String(e)}`); + } + } finally { + this.releaseSlot(); } } @@ -43,9 +93,9 @@ export class PowerShellBridge { * 以提权方式执行脚本(非管理员时弹出 UAC 对话框) * 管理员身份下直接 fallback 到 execute() */ - async executeElevated(script: string): Promise { + async executeElevated(script: string, priority: 'high' | 'normal' = 'normal'): Promise { if (isAdmin()) { - return this.execute(script); + return this.execute(script, priority); } const uid = crypto.randomUUID(); @@ -55,6 +105,7 @@ export class PowerShellBridge { // 包装原始脚本:捕获原始 JSON 输出(脚本本身已输出 JSON),写入 resultFile const resultFilePs = resultFile.replace(/'/g, "''"); const wrappedScript2 = ` +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'Stop' try { $__out = & { @@ -84,18 +135,18 @@ ${script} ); } finally { try { fs.unlinkSync(opScript); } catch { /* ignore */ } - } - - if (!fs.existsSync(resultFile)) { - throw new Error('操作已取消(UAC 提权被拒绝)'); + if (!fs.existsSync(resultFile)) { + throw new Error('操作已取消(UAC 提权被拒绝)'); + } } let resultJson: string; try { resultJson = fs.readFileSync(resultFile, 'utf8').trim(); - fs.unlinkSync(resultFile); } catch { throw new Error('读取操作结果失败'); + } finally { + try { fs.unlinkSync(resultFile); } catch { /* ignore */ } } let parsed: unknown; @@ -113,11 +164,11 @@ ${script} } /** - * 构建扫描指定注册表路径下所有子键的脚本 - * 返回 JSON 数组,每项含菜单条目信息 + * 构建扫描 Classic Shell 注册表路径的脚本 + * 仅返回原始注册表值(MUIVerb/Default/LocalizedDisplayName), + * 不执行间接字符串解析。解析逻辑由 TypeScript 侧 ShellExtNameResolver 完成。 */ buildGetItemsScript(hkcrSubPath: string): string { - // PS 单对象会返回哈希表而非数组,用 @(...) 强制数组 return ` $ErrorActionPreference = 'SilentlyContinue' New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null @@ -127,8 +178,6 @@ $subKeys = Get-ChildItem -LiteralPath $basePath | Where-Object { $_.PSIsContaine $result = @($subKeys | ForEach-Object { $key = $_ $keyName = $key.PSChildName - $name = $key.GetValue('') - if (-not $name) { $name = $keyName } $iconPath = $key.GetValue('Icon') $isEnabled = ($key.GetValue('LegacyDisable') -eq $null) $commandSubKey = Join-Path $key.PSPath 'command' @@ -139,13 +188,14 @@ $result = @($subKeys | ForEach-Object { } $regKey = '${hkcrSubPath}\\' + $keyName [PSCustomObject]@{ - name = [string]$name - command = [string]$command - iconPath = if ($iconPath) { [string]$iconPath } else { $null } - isEnabled = [bool]$isEnabled - source = '' - registryKey = [string]$regKey - subKeyName = [string]$keyName + subKeyName = [string]$keyName + rawMUIVerb = if ($key.GetValue('MUIVerb')) { [string]$key.GetValue('MUIVerb') } else { $null } + rawDefault = if ($key.GetValue('')) { [string]$key.GetValue('') } else { $null } + rawLocalizedDisplayName = if ($key.GetValue('LocalizedDisplayName')) { [string]$key.GetValue('LocalizedDisplayName') } else { $null } + rawIcon = if ($iconPath) { [string]$iconPath } else { $null } + isEnabled = [bool]$isEnabled + command = [string]$command + registryKey = [string]$regKey } }) $result | ConvertTo-Json -Compress -Depth 3 @@ -188,160 +238,151 @@ Write-Output '{"ok":true}' /** * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 - * 使用四级级联策略解析本地化名称: - * 1. LocalizedString/FriendlyTypeName → SHLoadIndirectString(解析 @DLL,-ID 格式) - * 2. InprocServer32 DLL 字符串表 → 通用字符串质量筛选(LoadLibraryEx + LoadString) - * 3. CLSID 默认值 - * 4. 处理程序键名(最终兜底) - * CmHelper.dll 编译后缓存至 %LOCALAPPDATA%\ContextMaster\,避免重复编译开销 + * 仅读取原始注册表数据(键名、CLSID、LocalizedString、MUIVerb、DLL 路径等), + * 不执行名称解析。解析逻辑由 TypeScript 侧 ShellExtNameResolver 完成。 */ buildGetShellExtItemsScript(shellexSubPath: string): string { return ` $ErrorActionPreference = 'SilentlyContinue' New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null -$cmDir = Join-Path $env:LOCALAPPDATA 'ContextMaster' -$cmDll = Join-Path $cmDir 'CmHelper.dll' -$helperLoaded = $false -if (Test-Path $cmDll) { - try { Add-Type -Path $cmDll -ErrorAction Stop; $helperLoaded = $true } catch {} -} -if (-not $helperLoaded) { - $src = @' -using System; -using System.Runtime.InteropServices; -using System.Text; -using System.Collections.Generic; -public class CmHelper { - const uint LOAD_AS_DATA = 2u; - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - static extern IntPtr LoadLibraryEx(string p, IntPtr h, uint f); - [DllImport("kernel32.dll")] - static extern bool FreeLibrary(IntPtr h); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - static extern int LoadString(IntPtr h, uint id, StringBuilder buf, int cap); - public static string ResolveIndirect(string s) { - if (string.IsNullOrEmpty(s) || !s.StartsWith("@")) return null; - var sb = new StringBuilder(512); - return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; - } - public static string[] ReadDllStrings(string dll, uint from, uint to) { - var list = new List(); - var hMod = LoadLibraryEx(dll, IntPtr.Zero, LOAD_AS_DATA); - if (hMod == IntPtr.Zero) return list.ToArray(); - try { - for (uint i = from; i <= to; i++) { - var sb = new StringBuilder(512); - if (LoadString(hMod, i, sb, 512) > 0) list.Add(sb.ToString()); - } - } finally { FreeLibrary(hMod); } - return list.ToArray(); - } +$shellexPath = 'HKCR:\\${shellexSubPath}' +if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } +# 推导 sibling shell 路径 +$shellPath = $null +if ($shellexPath -match '\\\\shellex\\\\ContextMenuHandlers$') { + $shellPath = $shellexPath -replace '\\\\shellex\\\\ContextMenuHandlers$', '\\shell' } -'@ - if (-not (Test-Path $cmDir)) { New-Item -Path $cmDir -ItemType Directory -Force | Out-Null } - if (Test-Path $cmDll) { Remove-Item -Path $cmDll -Force -ErrorAction SilentlyContinue } - try { - Add-Type -TypeDefinition $src -OutputAssembly $cmDll -ErrorAction Stop - $helperLoaded = $true - } catch { - try { Add-Type -TypeDefinition $src -ErrorAction Stop; $helperLoaded = $true } catch {} +$handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } +$result = @($handlers | ForEach-Object { + $handlerKeyName = $_.PSChildName + $defaultVal = $_.GetValue('') + $cleanName = $handlerKeyName -replace '^-+', '' + $actualClsid = $cleanName + if ($cleanName -notmatch '^\\{[0-9A-Fa-f-]+\\}$' -and + $defaultVal -match '^\\{[0-9A-Fa-f-]+\\}$') { + $actualClsid = $defaultVal } -} -function Resolve-ExtName($clsid, $fallback) { - if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { - $clsidPath = 'HKCR:\\CLSID\\' + $clsid + # 读取 CLSID 子键原始值 + $clsidLocalizedString = $null + $clsidMUIVerb = $null + $clsidDefault = $null + $dllPath = $null + if ($actualClsid -match '^\\{[0-9A-Fa-f-]+\\}$') { + $clsidPath = 'HKCR:\\CLSID\\' + $actualClsid if (Test-Path -LiteralPath $clsidPath) { $clsidKey = Get-Item -LiteralPath $clsidPath - foreach ($valName in @('LocalizedString', 'FriendlyTypeName')) { - $raw = $clsidKey.GetValue($valName) - if ($raw) { - if ($raw.StartsWith('@')) { - try { - $resolved = [CmHelper]::ResolveIndirect($raw) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } elseif ($raw.Length -ge 2) { - return $raw - } + if ($clsidKey.GetValue('LocalizedString')) { $clsidLocalizedString = [string]$clsidKey.GetValue('LocalizedString') } + if ($clsidKey.GetValue('MUIVerb')) { $clsidMUIVerb = [string]$clsidKey.GetValue('MUIVerb') } + if ($clsidKey.GetValue('')) { $clsidDefault = [string]$clsidKey.GetValue('') } + $inprocPath = $clsidPath + '\\InprocServer32' + if (Test-Path -LiteralPath $inprocPath) { + $dllRaw = (Get-Item -LiteralPath $inprocPath).GetValue('') + if ($dllRaw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllRaw) } + } + # CLSID\Shell 子键的 MUIVerb(COM 对象自身注册的 verb) + $clsidShellPath = $clsidPath + '\\Shell' + if (Test-Path -LiteralPath $clsidShellPath) { + Get-ChildItem -LiteralPath $clsidShellPath -ErrorAction SilentlyContinue | ForEach-Object { + $shellMv = $_.GetValue('MUIVerb') + if ($shellMv -and -not $clsidMUIVerb) { $clsidMUIVerb = [string]$shellMv } } } - $inprocPath = Join-Path $clsidPath 'InprocServer32' - if (Test-Path -LiteralPath $inprocPath) { - $dllPath = (Get-Item -LiteralPath $inprocPath).GetValue('') - # Fix 1: 展开 %SystemRoot% 等环境变量,否则 Test-Path 永远返回 $false - if ($dllPath) { - $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllPath) + # ProgID → 应用程序名(用于 Level 1.6) + $progIdVal = $clsidKey.GetValue('ProgID') + if ($progIdVal) { + $progIdPath = 'HKCR:\' + $progIdVal + if (Test-Path -LiteralPath $progIdPath) { + $progIdDef = (Get-Item -LiteralPath $progIdPath).GetValue('') + if ($progIdDef -and $progIdDef.Length -ge 2) { $progIdName = [string]$progIdDef } } - if ($dllPath -and (Test-Path -LiteralPath $dllPath)) { - # Level 2: 通用字符串质量筛选(过滤后取第一条,无硬编码词表,无长度限制) - try { - $candidates = [CmHelper]::ReadDllStrings($dllPath, 1, 1000) | - Where-Object { - $_.Length -ge 2 -and - $_ -notmatch '[\\\\/:*?<>|]' -and - $_ -notmatch '^\\{' -and - $_ -notmatch '^https?://' -and - $_ -notmatch '%[0-9A-Za-z]' -and - $_ -notmatch '\\{[0-9]+\\}' -and - $_ -notmatch '[\\r\\n\\t]' -and - $_ -notmatch '^[0-9]' -and - $_ -notmatch '[\\u3002\\uff01\\uff1f]' -and - $_ -notmatch '[.!?]$' - } - $pool = $candidates | Where-Object { $_ -match '[^\\x00-\\x7F]' } - if (-not $pool) { $pool = $candidates } - $best = $pool | Select-Object -First 1 - if ($best) { return $best } - } catch {} - # Level 2.5: DLL VersionInfo(适用于英文/日文等非中文软件) - try { - $ver = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllPath) - $desc = $null - if ($ver.FileDescription -and $ver.FileDescription.Length -ge 2) { - $desc = $ver.FileDescription - } elseif ($ver.ProductName -and $ver.ProductName.Length -ge 2) { - $desc = $ver.ProductName - } - if ($desc -and $desc.Length -le 80 -and $desc -notmatch '^\\{' -and $desc -notmatch '[\\\\/:*?<>|]') { - return $desc - } - } catch {} + } + } + } + # DLL 版本资源(.NET FileVersionInfo,天然支持 UI 语言,无需 koffi) + $dllFileDescription = $null + $dllProductName = $null + if ($dllPath -and (Test-Path -LiteralPath $dllPath -PathType Leaf)) { + try { + $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllPath) + if ($vi.FileDescription -and $vi.FileDescription.Length -ge 2) { + $dllFileDescription = [string]$vi.FileDescription + } + if ($vi.ProductName -and $vi.ProductName.Length -ge 2) { + $dllProductName = [string]$vi.ProductName + } + } catch {} + } + # sibling shell key MUIVerb + $siblingMUIVerb = $null + if ($shellPath) { + $siblingVerbPath = Join-Path $shellPath $cleanName + if (Test-Path -LiteralPath $siblingVerbPath) { + $smv = (Get-Item -LiteralPath $siblingVerbPath).GetValue('MUIVerb') + if ($smv) { $siblingMUIVerb = [string]$smv } + } + # 回退:反向扫描 shell verbs,查找 CommandStateHandler/DelegateExecute = $actualClsid + if (-not $siblingMUIVerb -and $actualClsid) { + Get-ChildItem -LiteralPath $shellPath -ErrorAction SilentlyContinue | ForEach-Object { + $csh = $_.GetValue('CommandStateHandler') + $de = $_.GetValue('DelegateExecute') + $ech = $_.GetValue('ExplorerCommandHandler') + if (($csh -eq $actualClsid) -or ($de -eq $actualClsid) -or ($ech -eq $actualClsid)) { + $mv = $_.GetValue('MUIVerb') + if ($mv) { $siblingMUIVerb = [string]$mv } } } - $def = $clsidKey.GetValue('') - if ($def) { return [string]$def } } } - return $fallback -} -$shellexPath = 'HKCR:\\${shellexSubPath}' -if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } -$handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } -$result = @($handlers | ForEach-Object { - $handlerKeyName = $_.PSChildName - $clsid = $_.GetValue('') - if (-not $clsid) { $clsid = $handlerKeyName } - $cleanName = $handlerKeyName -replace '^-+', '' - $displayName = Resolve-ExtName $clsid $cleanName - $isEnabled = -not $handlerKeyName.StartsWith('-') - $regKey = '${shellexSubPath}\\' + $cleanName + $isEnabled = -not $handlerKeyName.StartsWith('-') + $regKey = '${shellexSubPath}\\' + $cleanName [PSCustomObject]@{ - name = [string]$displayName - command = [string]$clsid - iconPath = $null - isEnabled = [bool]$isEnabled - source = [string]$handlerKeyName - registryKey = [string]$regKey - subKeyName = [string]$handlerKeyName - itemType = 'ShellExt' + handlerKeyName = [string]$handlerKeyName + cleanName = [string]$cleanName + defaultVal = [string]$defaultVal + isEnabled = [bool]$isEnabled + actualClsid = [string]$actualClsid + clsidLocalizedString = $clsidLocalizedString + clsidMUIVerb = $clsidMUIVerb + clsidDefault = $clsidDefault + dllPath = $dllPath + dllFileDescription = $dllFileDescription + dllProductName = $dllProductName + progIdName = $progIdName + siblingMUIVerb = $siblingMUIVerb + registryKey = [string]$regKey } }) $result | ConvertTo-Json -Compress -Depth 3 `.trim(); } + /** + * 构建读取 CommandStore shell 索引的脚本(Level 1.7 用) + * 返回 [{clsid, muiverb}] JSON 数组,MUIVerb 为原始值(间接字符串由 TS 侧解析) + */ + buildCommandStoreScript(): string { + return ` +$ErrorActionPreference = 'SilentlyContinue' +$cmdStorePath = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell' +$result = @() +if (Test-Path -LiteralPath $cmdStorePath) { + Get-ChildItem -LiteralPath $cmdStorePath | ForEach-Object { + $handler = $_.GetValue('ExplorerCommandHandler') + if ($handler -and $handler -match '^\\{[0-9A-Fa-f-]+\\}$') { + $mv = $_.GetValue('MUIVerb') + if ($mv) { + [PSCustomObject]@{ + clsid = [string]$handler + muiverb = [string]$mv + } + } + } + } | ForEach-Object { $result += $_ } +} +$result | ConvertTo-Json -Compress -Depth 2 +`.trim(); + } + /** * 构建启用/禁用 Shell 扩展的脚本(通过重命名键名添加/去除 '-' 前缀) * enable=true → 将 '-Name' 重命名为 'Name' @@ -366,7 +407,7 @@ $fullPath = Join-Path $parentPath $currentKey if (-not (Test-Path -LiteralPath $fullPath)) { throw "ShellExt key not found: $fullPath" } -Rename-Item -LiteralPath $fullPath -NewName $newKey -Force +Rename-Item -LiteralPath $fullPath -NewName $newKey -Force Write-Output '{"ok":true}' `.trim(); } diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 93625c7..333023f 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -1,6 +1,8 @@ import { MenuScene, MenuItemType } from '../../shared/enums'; import { MenuItemEntry } from '../../shared/types'; import { PowerShellBridge } from './PowerShellBridge'; +import { RegistryCache } from '../utils/RegistryCache'; +import { ShellExtNameResolver, CommandStoreIndex, PsRawClassicItem, PsRawShellExtItem } from './ShellExtNameResolver'; import log from '../utils/logger'; // 与 C# RegistryService._sceneRegistryPaths 完全一致 @@ -26,61 +28,116 @@ const SCENE_SHELLEX_PATHS: Record = { // 完整 HKCR 前缀(用于显示) const HKCR_PREFIX = 'HKEY_CLASSES_ROOT'; -interface PsMenuItemRaw { - name: string; - command: string; - iconPath: string | null; - isEnabled: boolean; - source: string; - registryKey: string; - subKeyName: string; - itemType?: string; // 'ShellExt' for shell extensions -} - export class RegistryService { private readonly ps: PowerShellBridge; + private readonly cache: RegistryCache; + private readonly resolver: ShellExtNameResolver; + private readonly cmdStoreIndex: CommandStoreIndex; /** 事务回滚数据:registryKey → 原始 isEnabled */ private rollbackData = new Map(); private inTransaction = false; private nextId = 1; - constructor(ps: PowerShellBridge) { + constructor( + ps: PowerShellBridge, + resolver: ShellExtNameResolver, + cmdStoreIndex: CommandStoreIndex, + cache?: RegistryCache, + ) { this.ps = ps; + this.resolver = resolver; + this.cmdStoreIndex = cmdStoreIndex; + this.cache = cache ?? new RegistryCache(); } /** * 获取指定场景下的所有菜单条目(Classic Shell + Shell 扩展) + * 优先从缓存读取,缓存未命中时执行 PowerShell 查询 */ - async getMenuItems(scene: MenuScene): Promise { + async getMenuItems(scene: MenuScene, priority: 'high' | 'normal' = 'normal'): Promise { + // 尝试从缓存读取 + const cached = this.cache.get(scene); + if (cached) { + log.debug(`RegistryService: Returning cached data for ${scene} (${cached.length} items)`); + return cached; + } + const basePath = SCENE_REGISTRY_PATHS[scene]; const shellexPath = SCENE_SHELLEX_PATHS[scene]; + try { - // 读取 Classic Shell 命令 + // 并行读取 Classic Shell 命令 + Shell 扩展(COM ContextMenuHandlers) const script = this.ps.buildGetItemsScript(basePath); - const raw = await this.ps.execute(script); - const items = Array.isArray(raw) ? raw : (raw ? [raw] : []); - - // 读取 Shell 扩展(COM ContextMenuHandlers),失败不阻断主流程 - let shellexItems: PsMenuItemRaw[] = []; - try { - const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); - const shellexRaw = await this.ps.execute(shellexScript); - shellexItems = Array.isArray(shellexRaw) ? shellexRaw : (shellexRaw ? [shellexRaw] : []); - } catch (e) { - log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); + const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); + const [raw, shellexRaw] = await Promise.all([ + this.ps.execute(script, priority), + this.ps.execute(shellexScript, priority).catch((e) => { + log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); + return [] as PsRawShellExtItem[]; + }), + ]); + const classicItems = Array.isArray(raw) ? raw : (raw ? [raw] : []); + const shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; + + // Classic Shell 条目:通过 resolver 解析名称(保护:单条失败不影响整体) + const classicEntries: MenuItemEntry[] = classicItems.map((r: PsRawClassicItem) => { + let name: string; + try { + name = this.cleanDisplayName(this.resolver.resolveClassicName(r)); + } catch (e) { + log.warn(`[RegistryService] resolveClassicName failed for "${r.subKeyName}":`, String(e)); + name = this.cleanDisplayName(r.subKeyName); + } + return { + id: this.nextId++, + name, + command: r.command, + iconPath: r.rawIcon, + isEnabled: r.isEnabled, + source: '', + menuScene: scene, + registryKey: r.registryKey, + type: r.command && r.command.trim() ? MenuItemType.Custom : MenuItemType.System, + dllPath: null, + }; + }); + + // Shell 扩展条目:通过 resolver 解析名称(保护:单条失败不影响整体) + const shellexEntries: MenuItemEntry[] = shellexItems.map((r: PsRawShellExtItem) => { + let name: string; + try { + name = this.cleanDisplayName(this.resolver.resolveExtName(r, this.cmdStoreIndex)); + } catch (e) { + log.warn(`[RegistryService] resolveExtName failed for "${r.cleanName}":`, String(e)); + name = this.cleanDisplayName(r.cleanName); + } + return { + id: this.nextId++, + name, + command: r.actualClsid, + iconPath: null, + isEnabled: r.isEnabled, + source: r.handlerKeyName, + menuScene: scene, + registryKey: r.registryKey, + type: MenuItemType.ShellExt, + dllPath: r.dllPath ?? null, + }; + }); + + const result = [...classicEntries, ...shellexEntries]; + + // 逐条诊断日志: 打印每个 ShellExt 条目的解析结果和原始数据 + for (let i = 0; i < shellexEntries.length; i++) { + const entry = shellexEntries[i]; + const raw = shellexItems[i]; + log.info(`[ResolveTrace] ${scene} | "${entry.name}" ← cleanName="${raw.cleanName}" clsid=${raw.actualClsid} dll=${raw.dllPath || 'none'} clsidDef="${raw.clsidDefault || ''}" clsidLS="${raw.clsidLocalizedString || ''}" clsidMUI="${raw.clsidMUIVerb || ''}" progId="${raw.progIdName || ''}" dllDesc="${raw.dllFileDescription || ''}" dllProd="${raw.dllProductName || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); } - return [...items, ...shellexItems].map((r) => ({ - id: this.nextId++, - name: r.name, - command: r.command, - iconPath: r.iconPath, - isEnabled: r.isEnabled, - source: r.source || this.inferSource(r.subKeyName), - menuScene: scene, - registryKey: r.registryKey, - type: this.determineType(r.itemType), - })); + // 写入缓存 + this.cache.set(scene, result); + + return result; } catch (e) { log.error(`getMenuItems(${scene}) failed:`, e); throw new Error(`读取注册表场景 ${scene} 失败: ${(e as Error).message}`); @@ -90,7 +147,7 @@ export class RegistryService { /** * 启用或禁用单个菜单条目 * ShellExt 通过重命名键(±前缀)实现;Classic Shell 通过 LegacyDisable 值实现 - * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 + * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 */ async setItemEnabled(registryKey: string, enabled: boolean): Promise<{ newRegistryKey?: string }> { try { @@ -100,7 +157,7 @@ export class RegistryService { return {}; } else { const script = this.ps.buildSetEnabledScript(registryKey, enabled); - await this.ps.executeElevated<{ ok: boolean }>(script); + await this.ps.executeElevated<{ ok: boolean }>(script); return {}; } } catch (e) { @@ -130,14 +187,22 @@ export class RegistryService { async rollback(): Promise { if (!this.inTransaction) return; log.warn('Rolling back registry changes...'); + const failedItems: string[] = []; try { for (const [key, wasEnabled] of this.rollbackData) { - await this.setItemEnabledInternal(key, wasEnabled); + try { + await this.setItemEnabledInternal(key, wasEnabled); + } catch (e) { + failedItems.push(`${key}: ${String(e)}`); + } } } finally { this.inTransaction = false; this.rollbackData.clear(); } + if (failedItems.length > 0) { + throw new Error(`部分项回滚失败:\n${failedItems.join('\n')}`); + } } /** @@ -155,6 +220,34 @@ export class RegistryService { return `${HKCR_PREFIX}\\${SCENE_REGISTRY_PATHS[scene]}`; } + /** + * 清除指定场景的缓存 + */ + invalidateCache(scene: MenuScene): void { + this.cache.invalidate(scene); + } + + /** + * 清除所有缓存 + */ + invalidateAllCache(): void { + this.cache.invalidateAll(); + } + + /** + * 获取缓存统计信息 + */ + getCacheStats(): ReturnType { + return this.cache.getStats(); + } + + /** + * 打印缓存统计日志 + */ + logCacheStats(): void { + this.cache.logStats(); + } + private async setItemEnabledInternal(registryKey: string, enabled: boolean): Promise { if (this.isShellExtKey(registryKey)) { const script = this.ps.buildShellExtToggleScript(registryKey, enabled); @@ -170,12 +263,15 @@ export class RegistryService { return registryKey.includes('shellex') && registryKey.includes('ContextMenuHandlers'); } -private inferSource(subKeyName: string): string { - return subKeyName || ''; + private cleanDisplayName(name: string): string { + if (!name) return name; + return name + .replace(/\(&\w\)/g, '') // ① 先处理带括号加速键整体:(&R)、(&E)、(&V) + .replace(/\(\w\)/g, '') // ② 单字母括号:(P)、(D) + .replace(/&\w/g, '') // ③ 裸加速键:&O、&L(兜底) + .replace(/\(\s*\)/g, '') // ④ 空括号兜底(防止顺序问题留下残留) + .replace(/\s+/g, ' ') // ⑤ 规范化多余空白 + .trim(); } - private determineType(itemType?: string): MenuItemType { - if (itemType === 'ShellExt') return MenuItemType.ShellExt; - return MenuItemType.System; - } } diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts new file mode 100644 index 0000000..e6be60c --- /dev/null +++ b/src/main/services/ShellExtNameResolver.ts @@ -0,0 +1,335 @@ +import { IWin32Shell } from './Win32Shell'; +import log from '../utils/logger'; + +// ---- 标准谓词翻译表 ---- +// Windows 对 open/edit/print 等标准 shell 动词有内置翻译,MUIVerb 为空时生效 +// 参考: https://learn.microsoft.com/en-us/windows/win32/shell/context-menu-handlers +const STANDARD_VERBS: Record = { + 'open': { zh: '打开', en: 'Open' }, + 'edit': { zh: '编辑', en: 'Edit' }, + 'print': { zh: '打印', en: 'Print' }, + 'printto': { zh: '打印到', en: 'Print to' }, + 'find': { zh: '搜索', en: 'Find' }, + 'explore': { zh: '浏览', en: 'Explore' }, + 'play': { zh: '播放', en: 'Play' }, + 'preview': { zh: '预览', en: 'Preview' }, + 'runas': { zh: '以管理员身份运行', en: 'Run as administrator' }, + 'runasuser': { zh: '以其他用户身份运行', en: 'Run as different user' }, + 'properties': { zh: '属性', en: 'Properties' }, + 'cut': { zh: '剪切', en: 'Cut' }, + 'copy': { zh: '复制', en: 'Copy' }, + 'paste': { zh: '粘贴', en: 'Paste' }, + 'delete': { zh: '删除', en: 'Delete' }, + 'rename': { zh: '重命名', en: 'Rename' }, + 'sendto': { zh: '发送到', en: 'Send to' }, + 'new': { zh: '新建', en: 'New' }, + 'select': { zh: '选择', en: 'Select' }, + 'refresh': { zh: '刷新', en: 'Refresh' }, + 'view': { zh: '查看', en: 'View' }, + 'sort': { zh: '排序', en: 'Sort' }, + 'share': { zh: '共享', en: 'Share' }, + 'format': { zh: '格式化', en: 'Format' }, + 'eject': { zh: '弹出', en: 'Eject' }, + 'install': { zh: '安装', en: 'Install' }, + 'config': { zh: '配置', en: 'Configure' }, + 'scan': { zh: '扫描', en: 'Scan' }, + 'restore': { zh: '还原', en: 'Restore' }, + 'togglehidden': { zh: '显示/隐藏', en: 'Toggle Hidden' }, + 'pintohome': { zh: '固定到快速访问', en: 'Pin to Quick access' }, + 'pintotaskbar': { zh: '固定到任务栏', en: 'Pin to taskbar' }, + 'unpintotaskbar': { zh: '从任务栏取消固定', en: 'Unpin from taskbar' }, + 'pinToStart': { zh: '固定到"开始"屏幕', en: 'Pin to Start' }, + 'unpinFromStart': { zh: '从"开始"屏幕取消固定', en: 'Unpin from Start' }, +}; + +// ---- 数据契约:PS 脚本返回的原始数据 ---- + +export interface PsRawClassicItem { + subKeyName: string; + rawMUIVerb: string | null; + rawDefault: string | null; + rawLocalizedDisplayName: string | null; + rawIcon: string | null; + isEnabled: boolean; + command: string; + registryKey: string; +} + +export interface PsRawShellExtItem { + handlerKeyName: string; + cleanName: string; + defaultVal: string; + isEnabled: boolean; + actualClsid: string; + clsidLocalizedString: string | null; + clsidMUIVerb: string | null; + clsidDefault: string | null; + dllPath: string | null; + dllFileDescription: string | null; + dllProductName: string | null; + progIdName: string | null; + siblingMUIVerb: string | null; + registryKey: string; +} + +// ---- 泛型名称过滤器 ---- + +type FilterRule = [RegExp, string]; + +const GENERIC_PATTERNS: FilterRule[] = [ + [/^外壳服务对象$/i, 'Group A: COM description'], + [/^(context|ctx)\s*menu(\s*(handler|ext(ension)?|provider|manager))?$/i, 'Group A'], + [/^shell\s*(extension|ext|common)(\s*(handler|provider|class))?$/i, 'Group A'], + [/shell\s+extension$/i, 'Group A: * Shell Extension suffix'], + [/^shell\s*service(\s*object)?$/i, 'Group A'], + [/^com\s*(object|server|class)$/i, 'Group A'], + [/\.dll$/i, 'Group A: filename'], + [/^microsoft windows/i, 'Group A: system'], + [/\s+class$/i, 'Group B: COM class suffix'], + [/^todo:/i, 'Group C: placeholder'], + [/<[^>]+>/, 'Group C: angle bracket placeholder'], + [/^(n\/a|na|none|unknown|untitled)$/i, 'Group C: invalid'], + [/^(a|an|the)\s+/i, 'Group D: article-start sentence'], + [/^\(.+\)$/, 'Group D: parenthesized debug marker'], +]; + +function isGenericName(name: string): boolean { + if (!name || name.length < 2) return true; + for (const [regex] of GENERIC_PATTERNS) { + if (regex.test(name)) { + log.debug(`[NameResolver] Filtered "${name}" — matches ${regex.source}`); + return true; + } + } + return false; +} + +function isUselessPlain(value: string, fallback: string): boolean { + if (!value || value.length < 2) return true; + if (value.localeCompare(fallback, undefined, { sensitivity: 'base' }) === 0) return true; + if (isGenericName(value)) return true; + return false; +} + +// ---- CommandStore 反向索引 ---- + +export class CommandStoreIndex { + private map = new Map(); + + buildFromData(entries: Array<{ clsid: string; muiverb: string }>): void { + for (const e of entries) { + this.map.set(e.clsid.toLowerCase(), e.muiverb); + } + } + + get size(): number { return this.map.size; } + + get(clsid: string): string | null { + return this.map.get(clsid.toLowerCase()) ?? null; + } + + invalidate(): void { + this.map.clear(); + } +} + +function translateStandardVerb(name: string, language: 'zh' | 'en'): string | null { + const lc = name.toLowerCase().trim(); + const entry = STANDARD_VERBS[lc]; + if (entry) { + return entry[language] || entry.en; + } + return null; +} + +// ---- Shell 扩展名称解析器 ---- + +export class ShellExtNameResolver { + private readonly language: 'zh' | 'en'; + + constructor(private readonly win32: IWin32Shell, language: 'zh' | 'en' = 'zh') { + this.language = language; + } + + /** Classic Shell 条目名称解析 */ + resolveClassicName(raw: PsRawClassicItem): string { + const candidates = [ + raw.rawMUIVerb, + raw.rawDefault, + raw.rawLocalizedDisplayName, + ]; + + for (const cand of candidates) { + if (!cand || cand.length < 2) continue; + if (cand.startsWith('@') || cand.startsWith('ms-resource:')) { + const resolved = this.win32.resolveIndirect(cand); + if (resolved && resolved.length >= 2) return resolved; + } else { + return cand; + } + } + + // 标准谓词翻译:open → 打开, edit → 编辑, ... + const translated = translateStandardVerb(raw.subKeyName, this.language); + if (translated) { + log.debug(`[NameResolver] Standard verb "${raw.subKeyName}" → "${translated}"`); + return translated; + } + + return raw.subKeyName; + } + + /** Shell 扩展条目名称解析(多级回退链) */ + resolveExtName(raw: PsRawShellExtItem, cmdStore: CommandStoreIndex): string { + const fallback = raw.cleanName; + + // Level 0: directName 间接格式(@dll,-id 或 ms-resource:) + if (raw.defaultVal && (raw.defaultVal.startsWith('@') || raw.defaultVal.startsWith('ms-resource:'))) { + try { + const resolved = this.win32.resolveIndirect(raw.defaultVal); + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 0 (directName indirect): "${resolved}"`); + return resolved; + } + } catch { /* fall through */ } + } + + // ====== Phase A: 间接格式优先(resolveIndirect 返回系统语言名称) ====== + if (raw.actualClsid) { + // Level 1-indirect: CLSID.LocalizedString @/ms-resource: 格式 + if (raw.clsidLocalizedString && + (raw.clsidLocalizedString.startsWith('@') || raw.clsidLocalizedString.startsWith('ms-resource:'))) { + try { + const resolved = this.win32.resolveIndirect(raw.clsidLocalizedString); + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1 (LocalizedString indirect): "${resolved}"`); + return resolved; + } + } catch { /* fall through */ } + } + + // Level 1.3-indirect: Sibling Shell Key MUIVerb @/ms-resource: 格式 + if (raw.siblingMUIVerb && + (raw.siblingMUIVerb.startsWith('@') || raw.siblingMUIVerb.startsWith('ms-resource:'))) { + try { + const resolved = this.win32.resolveIndirect(raw.siblingMUIVerb); + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1.3 (sibling MUIVerb indirect): "${resolved}"`); + return resolved; + } + } catch { /* fall through */ } + } + + // Level 1.5-indirect: CLSID.MUIVerb @/ms-resource: 格式 + if (raw.clsidMUIVerb && + (raw.clsidMUIVerb.startsWith('@') || raw.clsidMUIVerb.startsWith('ms-resource:'))) { + try { + const resolved = this.win32.resolveIndirect(raw.clsidMUIVerb); + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1.5 (MUIVerb indirect): "${resolved}"`); + return resolved; + } + } catch { /* fall through */ } + } + + // ====== Phase B: CommandStore(Windows 本地化机制,优先级高于 plain text) ====== + const cmdVerb = cmdStore.get(raw.actualClsid); + if (cmdVerb) { + if (cmdVerb.startsWith('@') || cmdVerb.startsWith('ms-resource:')) { + const resolved = this.win32.resolveIndirect(cmdVerb); + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1.7 (CommandStore resolved): "${resolved}"`); + return resolved; + } + } else { + log.debug(`[NameResolver] ${fallback} → Level 1.7 (CommandStore): "${cmdVerb}"`); + return cmdVerb; + } + } + + // Level 1.6: ProgID → 应用程序名(在 CommandStore 之后、plain text 之前) + if (raw.progIdName && raw.progIdName.length >= 2) { + if (!isUselessPlain(raw.progIdName, fallback) && !isGenericName(raw.progIdName)) { + log.debug(`[NameResolver] ${fallback} → Level 1.6 (ProgID): "${raw.progIdName}"`); + return raw.progIdName; + } + } + + // ====== Phase C: Plain text 回退(开发者硬编码名称,可能是英文) ====== + // Level 1-plain: CLSID.LocalizedString plain text + if (raw.clsidLocalizedString && + !raw.clsidLocalizedString.startsWith('@') && + !raw.clsidLocalizedString.startsWith('ms-resource:') && + raw.clsidLocalizedString.length >= 2) { + if (!isUselessPlain(raw.clsidLocalizedString, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1 (LocalizedString plain): "${raw.clsidLocalizedString}"`); + return raw.clsidLocalizedString; + } + } + + // Level 1.3-plain: Sibling Shell Key MUIVerb plain text + if (raw.siblingMUIVerb && + !raw.siblingMUIVerb.startsWith('@') && + !raw.siblingMUIVerb.startsWith('ms-resource:') && + raw.siblingMUIVerb.length >= 2) { + if (!isUselessPlain(raw.siblingMUIVerb, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1.3 (sibling MUIVerb): "${raw.siblingMUIVerb}"`); + return raw.siblingMUIVerb; + } + } + + // Level 1.5-plain: CLSID.MUIVerb plain text + if (raw.clsidMUIVerb && + !raw.clsidMUIVerb.startsWith('@') && + !raw.clsidMUIVerb.startsWith('ms-resource:') && + raw.clsidMUIVerb.length >= 2) { + if (!isUselessPlain(raw.clsidMUIVerb, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1.5 (MUIVerb): "${raw.clsidMUIVerb}"`); + return raw.clsidMUIVerb; + } + } + + // Level 2: CLSID 默认值 + if (raw.clsidDefault && raw.clsidDefault.length >= 2) { + if (!isUselessPlain(raw.clsidDefault, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default): "${raw.clsidDefault}"`); + return raw.clsidDefault; + } + } + } + + // Level 2.5: DLL 版本资源(PS 采集,天然支持 UI 语言) + // 先试 FileDescription,再试 ProductName(通常为用户可见名) + const dllCandidates = [raw.dllFileDescription, raw.dllProductName]; + for (const dllName of dllCandidates) { + if (dllName && dllName.length >= 2 && dllName.length <= 64) { + if (dllName.localeCompare(fallback, undefined, { sensitivity: 'base' }) === 0) continue; + if (!isGenericName(dllName)) { + log.debug(`[NameResolver] ${fallback} → Level 2.5 (DLL): "${dllName}"`); + return dllName; + } + log.debug(`[NameResolver] ${fallback} — Level 2.5 DLL "${dllName}" filtered as generic`); + } + } + + // Level 3: directName plain 字符串 + if (raw.defaultVal && + !raw.defaultVal.startsWith('@') && + !raw.defaultVal.startsWith('ms-resource:')) { + if (!isUselessPlain(raw.defaultVal, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 3 (directName plain): "${raw.defaultVal}"`); + return raw.defaultVal; + } + } + + // 标准谓词翻译:对 cleanName 做最后一搏 + const translated = translateStandardVerb(fallback, this.language); + if (translated) { + log.debug(`[NameResolver] ${fallback} → Standard verb translation: "${translated}"`); + return translated; + } + + log.debug(`[NameResolver] ${fallback} → Fallback (key name)`); + return fallback; + } +} diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts new file mode 100644 index 0000000..cb0f131 --- /dev/null +++ b/src/main/services/Win32Shell.ts @@ -0,0 +1,69 @@ +import koffi from 'koffi'; +import log from '../utils/logger'; + +export interface IWin32Shell { + resolveIndirect(source: string): string | null; + readonly uiLanguage: 'zh' | 'en'; +} + +export class Win32Shell implements IWin32Shell { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private resolveIndirectFn?: (...args: any[]) => number; + + private indirectCache = new Map(); + private koffiAvailable = true; + + private readonly uiLangId: number; + + get uiLanguage(): 'zh' | 'en' { + return this.uiLangId === 0x04 ? 'zh' : 'en'; + } + + constructor() { + try { + const shlwapi = koffi.load('shlwapi.dll'); + this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ + 'str16', // PCWSTR pszSource + 'void *', // PWSTR pszOutBuf + 'uint32', // UINT cchOutBuf + 'void *', // void **ppvReserved + ]); + + const kernel32 = koffi.load('kernel32.dll'); + const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); + this.uiLangId = getLangId() & 0xFF; + log.info(`[Win32Shell] Initialized OK — uiLanguage=${this.uiLanguage}`); + } catch (e) { + log.error(`[Win32Shell] FAILED — reason: ${String(e)}`); + this.koffiAvailable = false; + this.uiLangId = 0x09; + } + } + + resolveIndirect(source: string): string | null { + if (!this.koffiAvailable || !this.resolveIndirectFn) return null; + if (!source || (!source.startsWith('@') && !source.startsWith('ms-resource:'))) { + return null; + } + const cached = this.indirectCache.get(source); + if (cached !== undefined) return cached; + + try { + const buf = Buffer.alloc(2048); + const hr = this.resolveIndirectFn(source, buf, 1024, null); + if (hr === 0) { + const result = buf.toString('utf16le').replace(/\0[\s\S]*$/, ''); + log.debug(`[Win32Shell] SHLoadIndirectString("${source.substring(0, 50)}...") → "${result}"`); + this.indirectCache.set(source, result || null); + return result || null; + } + log.debug(`[Win32Shell] SHLoadIndirectString failed HRESULT 0x${(hr >>> 0).toString(16)} for "${source.substring(0, 50)}..."`); + this.indirectCache.set(source, null); + return null; + } catch (e) { + log.warn('[Win32Shell] SHLoadIndirectString exception:', String(e)); + this.indirectCache.set(source, null); + return null; + } + } +} diff --git a/src/main/utils/RegistryCache.ts b/src/main/utils/RegistryCache.ts new file mode 100644 index 0000000..3d97c0c --- /dev/null +++ b/src/main/utils/RegistryCache.ts @@ -0,0 +1,131 @@ +import { MenuScene } from '../../shared/enums'; +import { MenuItemEntry } from '../../shared/types'; +import log from './logger'; + +interface CacheEntry { + data: MenuItemEntry[]; + timestamp: number; + hitCount: number; +} + +interface CacheStats { + hits: number; + misses: number; + evictions: number; +} + +/** + * 注册表查询结果缓存管理器 + * 支持 TTL 过期机制和场景级缓存隔离 + */ +export class RegistryCache { + private readonly cache = new Map(); + private readonly stats: CacheStats = { + hits: 0, + misses: 0, + evictions: 0, + }; + + constructor(private readonly ttlMs: number = 30000) {} + + /** + * 获取缓存的菜单条目 + * @param scene 菜单场景 + * @returns 缓存数据(未命中或过期返回 null) + */ + get(scene: MenuScene): MenuItemEntry[] | null { + const entry = this.cache.get(scene); + + if (!entry) { + this.stats.misses++; + log.debug(`[RegistryCache] Miss: ${scene} (not found)`); + return null; + } + + const now = Date.now(); + if (now - entry.timestamp > this.ttlMs) { + this.stats.misses++; + this.cache.delete(scene); + log.debug(`[RegistryCache] Miss: ${scene} (expired)`); + return null; + } + + entry.hitCount++; + this.stats.hits++; + log.debug(`[RegistryCache] Hit: ${scene} (hits: ${entry.hitCount})`); + return entry.data; + } + + /** + * 设置缓存数据 + * @param scene 菜单场景 + * @param data 菜单条目数据 + */ + set(scene: MenuScene, data: MenuItemEntry[]): void { + const existing = this.cache.get(scene); + if (existing) { + this.stats.evictions++; + } + + this.cache.set(scene, { + data, + timestamp: Date.now(), + hitCount: 0, + }); + log.debug(`[RegistryCache] Set: ${scene} (${data.length} items)`); + } + + /** + * 清除指定场景的缓存 + * @param scene 菜单场景 + */ + invalidate(scene: MenuScene): void { + if (this.cache.has(scene)) { + this.cache.delete(scene); + log.debug(`[RegistryCache] Invalidated: ${scene}`); + } + } + + /** + * 清除所有缓存 + */ + invalidateAll(): void { + const count = this.cache.size; + this.cache.clear(); + log.debug(`[RegistryCache] Invalidated all: ${count} entries`); + } + + /** + * 获取缓存统计信息 + */ + getStats(): CacheStats & { hitRate: number; size: number } { + const total = this.stats.hits + this.stats.misses; + const hitRate = total > 0 ? this.stats.hits / total : 0; + return { + ...this.stats, + hitRate, + size: this.cache.size, + }; + } + + /** + * 打印统计日志 + */ + logStats(): void { + const stats = this.getStats(); + log.info( + `[RegistryCache] Stats: hits=${stats.hits}, misses=${stats.misses}, ` + + `hitRate=${(stats.hitRate * 100).toFixed(1)}%, size=${stats.size}, evictions=${stats.evictions}` + ); + } + + /** + * 检查缓存是否有效(未过期) + * @param scene 菜单场景 + */ + isValid(scene: MenuScene): boolean { + const entry = this.cache.get(scene); + if (!entry) return false; + return Date.now() - entry.timestamp <= this.ttlMs; + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e263d4f..7d48a8e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,9 @@ const api = { copyToClipboard: (text: string) => invoke(IPC.SYS_COPY_CLIPBOARD, text), + diagnose: () => + invoke>(IPC.SYS_DIAGNOSE), + openExternal: (url: string) => invoke(IPC.SYS_OPEN_EXTERNAL, url), diff --git a/src/renderer/api/bridge.ts b/src/renderer/api/bridge.ts index d9407aa..997c7bb 100644 --- a/src/renderer/api/bridge.ts +++ b/src/renderer/api/bridge.ts @@ -29,6 +29,7 @@ export interface WindowApi { importBackup(): Promise>; previewRestoreDiff(snapshotId: number): Promise>; + diagnose(): Promise>>; isAdmin(): Promise>; restartAsAdmin(): Promise>; openRegedit(fullRegPath: string): Promise>; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 208ec48..37eae89 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -1,27 +1,51 @@ -// renderer 进程中挂载到 window 的页面 API 全局类型声明 - -interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _mainPage: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _historyPage: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _backupPage: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _settingsPage: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _selectedId: any; - showUndo: (msg: string, itemId?: number) => void; - hideUndo: () => void; - doUndo: () => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - switchPage: (page: string, navEl?: HTMLElement, scene?: string) => Promise; - updateMaximizeBtn: () => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - filterHistory: (mode: string, btn: HTMLElement) => void; - clearHistory: () => Promise; - createBackup: () => Promise; - importBackup: () => Promise; - requestAdminRestart: () => Promise; - toggleSwitch: (btn: HTMLElement) => void; -} +// renderer 进程中挂载到 window 的页面 API 全局类型声明 + +interface MainPageApi { + selectItem(id: number): void; + toggleItem(id: number): Promise; + setFilter(mode: 'all' | 'enabled' | 'disabled', btn: HTMLElement): void; + flashCopyBtn(btn: HTMLButtonElement): void; + toggleFromDetail(): Promise; + deleteSelected(): void; +} + +interface HistoryPageApi { + undoRecord(id: number): Promise; + filterHistory(mode: string, btn: HTMLElement): void; + clearAllHistory(): Promise; +} + +interface BackupPageApi { + createBackup(): Promise; + restoreBackup(id: number): Promise; + exportBackup(id: number): Promise; + importBackup(): Promise; + deleteBackup(id: number): Promise; +} + +interface SettingsPageApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +interface Window { + _mainPage: MainPageApi; + _historyPage: HistoryPageApi; + _backupPage: BackupPageApi; + _settingsPage: SettingsPageApi; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _selectedId: any; + showUndo: (msg: string, itemId?: number) => void; + hideUndo: () => void; + doUndo: () => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + switchPage: (page: string, navEl?: HTMLElement, scene?: string) => Promise; + updateMaximizeBtn: () => Promise; + filterHistory: (mode: string, btn: HTMLElement) => void; + clearHistory: () => Promise; + createBackup: () => Promise; + importBackup: () => Promise; + requestAdminRestart: () => Promise; + toggleSwitch: (btn: HTMLElement) => void; + invalidateAllScenesCache?: () => void; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 9b2bd4f..ba62632 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -191,6 +191,16 @@ .setting-control { flex-shrink: 0; } select.native-select { height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 8px; font-size: 12px; background: var(--surface); color: var(--text); font-family: inherit; outline: none; cursor: pointer; } select.native-select:focus { border-color: var(--accent); } + + /* ===== CUSTOM DIALOGS ===== */ + dialog.cm-dialog { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 0; background: var(--surface); box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10); min-width: 340px; max-width: 480px; width: 92%; font-family: 'Noto Sans SC','Segoe UI Variable','Segoe UI',system-ui,sans-serif; } + dialog.cm-dialog::backdrop { background: rgba(0,0,0,0.4); backdrop-filter: blur(2px); } + .cm-dialog-header { padding: 16px 20px 0; font-size: 14px; font-weight: 600; color: var(--text); } + .cm-dialog-body { padding: 10px 20px 4px; } + .cm-dialog-msg { font-size: 13px; color: var(--text2); line-height: 1.65; white-space: pre-wrap; word-break: break-word; } + .cm-dialog-input { margin-top: 10px; width: 100%; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 10px; font-size: 13px; font-family: inherit; color: var(--text); background: var(--surface2); outline: none; box-sizing: border-box; transition: border-color 0.15s, box-shadow 0.15s; } + .cm-dialog-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,103,192,0.15); background: var(--surface); } + .cm-dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px 18px; } @@ -455,6 +465,19 @@ +
+
诊断 (Diagnostics)
+
+
+
koffi FFI 自检
+
点击按钮检测 SHLoadIndirectString 和 GetFileVersionInfo 是否正常
+
+
+ +
+
+
+
关于
@@ -515,6 +538,42 @@
+ + +
新建备份
+
+
+ +
+ +
+ + + +
确认操作
+
+
+
+ +
+ + + +
提示
+
+
+
+ +
+ diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 304b34a..6775833 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -2,7 +2,7 @@ import './api/bridge'; import './styles/themes.css'; import { MenuScene } from '../shared/enums'; import type { MenuItemEntry } from '../shared/types'; -import { loadScene, preloadBadgeCounts, renderGlobalResults, restoreSceneTitle } from './pages/mainPage'; +import { loadScene, preloadBadgeCounts, renderGlobalResults, restoreSceneTitle, onNavigateAway } from './pages/mainPage'; import { loadHistory, filterHistory, clearAllHistory } from './pages/historyPage'; import { loadBackups, createBackup, importBackup } from './pages/backupPage'; import { initSettings, requestAdminRestart, toggleSwitch, openLogDir } from './pages/settingsPage'; @@ -99,12 +99,15 @@ async function switchPage(page: PageId, navEl?: HTMLElement, scene?: MenuScene): const s = scene ?? currentScene; currentScene = s; await loadScene(s); - } else if (page === 'history') { - await loadHistory(); - } else if (page === 'backup') { - await loadBackups(); - } else if (page === 'settings') { - await initSettings(); + } else { + onNavigateAway(); + if (page === 'history') { + await loadHistory(); + } else if (page === 'backup') { + await loadBackups(); + } else if (page === 'settings') { + await initSettings(); + } } } diff --git a/src/renderer/pages/backupPage.ts b/src/renderer/pages/backupPage.ts index 3a4dc00..a4592b4 100644 --- a/src/renderer/pages/backupPage.ts +++ b/src/renderer/pages/backupPage.ts @@ -2,6 +2,8 @@ import '../api/bridge'; import type { BackupSnapshot } from '../../shared/types'; import { BackupType } from '../../shared/enums'; import { t, registerRefreshCallback } from '../i18n'; +import { escapeHtml } from '../utils/html'; +import { showAlert, showConfirm, showPrompt } from '../utils/dialog'; let backups: BackupSnapshot[] = []; @@ -14,7 +16,7 @@ registerRefreshCallback(refreshBackupContent); export async function loadBackups(): Promise { const result = await window.api.getBackups(); if (!result.success) { - alert(`${t('backup.loadFailed')}: ${result.error}`); + await showAlert(`${t('backup.loadFailed')}: ${result.error}`); return; } backups = result.data; @@ -74,7 +76,7 @@ export function renderBackup(): void { } export async function createBackup(): Promise { - const name = prompt( + const name = await showPrompt( t('backup.enterNote'), `${t('backup.manualBackup')} · ${new Date().toLocaleDateString('zh-CN')}` ); @@ -82,7 +84,7 @@ export async function createBackup(): Promise { const result = await window.api.createBackup(name); if (!result.success) { - alert(`${t('backup.createFailed')}: ${result.error}`); + await showAlert(`${t('backup.createFailed')}: ${result.error}`); return; } backups.unshift(result.data); @@ -97,7 +99,7 @@ export async function restoreBackup(id: number): Promise { const diffResult = await window.api.previewRestoreDiff(id); if (!diffResult.success) { - alert(`${t('backup.previewFailed')}: ${diffResult.error}`); + await showAlert(`${t('backup.previewFailed')}: ${diffResult.error}`); return; } const diffCount = diffResult.data.length; @@ -105,12 +107,12 @@ export async function restoreBackup(id: number): Promise { ? `${t('backup.willRestore')}「${b.name}」\n${t('backup.itemsWillChange', { count: diffCount })}\n${t('backup.autoSnapshot')}\n\n${t('backup.confirmContinue')}` : `${t('backup.noDiff')}「${b.name}」${t('backup.noNeedRestore')}`; - if (diffCount === 0) { alert(confirmMsg); return; } - if (!confirm(confirmMsg)) return; + if (diffCount === 0) { await showAlert(confirmMsg); return; } + if (!await showConfirm(confirmMsg)) return; const result = await window.api.restoreBackup(id); if (!result.success) { - alert(`${t('backup.restoreFailed')}: ${result.error}`); + await showAlert(`${t('backup.restoreFailed')}: ${result.error}`); return; } await loadBackups(); @@ -121,14 +123,14 @@ export async function restoreBackup(id: number): Promise { export async function exportBackup(id: number): Promise { const result = await window.api.exportBackup(id); if (!result.success) { - alert(`${t('backup.exportFailed')}: ${result.error}`); + await showAlert(`${t('backup.exportFailed')}: ${result.error}`); } } export async function importBackup(): Promise { const result = await window.api.importBackup(); if (!result.success) { - if (result.error !== t('backup.noFileSelected')) alert(`${t('backup.importFailed')}: ${result.error}`); + if (result.error !== t('backup.noFileSelected')) await showAlert(`${t('backup.importFailed')}: ${result.error}`); return; } backups.unshift(result.data); @@ -138,9 +140,9 @@ export async function importBackup(): Promise { } export async function deleteBackup(id: number): Promise { - if (!confirm(t('backup.confirmDelete'))) return; + if (!await showConfirm(t('backup.confirmDelete'))) return; const result = await window.api.deleteBackup(id); - if (!result.success) { alert(`${t('backup.deleteFailed')}: ${result.error}`); return; } + if (!result.success) { await showAlert(`${t('backup.deleteFailed')}: ${result.error}`); return; } backups = backups.filter((b) => b.id !== id); renderBackup(); } @@ -159,10 +161,6 @@ function formatDate(iso: string): string { } catch { return iso; } } -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>'); -} - const backupPageApi = { createBackup, restoreBackup, exportBackup, importBackup, deleteBackup }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any)._backupPage = backupPageApi; diff --git a/src/renderer/pages/historyPage.ts b/src/renderer/pages/historyPage.ts index d2bd84d..75d4b45 100644 --- a/src/renderer/pages/historyPage.ts +++ b/src/renderer/pages/historyPage.ts @@ -2,6 +2,7 @@ import '../api/bridge'; import type { OperationRecord } from '../../shared/types'; import { OperationType } from '../../shared/enums'; import { t, registerRefreshCallback } from '../i18n'; +import { escapeHtml } from '../utils/html'; function getOpLabel(type: OperationType): string { const opKeys: Record = { @@ -127,10 +128,6 @@ function formatTime(iso: string): string { } } -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>'); -} - const historyPageApi = { undoRecord, filterHistory, clearAllHistory }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any)._historyPage = historyPageApi; diff --git a/src/renderer/pages/mainPage.ts b/src/renderer/pages/mainPage.ts index 5f03b48..b25f68b 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -2,6 +2,7 @@ import '../api/bridge'; import { MenuScene, MenuItemType } from '../../shared/enums'; import type { MenuItemEntry, ToggleItemParams } from '../../shared/types'; import { t, registerRefreshCallback } from '../i18n'; +import { escapeHtml } from '../utils/html'; export const SCENE_REG_ROOTS: Record = { [MenuScene.Desktop]: 'HKEY_CLASSES_ROOT\\DesktopBackground\\Shell', @@ -29,6 +30,10 @@ let selectedItemId: number | null = null; let filterMode: 'all' | 'enabled' | 'disabled' = 'all'; let loadingScene = false; let currentScene: MenuScene = MenuScene.Desktop; +let pendingScene: MenuScene | null = null; + +const RENDERER_CACHE_TTL = 2 * 60 * 1000; // 2 分钟 +const rendererCache = new Map(); export function refreshCurrentContent(): void { renderItems(); @@ -43,13 +48,45 @@ export function refreshCurrentContent(): void { registerRefreshCallback(refreshCurrentContent); -export async function loadScene(scene: MenuScene): Promise { - if (loadingScene) return; +export async function loadScene(scene: MenuScene, forceRefresh = false): Promise { + if (loadingScene) { + pendingScene = scene; + // 立即更新 header 和导航高亮,表明请求已被接受 + const titleEl = document.getElementById('sceneTitle'); + if (titleEl) titleEl.innerHTML = `${getSceneName(scene)} `; + return; + } loadingScene = true; currentScene = scene; + pendingScene = null; + + // 检查 Renderer 缓存(stale-while-revalidate) + if (!forceRefresh) { + const cached = rendererCache.get(scene); + if (cached && Date.now() - cached.timestamp < RENDERER_CACHE_TTL) { + console.debug(`[Renderer] cache hit: ${scene}`); + currentItems = cached.items; + selectedItemId = null; + resetDetailPanel(); + updateSceneHeader(scene); + renderItems(); + updateStatusBar(scene); + loadingScene = false; + // TTL 剩余不足 30s 时后台静默刷新,避免下次切换出现加载状态 + if (Date.now() - cached.timestamp > RENDERER_CACHE_TTL - 30_000) { + void silentRefreshScene(scene); + } + if (pendingScene !== null) { + const next = pendingScene; + pendingScene = null; + await loadScene(next); + } + return; + } + } const listEl = document.getElementById('itemList'); - if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; + if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; selectedItemId = null; resetDetailPanel(); @@ -59,13 +96,27 @@ export async function loadScene(scene: MenuScene): Promise { if (!result.success) { showError(`${t('main.loadFailed')}: ${result.error}`); - return; + } else { + currentItems = result.data; + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + updateSceneHeader(scene); + renderItems(); + updateStatusBar(scene); } - currentItems = result.data; - updateSceneHeader(scene); - renderItems(); - updateStatusBar(scene); + // 若加载期间有新的场景请求,执行最新的那个 + if (pendingScene !== null) { + const next = pendingScene; + pendingScene = null; + await loadScene(next); + } +} + +async function silentRefreshScene(scene: MenuScene): Promise { + const result = await window.api.getMenuItems(scene); + if (result.success) { + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + } } // ── 渲染条目列表 ── @@ -173,6 +224,8 @@ export async function toggleItem(id: number): Promise { if (result.data.newRegistryKey) { item.registryKey = result.data.newRegistryKey; } + // toggle 后使该场景的 renderer 缓存失效,确保下次切回时拿到最新状态 + rendererCache.delete(item.menuScene); renderItems(); const action = item.isEnabled ? t('history.operation.enable') : t('history.operation.disable'); (window as Window & { showUndo?: (msg: string, itemId: number) => void; invalidateAllScenesCache?: () => void }) @@ -201,9 +254,8 @@ export function showDetail(id: number): void { } return `${SCENE_REG_ROOTS[item.menuScene]}\\${item.registryKey.split('\\').pop()}`; })(); - const regCmdPath = isShellExt - ? `(COM DLL,CLSID: ${item.command})` - : `${regItemPath}\\command`; + const regCmdPath = `${regItemPath}\\command`; + // 在 HTML onclick 属性里,反斜杠会被 JS 当转义前缀消耗,必须双写 const regItemPathAttr = regItemPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const disabledNoteContent = isShellExt @@ -267,10 +319,17 @@ export function showDetail(id: number): void { ${legacyNote}
-
-
${isShellExt ? t('item.comObject') : t('item.commandSubkey')}
-
${escapeHtml(regCmdPath)}
+ ${isShellExt ? `
+
COM 标识符
+
${escapeHtml(item.command)}
+ ${item.dllPath ? `
+
提供程序 DLL
+
${escapeHtml(item.dllPath)}
+
` : ''}` : `
+
命令子键路径
+
${escapeHtml(regCmdPath)}
+
`}
${t('item.openInRegedit')}
@@ -367,14 +426,6 @@ function showOperationError(msg: string): void { }, 3000); } -function escapeHtml(s: string): string { - return s.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - // ── 从详情面板触发切换 ── export async function toggleFromDetail(): Promise { if (selectedItemId == null) return; @@ -422,22 +473,43 @@ export function restoreSceneTitle(scene: MenuScene): void { resetDetailPanel(); } -// ── 预加载其余场景的 badge 数量 ── +// ── 预加载其余场景的 badge 数量(串行,每个场景完成后立即更新,渐进显示)── export async function preloadBadgeCounts(skipScene: MenuScene): Promise { const allScenes = Object.values(MenuScene) as MenuScene[]; - const scenesToLoad = allScenes.filter((s) => s !== skipScene); - - await Promise.all( - scenesToLoad.map(async (scene) => { - const result = await window.api.getMenuItems(scene); - const badgeEl = document.getElementById(`badge-${scene}`); - if (badgeEl) { - badgeEl.textContent = result.success ? String(result.data.length) : '?'; - } - }) - ); + const targetScenes = allScenes.filter((scene) => scene !== skipScene); + + for (const scene of targetScenes) { + const result = await window.api.getMenuItems(scene).catch(() => null); + const badgeEl = document.getElementById(`badge-${scene}`); + if (!badgeEl) continue; + + if (result && result.success && 'data' in result) { + badgeEl.textContent = String(result.data.length); + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + } else { + badgeEl.textContent = '?'; + } + } } +export function onNavigateAway(): void { + pendingScene = null; // 取消挂起的场景切换请求,避免导航离开后触发残留副作用 +} + +// ── 注入 loading spinner 样式 ── +(function injectSpinnerStyles() { + if (document.getElementById('_cmSpinnerStyles')) return; + const style = document.createElement('style'); + style.id = '_cmSpinnerStyles'; + style.textContent = ` +.loading-state { display:flex; align-items:center; justify-content:center; height:200px; gap:10px; color:var(--text3); } +.loading-spinner { width:18px; height:18px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:cmSpin 0.7s linear infinite; flex-shrink:0; } +@keyframes cmSpin { to { transform:rotate(360deg); } } +.loading-badge { font-size:11px; color:var(--text3); font-weight:normal; } + `.trim(); + document.head.appendChild(style); +})(); + // 挂载到 window 供 HTML inline onclick 调用 const mainPageApi = { selectItem, toggleItem, setFilter, flashCopyBtn, toggleFromDetail, deleteSelected }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/pages/settingsPage.ts b/src/renderer/pages/settingsPage.ts index a16603f..dde67ae 100644 --- a/src/renderer/pages/settingsPage.ts +++ b/src/renderer/pages/settingsPage.ts @@ -79,10 +79,24 @@ export async function openLogDir(): Promise { if (!result.success) alert(`打开日志目录失败: ${result.error}`); } -const settingsPageApi = { - requestAdminRestart, - toggleSwitch, - openLogDir +export async function runDiagnose(): Promise { + const el = document.getElementById('diagnoseResult'); + if (el) el.textContent = '诊断中...'; + + const result = await window.api.diagnose(); + const text = result.success + ? JSON.stringify(result.data, null, 2) + : `IPC 失败: ${result.error}`; + + if (el) el.textContent = text; + console.log('[Diagnose]', text); +} + +const settingsPageApi = { + requestAdminRestart, + toggleSwitch, + openLogDir, + runDiagnose, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/utils/debug.ts b/src/renderer/utils/debug.ts index aa6ad7f..d1753e4 100644 --- a/src/renderer/utils/debug.ts +++ b/src/renderer/utils/debug.ts @@ -3,18 +3,18 @@ const isDebug = import.meta.env.DEV || import.meta.env.VITE_DEBUG === 'true'; export const debug = { log: (...args: unknown[]): void => { if (isDebug) console.log(...args); - window.api.logToFile('info', args.map((a) => String(a)).join(' ')); + void window.api.logToFile('info', args.map((a) => String(a)).join(' ')); }, warn: (...args: unknown[]): void => { if (isDebug) console.warn(...args); - window.api.logToFile('warn', args.map((a) => String(a)).join(' ')); + void window.api.logToFile('warn', args.map((a) => String(a)).join(' ')); }, error: (...args: unknown[]): void => { if (isDebug) console.error(...args); - window.api.logToFile('error', args.map((a) => String(a)).join(' ')); + void window.api.logToFile('error', args.map((a) => String(a)).join(' ')); }, info: (...args: unknown[]): void => { if (isDebug) console.info(...args); - window.api.logToFile('info', args.map((a) => String(a)).join(' ')); + void window.api.logToFile('info', args.map((a) => String(a)).join(' ')); }, }; diff --git a/src/renderer/utils/dialog.ts b/src/renderer/utils/dialog.ts new file mode 100644 index 0000000..79312b9 --- /dev/null +++ b/src/renderer/utils/dialog.ts @@ -0,0 +1,101 @@ +/** 使用 HTML5 原生 封装的弹窗工具函数,替代 Electron 不支持的 prompt/confirm/alert */ + +function getEl(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Dialog element #${id} not found`); + return el as T; +} + +/** 弹出输入框,返回用户输入或 null(取消) */ +export function showPrompt(message: string, defaultValue = ''): Promise { + return new Promise((resolve) => { + const dialog = getEl('cm-prompt-dialog'); + const msgEl = getEl('cm-prompt-msg'); + const input = getEl('cm-prompt-input'); + const okBtn = getEl('cm-prompt-ok'); + const cancelBtn = getEl('cm-prompt-cancel'); + + msgEl.textContent = message; + input.value = defaultValue; + + const cleanup = () => { + okBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + dialog.removeEventListener('keydown', onKey); + dialog.close(); + }; + + const onOk = () => { cleanup(); resolve(input.value || null); }; + const onCancel = () => { cleanup(); resolve(null); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); onOk(); } + if (e.key === 'Escape') { e.preventDefault(); onCancel(); } + }; + + okBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + dialog.addEventListener('keydown', onKey); + + dialog.showModal(); + input.select(); + }); +} + +/** 弹出确认框,返回 true(确认)或 false(取消) */ +export function showConfirm(message: string): Promise { + return new Promise((resolve) => { + const dialog = getEl('cm-confirm-dialog'); + const msgEl = getEl('cm-confirm-msg'); + const okBtn = getEl('cm-confirm-ok'); + const cancelBtn = getEl('cm-confirm-cancel'); + + msgEl.textContent = message; + + const cleanup = () => { + okBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + dialog.removeEventListener('keydown', onKey); + dialog.close(); + }; + + const onOk = () => { cleanup(); resolve(true); }; + const onCancel = () => { cleanup(); resolve(false); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); onOk(); } + if (e.key === 'Escape') { e.preventDefault(); onCancel(); } + }; + + okBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + dialog.addEventListener('keydown', onKey); + + dialog.showModal(); + }); +} + +/** 弹出提示框 */ +export function showAlert(message: string): Promise { + return new Promise((resolve) => { + const dialog = getEl('cm-alert-dialog'); + const msgEl = getEl('cm-alert-msg'); + const okBtn = getEl('cm-alert-ok'); + + msgEl.textContent = message; + + const cleanup = () => { + okBtn.removeEventListener('click', onOk); + dialog.removeEventListener('keydown', onKey); + dialog.close(); + }; + + const onOk = () => { cleanup(); resolve(); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); onOk(); } + }; + + okBtn.addEventListener('click', onOk); + dialog.addEventListener('keydown', onKey); + + dialog.showModal(); + }); +} diff --git a/src/renderer/utils/html.ts b/src/renderer/utils/html.ts new file mode 100644 index 0000000..7df7d41 --- /dev/null +++ b/src/renderer/utils/html.ts @@ -0,0 +1,8 @@ +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index ff686a6..c279405 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -27,6 +27,7 @@ export const IPC = { SYS_COPY_CLIPBOARD: 'sys:copyClipboard', SYS_OPEN_EXTERNAL: 'sys:openExternal', SYS_LOG_TO_FILE: 'sys:logToFile', + SYS_DIAGNOSE: 'sys:diagnose', WIN_MINIMIZE: 'win:minimize', WIN_MAXIMIZE: 'win:maximize', WIN_CLOSE: 'win:close', diff --git a/src/shared/types.ts b/src/shared/types.ts index 6320b8f..431b13e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -16,6 +16,7 @@ export interface MenuItemEntry { menuScene: MenuScene; registryKey: string; type: MenuItemType; + dllPath?: string | null; // 仅 ShellExt 类型有值,指向 InprocServer32 DLL } // 操作记录 diff --git a/tests/unit/main/ipc/registry.test.ts b/tests/unit/main/ipc/registry.test.ts index 6c34c2e..a4f1fbd 100644 --- a/tests/unit/main/ipc/registry.test.ts +++ b/tests/unit/main/ipc/registry.test.ts @@ -65,7 +65,7 @@ describe('IPC Registry Handlers', () => { const result = await handler({}, MenuScene.Desktop); - expect(mockMenuManager.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop); + expect(mockMenuManager.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop, false, 'high'); expect(result).toEqual(mockItems); }); }); diff --git a/tests/unit/main/services/BackupService.test.ts b/tests/unit/main/services/BackupService.test.ts index 6ced4fa..70990ce 100644 --- a/tests/unit/main/services/BackupService.test.ts +++ b/tests/unit/main/services/BackupService.test.ts @@ -106,11 +106,21 @@ describe('BackupService', () => { }); describe('deleteBackup', () => { - it('should call repo delete with correct id', () => { - service.deleteBackup(123); + it('should call repo delete with correct id', async () => { + mockRepo.findById.mockReturnValue({ + id: 123, name: 'Test', creationTime: '', type: BackupType.Manual, menuItemsJson: '[]', sha256Checksum: 'abc', + }); + + await service.deleteBackup(123); expect(mockRepo.delete).toHaveBeenCalledWith(123); }); + + it('should throw when backup not found', async () => { + mockRepo.findById.mockReturnValue(null); + + await expect(service.deleteBackup(999)).rejects.toThrow('备份快照不存在: id=999'); + }); }); describe('getAllBackups', () => { diff --git a/tests/unit/main/services/MenuManagerService.test.ts b/tests/unit/main/services/MenuManagerService.test.ts index 3b33193..d821f33 100644 --- a/tests/unit/main/services/MenuManagerService.test.ts +++ b/tests/unit/main/services/MenuManagerService.test.ts @@ -10,6 +10,7 @@ vi.mock('@/main/services/RegistryService'); vi.mock('@/main/services/OperationHistoryService'); vi.mock('@/main/utils/logger', () => ({ default: { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -29,6 +30,7 @@ describe('MenuManagerService', () => { createRollbackPoint: vi.fn(), commitTransaction: vi.fn(), rollback: vi.fn(), + invalidateCache: vi.fn(), } as MockedObject; mockHistory = { @@ -57,7 +59,7 @@ describe('MenuManagerService', () => { const result = await service.getMenuItems(MenuScene.Desktop); - expect(mockRegistry.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop); + expect(mockRegistry.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop, 'normal'); expect(result).toEqual(mockItems); }); }); diff --git a/tests/unit/main/services/OperationHistoryService.test.ts b/tests/unit/main/services/OperationHistoryService.test.ts new file mode 100644 index 0000000..2955fc9 --- /dev/null +++ b/tests/unit/main/services/OperationHistoryService.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, MockedObject } from 'vitest'; +import { OperationHistoryService } from '@/main/services/OperationHistoryService'; +import { OperationRecordRepo } from '@/main/data/repositories/OperationRecordRepo'; +import { MenuManagerService } from '@/main/services/MenuManagerService'; +import { MenuScene, MenuItemType, OperationType } from '@/shared/enums'; +import { OperationRecord } from '@/shared/types'; + +vi.mock('@/main/utils/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const makeRecord = (overrides: Partial = {}): OperationRecord => ({ + id: 1, + timestamp: '2026-01-01T00:00:00Z', + operationType: OperationType.Enable, + targetEntryName: 'TestItem', + registryPath: 'DesktopBackground\\Shell\\TestItem', + oldValue: null, + newValue: null, + ...overrides, +}); + +const makeItem = (scene: MenuScene, registryKey: string) => ({ + id: -1, + name: 'TestItem', + command: '', + iconPath: null, + isEnabled: true, + source: '', + menuScene: scene, + registryKey, + type: MenuItemType.System, +}); + +describe('OperationHistoryService', () => { + let service: OperationHistoryService; + let mockRepo: MockedObject; + let mockMenuManager: MockedObject; + + beforeEach(() => { + mockRepo = { + insert: vi.fn(), + findAll: vi.fn().mockReturnValue([]), + findById: vi.fn(), + deleteAll: vi.fn(), + } as unknown as MockedObject; + + mockMenuManager = { + enableItem: vi.fn(), + disableItem: vi.fn(), + invalidateCache: vi.fn(), + } as unknown as MockedObject; + + service = new OperationHistoryService(mockRepo); + }); + + // ── determineSceneFromRegistryKey(通过 undoOperation 间接测试)── + + describe('undoOperation — 场景路径解析', () => { + it('Directory\\Background\\shell → DirectoryBackground(不误判为 Folder)', async () => { + const record = makeRecord({ + operationType: OperationType.Enable, + registryPath: 'Directory\\Background\\shell\\TestItem', + }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.DirectoryBackground); + }); + + it('DesktopBackground → Desktop', async () => { + const record = makeRecord({ registryPath: 'DesktopBackground\\Shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Desktop); + }); + + it('CLSID\\{645FF040 → RecycleBin', async () => { + const record = makeRecord({ + registryPath: 'CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell\\TestItem', + }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.RecycleBin); + }); + + it('Drive\\shell → Drive', async () => { + const record = makeRecord({ registryPath: 'Drive\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Drive); + }); + + it('Directory\\shell → Folder', async () => { + const record = makeRecord({ registryPath: 'Directory\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Folder); + }); + + it('*\\shell → File', async () => { + const record = makeRecord({ registryPath: '*\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.File); + }); + + it('未知路径抛出错误', async () => { + const record = makeRecord({ registryPath: 'UNKNOWN\\path\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + + await expect( + service.undoOperation(1, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('无法从注册表路径确定场景'); + }); + }); + + // ── undoOperation 启用/禁用反转逻辑 ── + + describe('undoOperation — 启用/禁用反转', () => { + it('Enable 操作撤销 → 调用 disableItem', async () => { + const record = makeRecord({ operationType: OperationType.Enable }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.disableItem).toHaveBeenCalled(); + expect(mockMenuManager.enableItem).not.toHaveBeenCalled(); + }); + + it('Disable 操作撤销 → 调用 enableItem', async () => { + const record = makeRecord({ operationType: OperationType.Disable }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.enableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.enableItem).toHaveBeenCalled(); + expect(mockMenuManager.disableItem).not.toHaveBeenCalled(); + }); + + it('撤销不存在的记录 → 抛出异常', async () => { + vi.mocked(mockRepo.findById).mockReturnValue(null); + + await expect( + service.undoOperation(999, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('找不到要撤销的操作记录'); + }); + + it('不支持 Backup 类型的撤销 → 抛出异常', async () => { + const record = makeRecord({ operationType: OperationType.Backup }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + + await expect( + service.undoOperation(1, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('不支持该类型操作的撤销'); + }); + }); +}); diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 18ce5e6..43eba49 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -41,6 +41,290 @@ describe('PowerShellBridge', () => { expect(script).toContain('Get-ChildItem'); expect(script).toContain('ConvertTo-Json'); }); + + it('应输出 rawMUIVerb / rawDefault / rawLocalizedDisplayName 原始字段', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).toContain('rawMUIVerb'); + expect(script).toContain('rawDefault'); + expect(script).toContain('rawLocalizedDisplayName'); + }); + + it('不应包含 CmHelper 或 Resolve-MenuName(名称解析已移至 TS 层)', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).not.toContain('CmHelper'); + expect(script).not.toContain('Resolve-MenuName'); + expect(script).not.toContain('SHLoadIndirectString'); + }); + + it('应将正确的注册表路径嵌入脚本', () => { + const script = bridge.buildGetItemsScript('*\\shell'); + expect(script).toContain('HKCR:\\*\\shell'); + }); + + it('不应包含热键清理逻辑(热键清理已移至 TS 层 cleanDisplayName)', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + // -replace 只用于键名前缀剥离($handlerKeyName -replace '^-+'),不含加速键正则 + expect(script).not.toMatch(/\(&\\w\)|\(\\w\)|&\\w/); + }); + }); + + describe('buildGetShellExtItemsScript', () => { + it('不应包含 CmHelper / Resolve-ExtName / Test-IsGenericName(解析已移至 TS)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('CmHelper'); + expect(script).not.toContain('Resolve-ExtName'); + expect(script).not.toContain('Test-IsGenericName'); + expect(script).not.toContain('Test-IsUselessPlain'); + expect(script).not.toContain('Format-DisplayName'); + expect(script).not.toContain('SHLoadIndirectString'); + expect(script).not.toContain('GetLocalizedVerStrings'); + }); + + it('不应包含 CommandStore 索引构建(已移至独立脚本)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('cmdStoreVerbs'); + expect(script).not.toContain('CommandStore'); + }); + + it('应输出 handlerKeyName / cleanName / defaultVal 原始字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('handlerKeyName'); + expect(script).toContain('cleanName'); + expect(script).toContain('defaultVal'); + }); + + it('应输出 CLSID 子键原始字段 (clsidLocalizedString / clsidMUIVerb / clsidDefault)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('clsidLocalizedString'); + expect(script).toContain('clsidMUIVerb'); + expect(script).toContain('clsidDefault'); + }); + + it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('InprocServer32'); + expect(script).toContain('ExpandEnvironmentVariables'); + expect(script).toContain('dllPath'); + }); + + it('应输出 siblingMUIVerb 字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('siblingMUIVerb'); + }); + + it('应包含 sibling shell 路径推导逻辑', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('$shellPath'); + expect(script).toContain('$siblingVerbPath'); + expect(script).toContain('ContextMenuHandlers$'); + }); + + it('ForEach 循环应使用 $actualClsid 和 $defaultVal 分离 CLSID', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('$actualClsid'); + }); + + it('不应包含硬编码 friendlyNames 映射表', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('$friendlyNames'); + expect(script).not.toContain('friendlyNames.ContainsKey'); + }); + + it('不应包含热键清理逻辑(已移至 TS 层)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // -replace 只用于键名前缀剥离,不含加速键正则 + expect(script).not.toMatch(/\(&\\w\)|\(\\w\)|&\\w/); + }); + + it('不应包含 ReadDllStrings(已移除)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('ReadDllStrings'); + expect(script).not.toContain('LoadLibraryEx'); + }); + + it('不应包含 CmHelper.Ver 版本校验', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain("[CmHelper]::Ver"); + }); + + it('不应包含 Level 级别注释(Level 逻辑已移至 TS)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('Level 0:'); + expect(script).not.toContain('Level 1:'); + expect(script).not.toContain('Level 2'); + expect(script).not.toContain('Level 3:'); + }); + + it('不应包含 C# 源码 Add-Type 编译', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('using System;'); + expect(script).not.toContain('Add-Type -TypeDefinition'); + }); + }); + + describe('buildCommandStoreScript', () => { + it('应包含 CommandStore\\shell 路径', () => { + const script = bridge.buildCommandStoreScript(); + + expect(script).toContain('CommandStore\\shell'); + }); + + it('应读取 ExplorerCommandHandler 和 MUIVerb', () => { + const script = bridge.buildCommandStoreScript(); + + expect(script).toContain('ExplorerCommandHandler'); + expect(script).toContain("GetValue('MUIVerb')"); + }); + + it('应输出 clsid 和 muiverb 字段', () => { + const script = bridge.buildCommandStoreScript(); + + expect(script).toContain('clsid'); + expect(script).toContain('muiverb'); + }); + + it('不应包含 CmHelper 或 SHLoadIndirectString', () => { + const script = bridge.buildCommandStoreScript(); + + expect(script).not.toContain('CmHelper'); + expect(script).not.toContain('SHLoadIndirectString'); + }); + }); + + describe('并发信号量', () => { + it('同时发起 5 个 execute,最大并发数不超过 3', async () => { + const childProcess = await import('child_process'); + const execFileMock = vi.mocked(childProcess.execFile); + + let activeCalls = 0; + let maxActive = 0; + const pendingCallbacks: Array<() => void> = []; + + // promisify 标准行为:单对象参数会直接作为解析值, + // 使 execFileAsync 解析为 { stdout, stderr } 对象 + execFileMock.mockImplementation(((_cmd: any, _args: any, _opts: any, cb: any) => { + activeCalls++; + maxActive = Math.max(maxActive, activeCalls); + pendingCallbacks.push(() => { + activeCalls--; + cb(null, { stdout: '[]', stderr: '' }); + }); + }) as any); + + try { + const promises = Array.from({ length: 5 }, () => + bridge.execute('echo test') + ); + + // 等待微任务队列清空,让信号量处理排队 + await new Promise((r) => setImmediate(r)); + + // 此时应只有 maxConcurrent=3 个 execFile 在运行 + expect(activeCalls).toBeLessThanOrEqual(3); + + // 逐个完成,验证排队的请求能正确被释放 + while (pendingCallbacks.length > 0) { + pendingCallbacks.shift()!(); + await new Promise((r) => setImmediate(r)); + } + + await Promise.all(promises); + + // 整个过程中最大并发数恰好为 3 + expect(maxActive).toBe(3); + } finally { + // 恢复原始同步 mock 实现 + execFileMock.mockImplementation(((_cmd: any, _args: any, _opts: any, cb: any) => { + cb(null, { stdout: '[]', stderr: '' }); + }) as any); + } + }); + + it('high 优先级请求应插队到 normal 请求之前完成', async () => { + const childProcess = await import('child_process'); + const execFileMock = vi.mocked(childProcess.execFile); + + const completionOrder: string[] = []; + const pendingCallbacks: Array<() => void> = []; + + execFileMock.mockImplementation(((_cmd: any, _args: any, _opts: any, cb: any) => { + pendingCallbacks.push(() => cb(null, { stdout: '[]', stderr: '' })); + }) as any); + + try { + // 饱和全部 3 个槽(normal 优先级) + const s1 = bridge.execute('s1'); + const s2 = bridge.execute('s2'); + const s3 = bridge.execute('s3'); + await new Promise((r) => setImmediate(r)); + + // 入队:2 个 normal,然后 1 个 high(high 用 unshift 插到队首) + bridge.execute('n1').then(() => completionOrder.push('normal1')); + bridge.execute('n2').then(() => completionOrder.push('normal2')); + const highP = bridge.execute('h', 'high').then(() => completionOrder.push('high')); + await new Promise((r) => setImmediate(r)); + + // 依次释放全部 callbacks,每次等微任务链完成 + // 释放 s1 后,high 插队获得槽(unshift);后续释放 s2/s3 让 normal 获得槽 + while (pendingCallbacks.length > 0) { + pendingCallbacks.shift()!(); + await new Promise((r) => setImmediate(r)); + } + + await Promise.all([s1, s2, s3, highP]); + + // high 应是第一个完成的 + expect(completionOrder[0]).toBe('high'); + } finally { + execFileMock.mockImplementation(((_cmd: any, _args: any, _opts: any, cb: any) => { + cb(null, { stdout: '[]', stderr: '' }); + }) as any); + } + }); }); describe('buildSetEnabledScript', () => { diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 5e893fd..9905ecf 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -1,63 +1,226 @@ import { describe, it, expect, vi, beforeEach, MockedObject } from 'vitest'; import { RegistryService } from '@/main/services/RegistryService'; import { PowerShellBridge } from '@/main/services/PowerShellBridge'; +import { ShellExtNameResolver, CommandStoreIndex } from '@/main/services/ShellExtNameResolver'; +import { IWin32Shell } from '@/main/services/Win32Shell'; import { MenuScene, MenuItemType } from '@shared/enums'; // Mock PowerShellBridge vi.mock('@/main/services/PowerShellBridge'); +function createMockPs(): MockedObject { + return { + buildGetItemsScript: vi.fn(), + buildGetShellExtItemsScript: vi.fn(), + buildSetEnabledScript: vi.fn(), + buildShellExtToggleScript: vi.fn(), + buildCommandStoreScript: vi.fn(), + execute: vi.fn(), + executeElevated: vi.fn(), + } as unknown as MockedObject; +} + +function createMockResolver(resolveClassicName?: (raw: any) => string, resolveExtName?: (raw: any, cmdStore: any) => string): ShellExtNameResolver { + const win32: IWin32Shell = { + resolveIndirect: vi.fn().mockReturnValue(null), + uiLanguage: 'zh', + }; + const resolver = new ShellExtNameResolver(win32); + if (resolveClassicName) vi.spyOn(resolver, 'resolveClassicName').mockImplementation(resolveClassicName); + if (resolveExtName) vi.spyOn(resolver, 'resolveExtName').mockImplementation(resolveExtName); + return resolver; +} + describe('RegistryService', () => { let service: RegistryService; let mockPs: MockedObject; + let mockResolver: ShellExtNameResolver; + let mockCmdStore: CommandStoreIndex; beforeEach(() => { - mockPs = { - buildGetItemsScript: vi.fn(), - buildGetShellExtItemsScript: vi.fn(), - buildSetEnabledScript: vi.fn(), - buildShellExtToggleScript: vi.fn(), - execute: vi.fn(), - executeElevated: vi.fn(), - } as MockedObject; - - service = new RegistryService(mockPs); + mockPs = createMockPs(); + mockResolver = createMockResolver(); + mockCmdStore = new CommandStoreIndex(); + service = new RegistryService(mockPs, mockResolver, mockCmdStore); }); describe('getMenuItems', () => { it('should return empty array when no items found', async () => { (mockPs.execute as ReturnType).mockResolvedValue([]); - + const result = await service.getMenuItems(MenuScene.Desktop); - + expect(result).toEqual([]); }); - it('should parse and return menu items correctly', async () => { + it('should parse Classic Shell items through resolver', async () => { const rawItems = [{ - name: 'Test Menu', - command: 'test.exe', - iconPath: 'C:\\icon.ico', - isEnabled: true, - source: 'TestApp', - registryKey: 'HKCR\\\\Test\\\\shell\\\\Test Menu', subKeyName: 'Test Menu', + rawMUIVerb: '@shell32.dll,-1234', + rawDefault: 'Open', + rawLocalizedDisplayName: null, + rawIcon: 'C:\\icon.ico', + isEnabled: true, + command: 'test.exe', + registryKey: 'HKCR\\Test\\shell\\Test Menu', }]; - - // Mock both Classic Shell and ShellExt calls - mockPs.execute.mockResolvedValueOnce(rawItems) // Classic Shell - .mockResolvedValueOnce([]); // ShellExt (empty) - + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + const result = await service.getMenuItems(MenuScene.File); - + expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: 'Test Menu', - command: 'test.exe', + expect(result[0].command).toBe('test.exe'); + expect(result[0].menuScene).toBe(MenuScene.File); + expect(result[0].type).toBe(MenuItemType.Custom); + }); + + it('should parse ShellExt items through resolver', async () => { + const shellextItems = [{ + handlerKeyName: 'YunShellExt', + cleanName: 'YunShellExt', + defaultVal: '', + isEnabled: true, + actualClsid: '{BIG-DATA-CLSID}', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: 'C:\\Program Files\\YunShellExt\\YunShellExt64.dll', + siblingMUIVerb: null, + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\YunShellExt', + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe(MenuItemType.ShellExt); + expect(result[0].dllPath).toBe('C:\\Program Files\\YunShellExt\\YunShellExt64.dll'); + }); + }); + + describe('名称净化(热键清理 cleanDisplayName)', () => { + it('应清理带括号加速键整体,不留残余括号', async () => { + const cases = [ + { input: '使用 Visual Studio 打开(&V)', expected: '使用 Visual Studio 打开' }, + { input: '个性化(&R)', expected: '个性化' }, + { input: '加入 QQ音乐 播放队列(&E)', expected: '加入 QQ音乐 播放队列' }, + ]; + + for (const { input, expected } of cases) { + const freshPs = createMockPs(); + // resolver returns the raw name as-is (only cleanDisplayName cleans it) + const freshResolver = createMockResolver((raw: any) => input); + const freshService = new RegistryService(freshPs, freshResolver, mockCmdStore); + + const rawItems = [{ + subKeyName: 'TestItem', + rawMUIVerb: input, + rawDefault: null, + rawLocalizedDisplayName: null, + rawIcon: null, + isEnabled: true, + command: '', + registryKey: 'DesktopBackground\\Shell\\TestItem', + }]; + + freshPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await freshService.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe(expected); + expect(result[0].name).not.toContain('('); + expect(result[0].name).not.toContain(')'); + } + }); + }); + + describe('resolver passes through to cleanDisplayName', () => { + it('正常名称应通过 resolver 并正确显示', async () => { + const rawItems = [{ + subKeyName: 'TestItem', + rawMUIVerb: null, + rawDefault: '在桌面上显示', + rawLocalizedDisplayName: null, + rawIcon: null, isEnabled: true, - source: 'TestApp', - menuScene: MenuScene.File, - type: MenuItemType.System, - }); + command: '', + registryKey: 'DesktopBackground\\Shell\\TestItem', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe('在桌面上显示'); + }); + + it('ShellExt 条目名称应由 resolver 决定', async () => { + const shellextItems = [{ + handlerKeyName: 'DesktopSlideshow', + cleanName: 'DesktopSlideshow', + defaultVal: '@windows.immersivecontrolpanel.dll,-1', + isEnabled: true, + actualClsid: '{2CC2D03E-B04A-43BE-A6BE-8C20E6A64F87}', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: null, + siblingMUIVerb: null, + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\DesktopSlideshow', + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('DesktopSlideshow'); + }); + }); + + describe('dllPath 字段透传', () => { + it('ShellExt 条目应将 dllPath 传入 MenuItemEntry', async () => { + const shellextItems = [{ + handlerKeyName: 'gvim', + cleanName: 'gvim', + defaultVal: '', + isEnabled: true, + actualClsid: '{51EEE242-AD87-11d3-9C1E-0090278BBD99}', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: 'C:\\Program Files\\Vim\\vim91\\gvimext.dll', + siblingMUIVerb: null, + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\gvim', + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result).toHaveLength(1); + expect(result[0].dllPath).toBe('C:\\Program Files\\Vim\\vim91\\gvimext.dll'); + }); + + it('Classic Shell 条目 dllPath 应为 null', async () => { + const rawItems = [{ + subKeyName: 'Classic', + rawMUIVerb: null, + rawDefault: 'Classic Item', + rawLocalizedDisplayName: null, + rawIcon: null, + isEnabled: true, + command: 'cmd.exe', + registryKey: 'DesktopBackground\\Shell\\Classic', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].dllPath).toBeNull(); }); }); @@ -67,19 +230,13 @@ describe('RegistryService', () => { { registryKey: 'key1', isEnabled: true }, { registryKey: 'key2', isEnabled: false }, ]; - + service.createRollbackPoint(items); - - // Rollback point should be stored internally - // This is verified by rollback behavior }); it('should commit transaction and clear rollback data', () => { service.createRollbackPoint([{ registryKey: 'key', isEnabled: true }]); service.commitTransaction(); - - // After commit, rollback data should be cleared - // This is verified by subsequent operations }); }); }); diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts new file mode 100644 index 0000000..b7c20fb --- /dev/null +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -0,0 +1,480 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + ShellExtNameResolver, + CommandStoreIndex, + PsRawClassicItem, + PsRawShellExtItem, +} from '@/main/services/ShellExtNameResolver'; +import { IWin32Shell } from '@/main/services/Win32Shell'; + +function createMockWin32(lang: 'zh' | 'en' = 'zh'): IWin32Shell { + return { + resolveIndirect: vi.fn().mockReturnValue(null), + uiLanguage: lang, + }; +} + +function createClassicItem(overrides: Partial = {}): PsRawClassicItem { + return { + subKeyName: 'TestItem', + rawMUIVerb: null, + rawDefault: null, + rawLocalizedDisplayName: null, + rawIcon: null, + isEnabled: true, + command: 'test.exe', + registryKey: 'DesktopBackground\\Shell\\TestItem', + ...overrides, + }; +} + +function createShellExtItem(overrides: Partial = {}): PsRawShellExtItem { + return { + handlerKeyName: 'TestExt', + cleanName: 'TestExt', + defaultVal: '', + isEnabled: true, + actualClsid: '{12345678-1234-1234-1234-123456789ABC}', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: null, + dllFileDescription: null, + dllProductName: null, + progIdName: null, + siblingMUIVerb: null, + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\TestExt', + ...overrides, + }; +} + +describe('ShellExtNameResolver', () => { + let resolver: ShellExtNameResolver; + let win32: IWin32Shell; + + beforeEach(() => { + win32 = createMockWin32(); + resolver = new ShellExtNameResolver(win32); + }); + + // ---- Classic Shell 名称解析 ---- + + describe('resolveClassicName', () => { + it('should return MUIVerb as plain string when available', () => { + const item = createClassicItem({ rawMUIVerb: '用Vim编辑' }); + expect(resolver.resolveClassicName(item)).toBe('用Vim编辑'); + }); + + it('should resolve MUIVerb via resolveIndirect when it starts with @', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('打开方式'); + const item = createClassicItem({ rawMUIVerb: '@shell32.dll,-8510' }); + expect(resolver.resolveClassicName(item)).toBe('打开方式'); + }); + + it('should resolve MUIVerb via resolveIndirect when it starts with ms-resource:', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('个性化'); + const item = createClassicItem({ rawMUIVerb: 'ms-resource:Personalize' }); + expect(resolver.resolveClassicName(item)).toBe('个性化'); + }); + + it('should fallback to Default (rawDefault) when MUIVerb is null', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: 'Open' }); + expect(resolver.resolveClassicName(item)).toBe('Open'); + }); + + it('should fallback to Default as @ format resolved', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('自定义'); + const item = createClassicItem({ rawMUIVerb: null, rawDefault: '@mydll.dll,-100' }); + expect(resolver.resolveClassicName(item)).toBe('自定义'); + }); + + it('should fallback to LocalizedDisplayName', () => { + const item = createClassicItem({ + rawMUIVerb: null, + rawDefault: null, + rawLocalizedDisplayName: '显示设置', + }); + expect(resolver.resolveClassicName(item)).toBe('显示设置'); + }); + + it('should fallback to subKeyName when all candidates are null', () => { + const item = createClassicItem({ + rawMUIVerb: null, + rawDefault: null, + rawLocalizedDisplayName: null, + subKeyName: 'FallbackKey', + }); + expect(resolver.resolveClassicName(item)).toBe('FallbackKey'); + }); + + it('should skip empty candidates', () => { + const item = createClassicItem({ + rawMUIVerb: '', + rawDefault: ' ', // length >= 2 + }); + // empty string skipped, ' ' has length >= 2 so used as-is + expect(resolver.resolveClassicName(item)).toBe(' '); + }); + }); + + // ---- Shell 扩展名称解析(多级回退) ---- + + describe('resolveExtName', () => { + const cmdStore = new CommandStoreIndex(); + + it('fallback 返回 cleanName', () => { + const item = createShellExtItem({ cleanName: 'MyExt' }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('MyExt'); + }); + + // Level 0: directName indirect format + it('Level 0: 应解析 @dll,-id 格式的 defaultVal', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('打开方式'); + const item = createShellExtItem({ + defaultVal: '@shell32.dll,-8510', + cleanName: 'OpenWith', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('打开方式'); + }); + + it('Level 0: 应解析 ms-resource: 格式的 defaultVal', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('共享'); + const item = createShellExtItem({ + defaultVal: 'ms-resource:Share', + cleanName: 'ShareHandler', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('共享'); + }); + + // Level 1: CLSID.LocalizedString + it('Level 1: 应返回 plain LocalizedString', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'Windows 照片查看器', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('Windows 照片查看器'); + }); + + it('Level 1: 应解析间接格式的 LocalizedString', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('编辑'); + const item = createShellExtItem({ + clsidLocalizedString: '@notepad.dll,-100', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('编辑'); + }); + + it('Level 1: 应过滤等于 fallback 的 LocalizedString', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'TestExt', // same as cleanName + cleanName: 'TestExt', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); // falls through to fallback + }); + + // Level 1.3: Sibling Shell Key MUIVerb + it('Level 1.3: 应返回 plain sibling MUIVerb', () => { + const item = createShellExtItem({ + siblingMUIVerb: '用Vim编辑', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('用Vim编辑'); + }); + + it('Level 1.3: 应解析间接格式的 sibling MUIVerb', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('压缩'); + const item = createShellExtItem({ + siblingMUIVerb: '@zip.dll,-200', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('压缩'); + }); + + // Level 1.5: CLSID.MUIVerb + it('Level 1.5: 应返回 plain CLSID MUIVerb', () => { + const item = createShellExtItem({ + clsidMUIVerb: '固定到任务栏', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('固定到任务栏'); + }); + + it('Level 1.5: 应解析间接格式的 MUIVerb', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue('扫描'); + const item = createShellExtItem({ + clsidMUIVerb: '@scanner.dll,-50', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('扫描'); + }); + + // Level 1.7: CommandStore reverse + it('Level 1.7: 应从 CommandStore 索引查找', () => { + const store = new CommandStoreIndex(); + store.buildFromData([ + { clsid: '{12345678-1234-1234-1234-123456789ABC}', muiverb: '固定到快速访问' }, + ]); + const item = createShellExtItem(); + expect(resolver.resolveExtName(item, store)).toBe('固定到快速访问'); + }); + + it('Level 1.7: CommandStore 不区分大小写', () => { + const store = new CommandStoreIndex(); + store.buildFromData([ + { clsid: '{12345678-1234-1234-1234-123456789abc}', muiverb: '大小写测试' }, + ]); + const item = createShellExtItem({ + actualClsid: '{12345678-1234-1234-1234-123456789ABC}', + }); + expect(resolver.resolveExtName(item, store)).toBe('大小写测试'); + }); + + // Level 2: CLSID Default + it('Level 2: 应返回非泛型的 CLSID Default', () => { + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: '阿里云盘外壳扩展', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('阿里云盘外壳扩展'); + }); + + it('Level 2: 应过滤等于 fallback 的 CLSID Default(如 gvim → Default = "gvim")', () => { + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: 'gvim', // same as cleanName + cleanName: 'gvim', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('gvim'); + }); + + // Level 2.5: DLL FileDescription + it('Level 2.5: 应返回 DLL FileDescription(PS 采集)', () => { + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllFileDescription: '阿里云盘', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('阿里云盘'); + }); + + it('Level 2.5: 应过滤泛型 DLL 描述(如 "Vim Shell Extension")', () => { + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllFileDescription: 'Vim Shell Extension', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); // fallback + }); + + // Level 3: directName plain string + it('Level 3: 应返回非泛型的 plain directName', () => { + const item = createShellExtItem({ + defaultVal: 'Edit with Notepad++', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('Edit with Notepad++'); + }); + + it('Level 3: 应过滤等于 fallback 的 directName', () => { + const item = createShellExtItem({ + defaultVal: 'TestExt', // same as cleanName + cleanName: 'TestExt', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + }); + + // ---- 泛型名称过滤 ---- + + describe('isGenericName (via resolveExtName)', () => { + const cmdStore = new CommandStoreIndex(); + + it('Group A: 应过滤 "Context Menu Handler"', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'Context Menu Handler', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group A: 应过滤 "Shell Extension"', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'Shell Extension', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group A: 应过滤 "shell extension" 后缀(WinRAR)', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'WinRAR shell extension', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group A: 应过滤 ".dll" 文件名', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'shellext.dll', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group A: 应过滤 "Microsoft Windows *" 系统描述', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'Microsoft Windows Operating System', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group B: 应过滤 "* Class" COM 类名', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'PcyybContextMenu Class', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group C: 应过滤 "TODO:" 占位符', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'TODO: Add description', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group C: 应过滤尖括号占位符', () => { + const item = createShellExtItem({ + clsidLocalizedString: '', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group C: 应过滤 "n/a" / "none" / "unknown"', () => { + for (const val of ['n/a', 'N/A', 'none', 'None', 'unknown', 'untitled']) { + const item = createShellExtItem({ clsidLocalizedString: val }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + } + }); + + it('Group D: 应过滤冠词开头的句子', () => { + const item = createShellExtItem({ + clsidLocalizedString: 'a small project for the context menu of gvim!', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Group D: 应过滤括号完全包裹的调试标记', () => { + for (const val of ['(调试)', '(Debug)', '(unknown)']) { + const item = createShellExtItem({ clsidLocalizedString: val }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + } + }); + + it('不应误杀正常产品名', () => { + const validNames = [ + 'Quark AI Context Menu', + '百度网盘', + '阿里云盘', + '用Vim编辑', + 'Edit with Notepad++', + '7-Zip (64-bit)', + 'Adobe Acrobat', + ]; + for (const name of validNames) { + const item = createShellExtItem({ clsidLocalizedString: name }); + expect(resolver.resolveExtName(item, cmdStore)).toBe(name); + } + }); + + it('应过滤 "外壳服务对象" COM 描述', () => { + const item = createShellExtItem({ + clsidLocalizedString: '外壳服务对象', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + }); + + // ---- 标准谓词翻译 ---- + + describe('Standard Verb Translation', () => { + const cmdStore = new CommandStoreIndex(); + + it('应翻译标准动词 open → 打开', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'open' }); + expect(resolver.resolveClassicName(item)).toBe('打开'); + }); + + it('应翻译标准动词 edit → 编辑', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'edit' }); + expect(resolver.resolveClassicName(item)).toBe('编辑'); + }); + + it('应翻译标准动词 properties → 属性', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'properties' }); + expect(resolver.resolveClassicName(item)).toBe('属性'); + }); + + it('应翻译标准动词 runas → 以管理员身份运行', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'runas' }); + expect(resolver.resolveClassicName(item)).toBe('以管理员身份运行'); + }); + + it('英文模式下应返回英文翻译', () => { + const enWin32 = createMockWin32('en'); + const enResolver = new ShellExtNameResolver(enWin32, 'en'); + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'open' }); + expect(enResolver.resolveClassicName(item)).toBe('Open'); + }); + + it('大小写不敏感匹配', () => { + const item = createClassicItem({ rawMUIVerb: null, rawDefault: null, subKeyName: 'Open' }); + expect(resolver.resolveClassicName(item)).toBe('打开'); + }); + + it('ShellExt cleanName 为 sendto 时应翻译为 发送到', () => { + const item = createShellExtItem({ + cleanName: 'sendto', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('发送到'); + }); + + it('ShellExt cleanName 为 print 时应翻译为 打印', () => { + const item = createShellExtItem({ + cleanName: 'print', + clsidLocalizedString: null, + clsidMUIVerb: null, + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('打印'); + }); + }); + + // ---- CommandStoreIndex ---- + + describe('CommandStoreIndex', () => { + it('应正确构建索引并查找', () => { + const store = new CommandStoreIndex(); + store.buildFromData([ + { clsid: '{AAA}', muiverb: 'Foo' }, + { clsid: '{BBB}', muiverb: 'Bar' }, + ]); + expect(store.get('{AAA}')).toBe('Foo'); + expect(store.get('{BBB}')).toBe('Bar'); + expect(store.get('{CCC}')).toBeNull(); + }); + + it('不区分大小写查找', () => { + const store = new CommandStoreIndex(); + store.buildFromData([{ clsid: '{abc-DEF}', muiverb: 'Test' }]); + expect(store.get('{ABC-def}')).toBe('Test'); + }); + + it('invalidate 应清空索引', () => { + const store = new CommandStoreIndex(); + store.buildFromData([{ clsid: '{AAA}', muiverb: 'Foo' }]); + store.invalidate(); + expect(store.get('{AAA}')).toBeNull(); + }); + }); +}); diff --git a/vite.main.config.ts b/vite.main.config.ts index cf4b68a..5ce781e 100644 --- a/vite.main.config.ts +++ b/vite.main.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['better-sqlite3', 'electron-log', 'electron'], + external: ['better-sqlite3', 'electron-log', 'electron', 'koffi'], }, }, });