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 LayermainPage.ts - 用户交互] -->|IPC| B[IPC HandlersElectron IPC 通信层] + B --> C[MenuManagerService.ts菜单管理服务业务逻辑、缓存控制、事务管理] + C --> D[RegistryService.ts注册表服务Classic Shell + Shell Ext 解析、缓存] + D --> E[PowerShellBridge.tsPowerShell 脚本注册表枚举 + DLL 版本信息采集] + D --> F[ShellExtNameResolver.ts名称解析器多级回退链 + 过滤 + 翻译] + F --> G[Win32Shell.tskoffi FFISHLoadIndirectString] +``` + +> **关键设计决策**:名称解析逻辑全部在 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 @@ ✕ + + + 新建备份 + + + + + + + + + + 确认操作 + + + + + + + + + 提示 + + + + + +