From a09f88825a577cc1e153aea3ce7254288e695d67 Mon Sep 17 00:00:00 2001 From: tanzz Date: Fri, 13 Mar 2026 21:53:09 +0800 Subject: [PATCH 01/31] =?UTF-8?q?docs:=20=E5=B0=86=20AGENTS.md=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E4=B8=BA=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完整翻译所有章节为简体中文 - 添加测试命令详细说明 - 补充 TypeScript 严格模式配置 feat: 添加 PowerShell 脚本工具 - cleanup.ps1: 清理脚本 - task.ps1: 任务管理脚本 --- AGENTS.md | 125 +++++++++++++++++++++++++-------------------- script/cleanup.ps1 | 6 +++ script/task.ps1 | 13 +++++ 3 files changed, 88 insertions(+), 56 deletions(-) create mode 100644 script/cleanup.ps1 create mode 100644 script/task.ps1 diff --git a/AGENTS.md b/AGENTS.md index 009038e..d216593 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,76 +1,89 @@ -# Agent Guidelines for ContextMaster +# 代理指南 - ContextMaster -## Build & Run Commands +## 构建与运行命令 ```bash -# Development (requires admin privileges) +# 开发模式(需要管理员权限) npm start -# Build +# 构建 npm run build npm run package npm run make -# Lint +# 代码检查 npm run lint -# Rebuild native modules (after Node/Electron version changes) +# 测试(Vitest + Playwright) +npm test # 运行所有测试 +npm run test:unit # 运行单元测试一次 +npm run test:unit:watch # 以监视模式运行单元测试 +npm run test:unit:ui # 打开 Vitest UI 界面 +npm run test:coverage # 运行测试并生成覆盖率报告 +npm run test:e2e # 运行 Playwright 端到端测试 +npm run test:e2e:ui # 打开 Playwright UI 模式 + +# 重建原生模块(Node/Electron 版本变更后) npx electron-rebuild ``` -## Code Style Guidelines +## 代码风格指南 ### TypeScript -- Strict mode enabled -- Use `type` for object shapes, `interface` for extensible contracts -- Prefer explicit return types on public methods -- Use `readonly` for immutable properties - -### Imports (grouped in this order) -1. Node built-ins (`path`, `fs`, etc.) -2. External packages (`electron`, `better-sqlite3`) -3. Internal absolute imports (`@shared/*`, `@main/*`) -4. Relative imports (`./`, `../`) - -### Naming Conventions -- `PascalCase`: Classes, interfaces, types, enums -- `camelCase`: Variables, functions, methods, properties -- `UPPER_SNAKE_CASE`: Constants, enum values -- Private fields: use `private` modifier, no `_` prefix -- IPC channels: `SCREAMING_SNAKE_CASE` in `shared/ipc-channels.ts` - -### Error Handling -- Use `IpcResult` pattern for IPC returns: `{ success: true, data: T } | { success: false, error: string }` -- Wrap IPC handlers with `wrapHandler()` (see `main/utils/ipcWrapper.ts`) -- Use `electron-log` for logging; never log secrets -- Prefer `try/catch` with specific error messages - -### Registry Operations -- All registry writes must create a rollback point first -- Disable entries via `LegacyDisable` string value -- Enable entries by deleting `LegacyDisable` value -- Requires admin privileges (UAC manifest configured) - -### Database (better-sqlite3) -- Use WAL mode enabled -- Synchronous API (database operations are blocking) -- Repository pattern in `main/data/repositories/` - -### Architecture +- 启用严格模式 +- 使用 `type` 定义对象形状,使用 `interface` 定义可扩展的契约 +- 公共方法优先使用显式返回类型 +- 使用 `readonly` 定义不可变属性 +- 启用 `noImplicitAny` 和 `strictNullChecks` + +### 导入顺序(按以下顺序分组) +1. Node 内置模块(`path`、`fs` 等) +2. 外部包(`electron`、`better-sqlite3`) +3. 内部绝对路径导入(`@shared/*`、`@main/*`) +4. 相对路径导入(`./`、`../`) + +### 命名规范 +- `PascalCase`:类、接口、类型、枚举 +- `camelCase`:变量、函数、方法、属性 +- `UPPER_SNAKE_CASE`:常量、枚举值 +- 私有字段:使用 `private` 修饰符,不加 `_` 前缀 +- IPC 通道:在 `shared/ipc-channels.ts` 中使用 `SCREAMING_SNAKE_CASE` + +### 错误处理 +- 使用 `IpcResult` 模式作为 IPC 返回值:`{ success: true, data: T } | { success: false, error: string }` +- 使用 `wrapHandler()` 包装 IPC 处理器(参见 `main/utils/ipcWrapper.ts`) +- 使用 `electron-log` 进行日志记录;切勿记录敏感信息 +- 优先使用 `try/catch` 并提供具体的错误信息 + +### 注册表操作 +- 所有注册表写入操作必须先创建回滚点 +- 通过 `LegacyDisable` 字符串值禁用条目 +- 通过删除 `LegacyDisable` 值启用条目 +- 需要管理员权限(已配置 UAC 清单) + +### 数据库(better-sqlite3) +- 启用 WAL 模式 +- 使用同步 API(数据库操作是阻塞的) +- 在 `main/data/repositories/` 中使用仓库模式 + +### 测试 +- 单元测试:使用 Vitest,配合 `happy-dom` 进行 DOM 模拟 +- 端到端测试:使用 Playwright 进行集成测试 +- 测试文件:`tests/unit/**/*.test.ts` 或 `tests/unit/**/*.spec.ts` +- 覆盖率:使用 V8 提供程序,输出文本、JSON 和 HTML 报告 + +### 架构 ``` src/ -├── shared/ # Types, enums, IPC channel constants -├── main/ # Main process: services, data, IPC handlers -├── preload/ # contextBridge exposure -└── renderer/ # Renderer process: pages, API bridge +├── shared/ # 类型、枚举、IPC 通道常量 +├── main/ # 主进程:服务、数据、IPC 处理器 +├── preload/ # contextBridge 暴露 +└── renderer/ # 渲染进程:页面、API 桥接 ``` -## Testing Notes -- No test framework configured; run `npm start` for manual testing -- Must run as Administrator on Windows - -## Key Files -- `src/shared/ipc-channels.ts`: IPC channel definitions -- `src/shared/types.ts`: Core type definitions -- `src/main/utils/ipcWrapper.ts`: IPC handler wrapper -- `forge.config.ts`: Electron Forge configuration +## 关键文件 +- `src/shared/ipc-channels.ts`:IPC 通道定义 +- `src/shared/types.ts`:核心类型定义 +- `src/main/utils/ipcWrapper.ts`:IPC 处理器包装器 +- `forge.config.ts`:Electron Forge 配置 +- `vitest.config.ts`:Vitest 测试配置 diff --git a/script/cleanup.ps1 b/script/cleanup.ps1 new file mode 100644 index 0000000..fb22346 --- /dev/null +++ b/script/cleanup.ps1 @@ -0,0 +1,6 @@ +param( + [string]$Task +) + +git worktree remove ".ai/worktrees/$Task" -f +git branch -D "task/$Task" \ No newline at end of file diff --git a/script/task.ps1 b/script/task.ps1 new file mode 100644 index 0000000..bf656fa --- /dev/null +++ b/script/task.ps1 @@ -0,0 +1,13 @@ +param( + [string]$Task +) + +$path = ".ai/worktrees/$Task" +$branch = "task/$Task" + +git worktree add $path -b $branch + +Write-Host "Worktree created:" +Write-Host $path + +wt new-tab powershell -NoExit -Command "cd $path" \ No newline at end of file From a552f402c4a3553c71a809280982af7147934ef6 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 00:26:04 +0800 Subject: [PATCH 02/31] refactor(scripts): update worktree creation and permissions settings - Simplify Windows Terminal command for new tab in task.ps1 - Clean up and standardize permissions in settings.json --- .claude/settings.json | 16 +++++++--------- script/task.ps1 | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index d6edf78..6741230 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,14 +1,12 @@ { "permissions": { "allow": [ - "Bash(python3 -c \":*)", - "Bash(dotnet *)", - "Bash(grep *)", - "Bash(mkdir *)", - "Bash(mv src/*)", - "Bash(mv temp*)", - "Bash(cp *)", - "Bash(rm -rf src/ContextMaster.UI/obj src/ContextMaster.UI/bin)" + "Bash(grep:*)", + "Bash(mkdir:*)", + "Bash(cp:*)", + "Bash(git:*)", + "Bash(pnpm:*)", + "Bash(npx vitest:*)" ] } -} +} \ No newline at end of file diff --git a/script/task.ps1 b/script/task.ps1 index bf656fa..29f96d6 100644 --- a/script/task.ps1 +++ b/script/task.ps1 @@ -10,4 +10,4 @@ git worktree add $path -b $branch Write-Host "Worktree created:" Write-Host $path -wt new-tab powershell -NoExit -Command "cd $path" \ No newline at end of file +wt -w 0 new-tab -d $path \ No newline at end of file From 319b590817442e71440c2017f52883d074d1ae0e Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 00:44:04 +0800 Subject: [PATCH 03/31] chore: update claude settings with additional file permissions --- .claude/settings.json | 6 +++++- .claude/settings.local.json | 30 ------------------------------ 2 files changed, 5 insertions(+), 31 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.json b/.claude/settings.json index 6741230..b7d993b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,11 @@ "Bash(cp:*)", "Bash(git:*)", "Bash(pnpm:*)", - "Bash(npx vitest:*)" + "Bash(npx vitest:*)", + "Read(**)", + "Write(src/**)", + "Write(tests/**)", + "Write(docs/**)" ] } } \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e38b1cd..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build)", - "Bash(rm -f src/ContextMaster.Core/Services/*.cs)", - "Bash(rm -f src/ContextMaster.Core/ViewModels/*.cs)", - "Bash(rm -f src/ContextMaster.Data/*.cs)", - "Bash(dir *)", - "Bash(dotnet *)", - "Bash(./ContextMaster.UI.exe)", - "Bash(cmd /c \"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\2022\\\\Community\\\\MSBuild\\\\Current\\\\Bin\\\\MSBuild.exe ContextMaster.sln /p:Configuration=Debug /p:Platform=x64\")", - "Bash(cmd /c \"C:\\\\Program Files\\\\Microsoft Visual Studio\\\\2022\\\\Community\\\\MSBuild\\\\Current\\\\Bin\\\\MSBuild.exe src\\\\ContextMaster.UI\\\\ContextMaster.UI.csproj /p:Configuration=Debug /p:Platform=x64\")", - "Bash(cmd /c \"start ContextMaster.UI.exe\")", - "Bash(tasklist)", - "Bash(findstr \"ContextMaster\")", - "Bash(cmd /c \"ContextMaster.UI.exe\")", - "Bash(powershell -Command \"Get-Process | Where-Object { $_ProcessName -like ''*ContextMaster*'' }\")", - "Bash(start \"\" \"ContextMaster.UI.exe\")", - "Bash(npx tsc:*)", - "Bash(npx vite:*)", - "Bash(npx electron-forge:*)", - "Bash(xargs grep:*)", - "Bash(sed:*)", - "mcp__Claude_Preview__preview_start", - "Skill(commit-commands:commit-push-pr)", - "Skill(commit-commands:commit-push-pr:*)", - "Skill(commit-commands:commit)" - ] - } -} From 28e59c147e6ed119b66b6d3466d8991b097a8345 Mon Sep 17 00:00:00 2001 From: tanzz Date: Fri, 13 Mar 2026 22:17:17 +0800 Subject: [PATCH 04/31] =?UTF-8?q?fix(registry):=20=E4=BF=AE=E5=A4=8D=20She?= =?UTF-8?q?ll=20=E6=89=A9=E5=B1=95=E5=90=8D=E7=A7=B0=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=8C=E6=96=B0=E5=A2=9E=20MUIVerb=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **根本原因** buildGetShellExtItemsScript 的 Level 3 DLL 字符串扫描(范围 1-500) 会命中与菜单项无关的资源字符串,导致 DesktopSlideshow 等条目显示 "在我的电池需要替换时发出警告"等错误名称。 **变更内容** - PowerShellBridge: 移除 Level 3 DLL 字符串暴力扫描(ReadDllStrings), CmHelper C# 源码同步精简(移除 LoadLibraryEx / FreeLibrary / LoadString P/Invoke) - PowerShellBridge: Level 2 扩展为遍历 FileDescription / ProductName / InternalName / OriginalFilename 四个 VersionInfo 字段 - PowerShellBridge: 原 Level 4(CLSID Default 值)晋升为 Level 3 兜底 - PowerShellBridge: buildGetItemsScript 新增 CmShell Add-Type + Resolve-MenuName, 经典菜单项名称读取优先级改为 MUIVerb → (Default) → 子键名, 支持 @dll,-id 格式的间接字符串解析 - RegistryService: 新增缓存支持(RegistryCache),getMenuItems 优先读缓存, enable/disable 操作后自动失效对应场景缓存 - RegistryService: 名称兜底层 —— 若 PS 返回以 @ 开头的未解析字符串, 自动替换为 subKeyName **单元测试** - PowerShellBridge.test.ts: 新增 12 个用例,覆盖 MUIVerb、CmShell、 Level 3 移除、InternalName/OriginalFilename、CLSID Default 升级等 - RegistryService.test.ts: 新增 5 个用例,覆盖 @ 前缀名称净化场景 (含 DesktopSlideshow 问题复现验证) - MenuManagerService.test.ts: 修复预存 mock 缺失(invalidateCache、log.debug) Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/MenuManagerService.ts | 65 +++++++++ src/main/services/PowerShellBridge.ts | 130 +++++++++-------- src/main/services/RegistryService.ts | 57 +++++++- src/main/utils/RegistryCache.ts | 131 ++++++++++++++++++ src/renderer/pages/mainPage.ts | 30 ++-- .../main/services/MenuManagerService.test.ts | 2 + .../main/services/PowerShellBridge.test.ts | 117 ++++++++++++++++ .../main/services/RegistryService.test.ts | 99 +++++++++++++ 8 files changed, 562 insertions(+), 69 deletions(-) create mode 100644 src/main/utils/RegistryCache.ts diff --git a/src/main/services/MenuManagerService.ts b/src/main/services/MenuManagerService.ts index 43d664b..0771c6a 100644 --- a/src/main/services/MenuManagerService.ts +++ b/src/main/services/MenuManagerService.ts @@ -51,11 +51,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 +110,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 +138,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 +159,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 +175,18 @@ export class MenuManagerService { throw new Error(`批量禁用失败,已回滚: ${(e as Error).message}`); } } + + /** + * 获取缓存统计信息 + */ + getCacheStats(): ReturnType { + return this.registry.getCacheStats(); + } + + /** + * 打印缓存统计日志 + */ + logCacheStats(): void { + this.registry.logCacheStats(); + } } diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index c017406..81c2d95 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -121,13 +121,36 @@ ${script} return ` $ErrorActionPreference = 'SilentlyContinue' New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null +try { + Add-Type -TypeDefinition @' +using System; using System.Runtime.InteropServices; using System.Text; +public class CmShell { + [DllImport("shlwapi.dll", CharSet=CharSet.Unicode)] + static extern int SHLoadIndirectString(string s, StringBuilder b, int c, IntPtr r); + public static string Resolve(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; + } +} +'@ -ErrorAction Stop +} catch {} +function Resolve-MenuName($raw) { + if (-not $raw) { return $null } + if ($raw -match '^@') { + try { $r = [CmShell]::Resolve($raw); if ($r) { return $r } } catch {} + return $null + } + return $raw +} $basePath = 'HKCR:\\${hkcrSubPath}' if (-not (Test-Path -LiteralPath $basePath)) { Write-Output '[]'; exit } $subKeys = Get-ChildItem -LiteralPath $basePath | Where-Object { $_.PSIsContainer } $result = @($subKeys | ForEach-Object { $key = $_ $keyName = $key.PSChildName - $name = $key.GetValue('') + $name = Resolve-MenuName ($key.GetValue('MUIVerb')) + if (-not $name) { $name = Resolve-MenuName ($key.GetValue('')) } if (-not $name) { $name = $keyName } $iconPath = $key.GetValue('Icon') $isEnabled = ($key.GetValue('LegacyDisable') -eq $null) @@ -190,7 +213,7 @@ Write-Output '{"ok":true}' * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 * 使用四级级联策略解析本地化名称: * 1. LocalizedString/FriendlyTypeName → SHLoadIndirectString(解析 @DLL,-ID 格式) - * 2. InprocServer32 DLL 字符串表 → 通用字符串质量筛选(LoadLibraryEx + LoadString) + * 2. DLL VersionInfo(FileDescription/ProductName/InternalName/OriginalFilename) * 3. CLSID 默认值 * 4. 处理程序键名(最终兜底) * CmHelper.dll 编译后缓存至 %LOCALAPPDATA%\ContextMaster\,避免重复编译开销 @@ -210,34 +233,14 @@ if (-not $helperLoaded) { 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(); - } } '@ if (-not (Test-Path $cmDir)) { New-Item -Path $cmDir -ItemType Directory -Force | Out-Null } @@ -249,11 +252,34 @@ public class CmHelper { try { Add-Type -TypeDefinition $src -ErrorAction Stop; $helperLoaded = $true } catch {} } } +# 常见 Shell 扩展友好名称映射表 +$friendlyNames = @{ + '{90AA3A4E-1CBA-4233-B8BB-535773D48449}' = 'Windows Defender' + '{09A47860-11B0-4DA5-AFA5-26D86198A780}' = 'Windows Defender' + '{D969A300-E7FF-11d0-A93B-00A0C90F2719}' = '发送到' + '{C2FBB630-2971-11D1-A18C-00C04FD75D13}' = '复制到文件夹' + '{C2FBB631-2971-11D1-A18C-00C04FD75D13}' = '移动到文件夹' + '{B4FB3F98-C1EA-428d-A78A-D1F5659CBA93}' = 'Windows Media Player' + '{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}' = 'Windows 传真和扫描' + '{E57CBC10-2D49-4B66-B1AA-74F08D5B8A01}' = 'Windows PowerShell' + '{5399E694-6CE5-4D6C-8FCE-1D8870FDCBA0}' = 'Windows 搜索' + '{F978C3D4-6F3D-4360-99F1-5F3C7A2C8C0D}' = 'OneDrive' + '{A0396A93-DC06-4AEF-BEAF-9A8F65E1D6C0}' = 'OneDrive' + '{8AB3A2F0-EF1C-4E99-8E6A-0D6E0B88C5A5}' = 'OneDrive' + '{3C8A3F87-34FB-4A3B-8B5A-6F5E3C8D9A2B}' = 'Visual Studio' + '{9F6C8B1E-3D4A-4C9F-B5E2-7A8D9C0F1E3B}' = 'Git' + '{A8B9C0D1-E2F3-4A5B-6C7D-8E9F0A1B2C3D}' = '7-Zip' +} function Resolve-ExtName($clsid, $fallback) { + # Level 0: 友好名称映射表 + if ($friendlyNames.ContainsKey($clsid)) { + return $friendlyNames[$clsid] + } if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { $clsidPath = 'HKCR:\\CLSID\\' + $clsid if (Test-Path -LiteralPath $clsidPath) { $clsidKey = Get-Item -LiteralPath $clsidPath + # Level 1: LocalizedString/FriendlyTypeName foreach ($valName in @('LocalizedString', 'FriendlyTypeName')) { $raw = $clsidKey.GetValue($valName) if ($raw) { @@ -264,58 +290,51 @@ function Resolve-ExtName($clsid, $fallback) { } catch {} } elseif ($raw.Length -ge 2) { return $raw - } + } } } $inprocPath = Join-Path $clsidPath 'InprocServer32' if (Test-Path -LiteralPath $inprocPath) { $dllPath = (Get-Item -LiteralPath $inprocPath).GetValue('') - # Fix 1: 展开 %SystemRoot% 等环境变量,否则 Test-Path 永远返回 $false + # 展开 %SystemRoot% 等环境变量 if ($dllPath) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllPath) } 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(适用于英文/日文等非中文软件) + # Level 2: DLL VersionInfo(FileDescription / ProductName / InternalName / OriginalFilename) 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 + foreach ($field in @($ver.FileDescription, $ver.ProductName, $ver.InternalName, $ver.OriginalFilename)) { + if ($field -and $field.Length -ge 2 -and $field.Length -le 80 -and + $field -notmatch '^\\{' -and $field -notmatch '[\\\\/:*?<>|]') { + $desc = $field + break + } } + if ($desc) { return $desc } } catch {} } } + # Level 3: CLSID 默认值 $def = $clsidKey.GetValue('') - if ($def) { return [string]$def } + if ($def -and $def.Length -ge 2) { return [string]$def } } } return $fallback } +function Format-DisplayName($name) { + if (-not $name) { return $name } + # 清理多余空格 + $name = $name -replace '\s+', ' ' + # 去除首尾空格 + $name = $name.Trim() + # 规范化大小写(首字母大写) + if ($name.Length -gt 1) { + $name = $name.Substring(0,1).ToUpper() + $name.Substring(1).ToLower() + } + return $name +} $shellexPath = 'HKCR:\\${shellexSubPath}' if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } $handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } @@ -325,8 +344,9 @@ $result = @($handlers | ForEach-Object { if (-not $clsid) { $clsid = $handlerKeyName } $cleanName = $handlerKeyName -replace '^-+', '' $displayName = Resolve-ExtName $clsid $cleanName + $displayName = Format-DisplayName $displayName $isEnabled = -not $handlerKeyName.StartsWith('-') - $regKey = '${shellexSubPath}\\' + $cleanName + $regKey = '${shellexSubPath}\\' + $cleanName [PSCustomObject]@{ name = [string]$displayName command = [string]$clsid @@ -366,7 +386,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..27cddb6 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -1,6 +1,7 @@ import { MenuScene, MenuItemType } from '../../shared/enums'; import { MenuItemEntry } from '../../shared/types'; import { PowerShellBridge } from './PowerShellBridge'; +import { RegistryCache } from '../utils/RegistryCache'; import log from '../utils/logger'; // 与 C# RegistryService._sceneRegistryPaths 完全一致 @@ -39,21 +40,32 @@ interface PsMenuItemRaw { export class RegistryService { private readonly ps: PowerShellBridge; + private readonly cache: RegistryCache; /** 事务回滚数据:registryKey → 原始 isEnabled */ private rollbackData = new Map(); private inTransaction = false; private nextId = 1; - constructor(ps: PowerShellBridge) { + constructor(ps: PowerShellBridge, cache?: RegistryCache) { this.ps = ps; + this.cache = cache ?? new RegistryCache(); } /** * 获取指定场景下的所有菜单条目(Classic Shell + Shell 扩展) + * 优先从缓存读取,缓存未命中时执行 PowerShell 查询 */ async getMenuItems(scene: MenuScene): 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 命令 const script = this.ps.buildGetItemsScript(basePath); @@ -70,9 +82,9 @@ export class RegistryService { log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); } - return [...items, ...shellexItems].map((r) => ({ + const result = [...items, ...shellexItems].map((r) => ({ id: this.nextId++, - name: r.name, + name: (r.name && !r.name.startsWith('@')) ? r.name : (r.subKeyName || r.name), command: r.command, iconPath: r.iconPath, isEnabled: r.isEnabled, @@ -81,6 +93,11 @@ export class RegistryService { 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 +107,7 @@ export class RegistryService { /** * 启用或禁用单个菜单条目 * ShellExt 通过重命名键(±前缀)实现;Classic Shell 通过 LegacyDisable 值实现 - * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 + * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 */ async setItemEnabled(registryKey: string, enabled: boolean): Promise<{ newRegistryKey?: string }> { try { @@ -100,7 +117,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) { @@ -155,6 +172,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,7 +215,7 @@ export class RegistryService { return registryKey.includes('shellex') && registryKey.includes('ContextMenuHandlers'); } -private inferSource(subKeyName: string): string { + private inferSource(subKeyName: string): string { return subKeyName || ''; } 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/renderer/pages/mainPage.ts b/src/renderer/pages/mainPage.ts index 5f03b48..506a50e 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -425,17 +425,31 @@ export function restoreSceneTitle(scene: MenuScene): void { // ── 预加载其余场景的 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); + + // 并行加载所有场景的 badge 数量 + const results = await Promise.all( + targetScenes.map(async (scene) => { + try { + const result = await window.api.getMenuItems(scene); + return { scene, result }; + } catch (e) { + return { scene, result: { success: false, error: String(e) } }; } }) ); + + // 更新所有 badge + for (const { scene, result } of results) { + const badgeEl = document.getElementById(`badge-${scene}`); + if (!badgeEl) continue; + + if (result.success && 'data' in result) { + badgeEl.textContent = String(result.data.length); + } else { + badgeEl.textContent = '?'; + } + } } // 挂载到 window 供 HTML inline onclick 调用 diff --git a/tests/unit/main/services/MenuManagerService.test.ts b/tests/unit/main/services/MenuManagerService.test.ts index 3b33193..51adff7 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 = { diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 18ce5e6..8e5f520 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -41,6 +41,123 @@ describe('PowerShellBridge', () => { expect(script).toContain('Get-ChildItem'); expect(script).toContain('ConvertTo-Json'); }); + + it('应读取 MUIVerb 作为首选名称', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).toContain("GetValue('MUIVerb')"); + }); + + it('应包含 Resolve-MenuName 函数用于解析间接字符串', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).toContain('Resolve-MenuName'); + expect(script).toContain("match '^@'"); + }); + + it('应包含 CmShell Add-Type 以调用 SHLoadIndirectString', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).toContain('CmShell'); + expect(script).toContain('SHLoadIndirectString'); + }); + + it('名称回退顺序:MUIVerb → Default → 键名', () => { + const script = bridge.buildGetItemsScript('*\\shell'); + + // MUIVerb 先被尝试 + const muiVerbIdx = script.indexOf("GetValue('MUIVerb')"); + // Default 次之 + const defaultIdx = script.indexOf("GetValue('')"); + // 键名最后 + const fallbackIdx = script.indexOf('$name = $keyName'); + + expect(muiVerbIdx).toBeGreaterThan(0); + expect(defaultIdx).toBeGreaterThan(muiVerbIdx); + expect(fallbackIdx).toBeGreaterThan(defaultIdx); + }); + + it('应将正确的注册表路径嵌入脚本', () => { + const script = bridge.buildGetItemsScript('*\\shell'); + // 模板字面量中 \\ 在 PS 脚本里生成单个 \,所以检查单反斜杠路径 + expect(script).toContain('HKCR:\\*\\shell'); + }); + }); + + describe('buildGetShellExtItemsScript', () => { + it('不应包含 ReadDllStrings(已移除 DLL 字符串扫描)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('ReadDllStrings'); + expect(script).not.toContain('LoadLibraryEx'); + expect(script).not.toContain('LOAD_AS_DATA'); + }); + + it('不应包含 1-500 范围的字符串表扫描', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('1, 500'); + expect(script).not.toContain('ReadDllStrings'); + }); + + it('Level 2 应包含 InternalName 和 OriginalFilename', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('InternalName'); + expect(script).toContain('OriginalFilename'); + }); + + it('Level 2 应以 foreach 遍历多个 VersionInfo 字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('foreach'); + expect(script).toContain('FileDescription'); + expect(script).toContain('ProductName'); + }); + + it('应将 CLSID Default 值作为 Level 3 兜底(而非 Level 4)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('Level 3: CLSID 默认值'); + expect(script).not.toContain('Level 4: CLSID'); + }); + + it('CmHelper 源码中不应包含 ReadDllStrings 方法', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // 源码里不再有 ReadDllStrings 定义 + expect(script).not.toMatch(/public static string\[\] ReadDllStrings/); + }); + + it('应包含 Level 1 LocalizedString/FriendlyTypeName 解析', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('LocalizedString'); + expect(script).toContain('FriendlyTypeName'); + }); + + it('应包含友好名称映射表', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('friendlyNames'); + expect(script).toContain('Windows Defender'); + }); }); describe('buildSetEnabledScript', () => { diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 5e893fd..c06db24 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -61,6 +61,105 @@ describe('RegistryService', () => { }); }); + describe('名称净化(@ 间接字符串兜底)', () => { + it('以 @ 开头的名称应被 subKeyName 替换', async () => { + const rawItems = [{ + name: '@%SystemRoot%\\system32\\shell32.dll,-1234', + command: '', + iconPath: null, + isEnabled: true, + source: '', + registryKey: 'DesktopBackground\\Shell\\TestItem', + subKeyName: 'TestItem', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe('TestItem'); + }); + + it('正常名称不应被替换', async () => { + const rawItems = [{ + name: '在桌面上显示', + command: '', + iconPath: null, + isEnabled: true, + source: '', + registryKey: 'DesktopBackground\\Shell\\TestItem', + subKeyName: 'TestItem', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe('在桌面上显示'); + }); + + it('subKeyName 为空时 @ 名称应保留原值', async () => { + const rawItems = [{ + name: '@unresolved', + command: '', + iconPath: null, + isEnabled: true, + source: '', + registryKey: 'DesktopBackground\\Shell\\', + subKeyName: '', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe('@unresolved'); + }); + + it('ShellExt 条目的 @ 名称也应净化为 subKeyName', async () => { + const shellextItems = [{ + name: '@%SystemRoot%\\system32\\shell32.dll,-9999', + command: '{645FF040-5081-101B-9F08-00AA002F954E}', + iconPath: null, + isEnabled: true, + source: 'DesktopSlideshow', + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\DesktopSlideshow', + subKeyName: 'DesktopSlideshow', + itemType: 'ShellExt', + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('DesktopSlideshow'); + }); + + it('模拟 DesktopSlideshow 问题场景:错误名称应被键名替换', async () => { + // 模拟 Level 3 DLL 扫描可能返回的错误名称(现已移除该逻辑) + // RegistryService 的 @ 兜底层作为最终保险 + const shellextItems = [{ + name: '@windows.immersivecontrolpanel.dll,-1', + command: '{2CC2D03E-B04A-43BE-A6BE-8C20E6A64F87}', + iconPath: null, + isEnabled: true, + source: 'DesktopSlideshow', + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\DesktopSlideshow', + subKeyName: 'DesktopSlideshow', + itemType: 'ShellExt', + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].name).toBe('DesktopSlideshow'); + expect(result[0].name).not.toContain('@'); + expect(result[0].name).not.toContain('电池'); + }); + }); + describe('transaction management', () => { it('should create rollback point correctly', () => { const items = [ From bd38d7c5d2eb28cdd9bf31c7689d4cb472829ad4 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 00:26:59 +0800 Subject: [PATCH 05/31] =?UTF-8?q?refactor(ps):=20=E7=AE=80=E5=8C=96=20Shel?= =?UTF-8?q?l=20=E6=89=A9=E5=B1=95=E5=90=8D=E7=A7=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=20DLL=20VersionInfo=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildGetShellExtItemsScript 从四级降为三级策略 Level 1: LocalizedString(@ 格式或直接值,过滤泛型 COM 类型描述) Level 2: CLSID 默认值 Level 3: 处理程序键名(最终兜底) - Format-DisplayName 移除大小写规范化和热键清理(热键已迁至 TS 层) - buildGetItemsScript 新增 LocalizedDisplayName 第三优先级 Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/PowerShellBridge.ts | 70 ++++++------------- .../main/services/PowerShellBridge.test.ts | 57 ++++++++++++--- 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 81c2d95..96efe58 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -151,6 +151,7 @@ $result = @($subKeys | ForEach-Object { $keyName = $key.PSChildName $name = Resolve-MenuName ($key.GetValue('MUIVerb')) if (-not $name) { $name = Resolve-MenuName ($key.GetValue('')) } + if (-not $name) { $name = Resolve-MenuName ($key.GetValue('LocalizedDisplayName')) } if (-not $name) { $name = $keyName } $iconPath = $key.GetValue('Icon') $isEnabled = ($key.GetValue('LegacyDisable') -eq $null) @@ -211,11 +212,10 @@ Write-Output '{"ok":true}' /** * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 - * 使用四级级联策略解析本地化名称: - * 1. LocalizedString/FriendlyTypeName → SHLoadIndirectString(解析 @DLL,-ID 格式) - * 2. DLL VersionInfo(FileDescription/ProductName/InternalName/OriginalFilename) - * 3. CLSID 默认值 - * 4. 处理程序键名(最终兜底) + * 使用三级级联策略解析本地化名称: + * 1. LocalizedString → SHLoadIndirectString(@ 格式)或直接使用 + * 2. CLSID 默认值(可靠、ASCII-safe) + * 3. 处理程序键名(最终兜底) * CmHelper.dll 编译后缓存至 %LOCALAPPDATA%\ContextMaster\,避免重复编译开销 */ buildGetShellExtItemsScript(shellexSubPath: string): string { @@ -279,44 +279,26 @@ function Resolve-ExtName($clsid, $fallback) { $clsidPath = 'HKCR:\\CLSID\\' + $clsid if (Test-Path -LiteralPath $clsidPath) { $clsidKey = Get-Item -LiteralPath $clsidPath - # Level 1: LocalizedString/FriendlyTypeName - 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 - } - } - } - $inprocPath = Join-Path $clsidPath 'InprocServer32' - if (Test-Path -LiteralPath $inprocPath) { - $dllPath = (Get-Item -LiteralPath $inprocPath).GetValue('') - # 展开 %SystemRoot% 等环境变量 - if ($dllPath) { - $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllPath) - } - if ($dllPath -and (Test-Path -LiteralPath $dllPath)) { - # Level 2: DLL VersionInfo(FileDescription / ProductName / InternalName / OriginalFilename) + # Level 1: LocalizedString(专为 Shell 扩展显示名设计) + # 注意:FriendlyTypeName 是 COM 类型描述(如"外壳服务对象"),不是菜单名,已移除 + $raw = $clsidKey.GetValue('LocalizedString') + if ($raw) { + if ($raw.StartsWith('@')) { try { - $ver = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllPath) - $desc = $null - foreach ($field in @($ver.FileDescription, $ver.ProductName, $ver.InternalName, $ver.OriginalFilename)) { - if ($field -and $field.Length -ge 2 -and $field.Length -le 80 -and - $field -notmatch '^\\{' -and $field -notmatch '[\\\\/:*?<>|]') { - $desc = $field - break - } - } - if ($desc) { return $desc } + $resolved = [CmHelper]::ResolveIndirect($raw) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } } catch {} + } elseif ($raw.Length -ge 2) { + # 过滤泛型 COM 类型描述,这类值不适合作为菜单显示名 + $lc = $raw.ToLower() + if ($lc -notmatch '外壳服务对象' -and + $lc -notmatch 'shell service object' -and + $lc -notmatch 'shell extension') { + return $raw + } } } - # Level 3: CLSID 默认值 + # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) $def = $clsidKey.GetValue('') if ($def -and $def.Length -ge 2) { return [string]$def } } @@ -325,15 +307,7 @@ function Resolve-ExtName($clsid, $fallback) { } function Format-DisplayName($name) { if (-not $name) { return $name } - # 清理多余空格 - $name = $name -replace '\s+', ' ' - # 去除首尾空格 - $name = $name.Trim() - # 规范化大小写(首字母大写) - if ($name.Length -gt 1) { - $name = $name.Substring(0,1).ToUpper() + $name.Substring(1).ToLower() - } - return $name + return $name.Trim() } $shellexPath = 'HKCR:\\${shellexSubPath}' if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 8e5f520..d443166 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -82,6 +82,30 @@ describe('PowerShellBridge', () => { // 模板字面量中 \\ 在 PS 脚本里生成单个 \,所以检查单反斜杠路径 expect(script).toContain('HKCR:\\*\\shell'); }); + + it('Resolve-MenuName 不应包含热键清理 -replace(热键清理已移至 TS 层)', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + // 提取 Resolve-MenuName 函数体,确认不含 -replace 热键正则 + const fnMatch = script.match(/function Resolve-MenuName[\s\S]*?\n\}/); + expect(fnMatch).not.toBeNull(); + const fnBody = fnMatch![0]; + expect(fnBody).not.toContain('-replace'); + }); + + it('名称检测链应包含 LocalizedDisplayName 作为第三优先级', () => { + const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + + expect(script).toContain("GetValue('LocalizedDisplayName')"); + // 顺序:MUIVerb → Default → LocalizedDisplayName → 键名 + const muiIdx = script.indexOf("GetValue('MUIVerb')"); + const defIdx = script.indexOf("GetValue('')"); + const localIdx = script.indexOf("GetValue('LocalizedDisplayName')"); + const fallbackIdx = script.indexOf('$name = $keyName'); + + expect(localIdx).toBeGreaterThan(defIdx); + expect(fallbackIdx).toBeGreaterThan(localIdx); + }); }); describe('buildGetShellExtItemsScript', () => { @@ -104,32 +128,45 @@ describe('PowerShellBridge', () => { expect(script).not.toContain('ReadDllStrings'); }); - it('Level 2 应包含 InternalName 和 OriginalFilename', () => { + it('不应包含 DLL VersionInfo 字段(已移除 FileVersionInfo 级别)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).not.toContain('InternalName'); + expect(script).not.toContain('OriginalFilename'); + expect(script).not.toContain('FileDescription'); + expect(script).not.toContain('FileVersionInfo'); + }); + + it('不应以 foreach 遍历 VersionInfo 字段(DLL VersionInfo 已移除)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('InternalName'); - expect(script).toContain('OriginalFilename'); + expect(script).not.toContain('FileVersionInfo'); + expect(script).not.toContain('ProductName'); }); - it('Level 2 应以 foreach 遍历多个 VersionInfo 字段', () => { + it('应将 CLSID Default 值作为 Level 2 兜底', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('foreach'); - expect(script).toContain('FileDescription'); - expect(script).toContain('ProductName'); + expect(script).toContain('Level 2: CLSID 默认值'); + expect(script).not.toContain('Level 3: CLSID'); }); - it('应将 CLSID Default 值作为 Level 3 兜底(而非 Level 4)', () => { + it('Format-DisplayName 不应包含热键清理正则(热键清理已移至 TS 层)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('Level 3: CLSID 默认值'); - expect(script).not.toContain('Level 4: CLSID'); + // Format-DisplayName 函数体不应包含 -replace 热键正则 + const fnMatch = script.match(/function Format-DisplayName[\s\S]*?\n\}/); + expect(fnMatch).not.toBeNull(); + const fnBody = fnMatch![0]; + expect(fnBody).not.toContain('-replace'); }); it('CmHelper 源码中不应包含 ReadDllStrings 方法', () => { From 45f4396dcc9e90638db8cc13659cf5f9dfbbe2f5 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 00:28:39 +0800 Subject: [PATCH 06/31] =?UTF-8?q?fix(main-page):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E5=88=87=E6=8D=A2=E7=AB=9E=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=96=B0=E5=A2=9E=20pendingScene=20=E9=98=9F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加载期间快速切换场景时,后续请求不再被丢弃,而是记录为 pendingScene,前一次加载完成后立即执行最新的待切换场景。 Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/pages/mainPage.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/renderer/pages/mainPage.ts b/src/renderer/pages/mainPage.ts index 506a50e..2461639 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -29,6 +29,7 @@ let selectedItemId: number | null = null; let filterMode: 'all' | 'enabled' | 'disabled' = 'all'; let loadingScene = false; let currentScene: MenuScene = MenuScene.Desktop; +let pendingScene: MenuScene | null = null; export function refreshCurrentContent(): void { renderItems(); @@ -44,9 +45,13 @@ export function refreshCurrentContent(): void { registerRefreshCallback(refreshCurrentContent); export async function loadScene(scene: MenuScene): Promise { - if (loadingScene) return; + if (loadingScene) { + pendingScene = scene; // 记录最新请求,加载完后执行 + return; + } loadingScene = true; currentScene = scene; + pendingScene = null; const listEl = document.getElementById('itemList'); if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; @@ -59,13 +64,19 @@ export async function loadScene(scene: MenuScene): Promise { if (!result.success) { showError(`${t('main.loadFailed')}: ${result.error}`); - return; + } else { + currentItems = result.data; + updateSceneHeader(scene); + renderItems(); + updateStatusBar(scene); } - currentItems = result.data; - updateSceneHeader(scene); - renderItems(); - updateStatusBar(scene); + // 若加载期间有新的场景请求,执行最新的那个 + if (pendingScene !== null) { + const next = pendingScene; + pendingScene = null; + await loadScene(next); + } } // ── 渲染条目列表 ── From 89c5aaeebefb851e9a1c856f4c108637130dbbd0 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 00:30:44 +0800 Subject: [PATCH 07/31] =?UTF-8?q?fix(registry):=20=E4=BF=AE=E5=A4=8D=20cle?= =?UTF-8?q?anDisplayName=20=E6=AD=A3=E5=88=99=E9=A1=BA=E5=BA=8F=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8B=AC=E5=8F=B7=E5=8A=A0=E9=80=9F=E9=94=AE?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=8B=AC=E5=8F=B7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原问题:/&\w/g 先执行,(&V) 中的 &V 被移除,剩余空括号 (), 后续 /\(&\w\)/g 无法匹配,导致"使用 Visual Studio 打开()"。 修复: 1. 调整顺序:/\(&\w\)/g 先于 /&\w/g 执行,整体匹配移除 (&V) 2. 新增 /\(\s*\)/g 空括号兜底,防止顺序问题遗留 3. 补充测试:覆盖 VS Code 打开、个性化、QQ 音乐三个典型场景 Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/RegistryService.ts | 42 ++++++++++++------- .../main/services/RegistryService.test.ts | 34 ++++++++++++++- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 27cddb6..52547b4 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -67,31 +67,31 @@ export class RegistryService { 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 shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); + const [raw, shellexRaw] = await Promise.all([ + this.ps.execute(script), + this.ps.execute(shellexScript).catch((e) => { + log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); + return [] as PsMenuItemRaw[]; + }), + ]); 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 shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; const result = [...items, ...shellexItems].map((r) => ({ id: this.nextId++, - name: (r.name && !r.name.startsWith('@')) ? r.name : (r.subKeyName || r.name), + name: this.cleanDisplayName( + (r.name && !r.name.startsWith('@')) ? r.name : (r.subKeyName || 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), + type: this.determineType(r.itemType, r.command), })); // 写入缓存 @@ -219,8 +219,20 @@ export class RegistryService { return subKeyName || ''; } - private determineType(itemType?: string): MenuItemType { + 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, command?: string): MenuItemType { if (itemType === 'ShellExt') return MenuItemType.ShellExt; + if (command && command.trim()) return MenuItemType.Custom; return MenuItemType.System; } } diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index c06db24..afdd47a 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -56,11 +56,43 @@ describe('RegistryService', () => { isEnabled: true, source: 'TestApp', menuScene: MenuScene.File, - type: MenuItemType.System, + type: MenuItemType.Custom, }); }); }); + describe('名称净化(热键清理)', () => { + 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 rawItems = [{ + name: input, + command: '', + iconPath: null, + isEnabled: true, + source: '', + registryKey: 'DesktopBackground\\Shell\\TestItem', + subKeyName: 'TestItem', + }]; + + // 每次调用需要新的 service 实例(避免缓存) + const freshService = new RegistryService(mockPs); + mockPs.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('名称净化(@ 间接字符串兜底)', () => { it('以 @ 开头的名称应被 subKeyName 替换', async () => { const rawItems = [{ From 014023654f448fb38f789cae52e302feeee1dc70 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 02:47:45 +0800 Subject: [PATCH 08/31] =?UTF-8?q?feat(shellex):=20=E6=96=B0=E5=A2=9E=20MUI?= =?UTF-8?q?Verb=20=E8=A7=A3=E6=9E=90=E5=B1=82=E4=B8=8E=20InprocServer32=20?= =?UTF-8?q?DLL=20=E8=B7=AF=E5=BE=84=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PowerShellBridge: Resolve-ExtName 在 LocalizedString 与 CLSID Default 之间插入 Level 1.5 MUIVerb,修复 gvim 等扩展名称退化问题;同时读取 InprocServer32 DLL 路径并展开环境变量后输出 dllPath 字段 - RegistryService: PsMenuItemRaw 新增 dllPath 字段,getMenuItems 映射时透传 - shared/types: MenuItemEntry 新增 dllPath?: string | null - mainPage: ShellExt 详情面板拆为"COM 标识符"+ 可选"提供程序 DLL"两行 - 补充 PowerShellBridge/RegistryService 单元测试(共 9 个新用例) Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/PowerShellBridge.ts | 19 ++++++ src/main/services/RegistryService.ts | 2 + src/renderer/pages/mainPage.ts | 18 ++++-- src/shared/types.ts | 1 + .../main/services/PowerShellBridge.test.ts | 26 ++++++++ .../main/services/RegistryService.test.ts | 61 +++++++++++++++++++ 6 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 96efe58..5c36916 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -298,6 +298,16 @@ function Resolve-ExtName($clsid, $fallback) { } } } + # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) + $muiVerb = $clsidKey.GetValue('MUIVerb') + if ($muiVerb) { + if ($muiVerb.StartsWith('@')) { + try { + $resolved = [CmHelper]::ResolveIndirect($muiVerb) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } + } catch {} + } elseif ($muiVerb.Length -ge 2) { return $muiVerb } + } # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) $def = $clsidKey.GetValue('') if ($def -and $def.Length -ge 2) { return [string]$def } @@ -321,6 +331,14 @@ $result = @($handlers | ForEach-Object { $displayName = Format-DisplayName $displayName $isEnabled = -not $handlerKeyName.StartsWith('-') $regKey = '${shellexSubPath}\\' + $cleanName + $dllPath = $null + if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { + $inprocPath = 'HKCR:\\CLSID\\' + $clsid + '\\InprocServer32' + if (Test-Path -LiteralPath $inprocPath) { + $raw = (Get-Item -LiteralPath $inprocPath).GetValue('') + if ($raw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($raw) } + } + } [PSCustomObject]@{ name = [string]$displayName command = [string]$clsid @@ -330,6 +348,7 @@ $result = @($handlers | ForEach-Object { registryKey = [string]$regKey subKeyName = [string]$handlerKeyName itemType = 'ShellExt' + dllPath = $dllPath } }) $result | ConvertTo-Json -Compress -Depth 3 diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 52547b4..8d7b367 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -36,6 +36,7 @@ interface PsMenuItemRaw { registryKey: string; subKeyName: string; itemType?: string; // 'ShellExt' for shell extensions + dllPath?: string | null; } export class RegistryService { @@ -92,6 +93,7 @@ export class RegistryService { menuScene: scene, registryKey: r.registryKey, type: this.determineType(r.itemType, r.command), + dllPath: r.dllPath ?? null, })); // 写入缓存 diff --git a/src/renderer/pages/mainPage.ts b/src/renderer/pages/mainPage.ts index 2461639..e785dba 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -212,9 +212,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 @@ -278,10 +277,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')}
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/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index d443166..2b7fccc 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -187,6 +187,32 @@ describe('PowerShellBridge', () => { expect(script).toContain('FriendlyTypeName'); }); + it('应包含 Level 1.5 MUIVerb 解析,位于 LocalizedString 与 CLSID Default 之间', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('MUIVerb'); + expect(script).toContain('Level 1.5: MUIVerb'); + + const localizedIdx = script.indexOf('LocalizedString'); + const muiVerbIdx = script.indexOf('Level 1.5: MUIVerb'); + const level2Idx = script.indexOf('Level 2: CLSID 默认值'); + + expect(muiVerbIdx).toBeGreaterThan(localizedIdx); + expect(level2Idx).toBeGreaterThan(muiVerbIdx); + }); + + it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('InprocServer32'); + expect(script).toContain('ExpandEnvironmentVariables'); + expect(script).toContain('dllPath'); + }); + it('应包含友好名称映射表', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index afdd47a..0a40e56 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -192,6 +192,67 @@ describe('RegistryService', () => { }); }); + describe('dllPath 字段透传', () => { + it('ShellExt 条目应将 dllPath 传入 MenuItemEntry', async () => { + const shellextItems = [{ + name: 'gvim Shell Extension', + command: '{51EEE242-AD87-11d3-9C1E-0090278BBD99}', + iconPath: null, + isEnabled: true, + source: 'gvim', + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\gvim', + subKeyName: 'gvim', + itemType: 'ShellExt', + dllPath: 'C:\\Program Files\\Vim\\vim91\\gvimext.dll', + }]; + + 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('无 DLL 路径的 ShellExt 条目 dllPath 应为 null', async () => { + const shellextItems = [{ + name: 'SomeExt', + command: '{12345678-1234-1234-1234-123456789ABC}', + iconPath: null, + isEnabled: true, + source: 'SomeExt', + registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\SomeExt', + subKeyName: 'SomeExt', + itemType: 'ShellExt', + dllPath: null, + }]; + + mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].dllPath).toBeNull(); + }); + + it('Classic Shell 条目 dllPath 应为 null', async () => { + const rawItems = [{ + name: 'Classic Item', + command: 'cmd.exe', + iconPath: null, + isEnabled: true, + source: '', + registryKey: 'DesktopBackground\\Shell\\Classic', + subKeyName: 'Classic', + }]; + + mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + + const result = await service.getMenuItems(MenuScene.Desktop); + + expect(result[0].dllPath).toBeNull(); + }); + }); + describe('transaction management', () => { it('should create rollback point correctly', () => { const items = [ From ff7eda9c8709c10af3c14b7408ec6040008e1f85 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 04:39:00 +0800 Subject: [PATCH 09/31] =?UTF-8?q?fix(shellex):=20=E4=BF=AE=E5=A4=8D=20CLSI?= =?UTF-8?q?D=20=E9=94=AE=E5=90=8D=E6=9D=A1=E7=9B=AE=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E9=80=80=E5=8C=96=E9=97=AE=E9=A2=98=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=E6=98=A0=E5=B0=84=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20ms-resource:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **核心 bug 修复** 当 handler 键名为 CLSID 格式而 Default 值为非 CLSID 字符串时(如 {90AA3A4E...} 的 Default = "Taskband Pin"),旧代码将 Default 值误用 作 $clsid 传入 Resolve-ExtName,导致 CLSID 正则匹配失败,名称退化为键名。 修复:引入 $actualClsid(键名优先为 CLSID)和 $directName(非 CLSID Default 值)分离变量,$actualClsid 用于注册表查找和 command 字段输出, $directName 作为 Resolve-ExtName Level 0 直接名称。 **移除 friendlyNames 硬编码映射表** 原映射表包含中文硬编码字符串('发送到'、'复制到文件夹'等),在 Level 0 优先级最高,屏蔽了 SHLoadIndirectString 的本地化机制,导致非中文系统 显示错误名称。移除后,Level 1 LocalizedString → SHLoadIndirectString 自动按系统语言返回正确本地化名称。 **新增 ms-resource: URI 支持** - CmHelper.ResolveIndirect 扩展入口过滤,允许 ms-resource: 前缀通过 - LocalizedString、MUIVerb、directName 的 StartsWith 检查同步扩展 - Windows 11 MSIX 封装扩展的裸 ms-resource:// URI 不再作为原始字符串 返回,而是先尝试 SHLoadIndirectString 解析,失败则静默降级 Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/PowerShellBridge.ts | 67 ++++++++++--------- .../main/services/PowerShellBridge.test.ts | 48 ++++++++++++- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 5c36916..3080552 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -237,7 +237,8 @@ public class CmHelper { [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); public static string ResolveIndirect(string s) { - if (string.IsNullOrEmpty(s) || !s.StartsWith("@")) return null; + if (string.IsNullOrEmpty(s) || + (!s.StartsWith("@") && !s.StartsWith("ms-resource:"))) return null; var sb = new StringBuilder(512); return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; } @@ -252,28 +253,21 @@ public class CmHelper { try { Add-Type -TypeDefinition $src -ErrorAction Stop; $helperLoaded = $true } catch {} } } -# 常见 Shell 扩展友好名称映射表 -$friendlyNames = @{ - '{90AA3A4E-1CBA-4233-B8BB-535773D48449}' = 'Windows Defender' - '{09A47860-11B0-4DA5-AFA5-26D86198A780}' = 'Windows Defender' - '{D969A300-E7FF-11d0-A93B-00A0C90F2719}' = '发送到' - '{C2FBB630-2971-11D1-A18C-00C04FD75D13}' = '复制到文件夹' - '{C2FBB631-2971-11D1-A18C-00C04FD75D13}' = '移动到文件夹' - '{B4FB3F98-C1EA-428d-A78A-D1F5659CBA93}' = 'Windows Media Player' - '{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}' = 'Windows 传真和扫描' - '{E57CBC10-2D49-4B66-B1AA-74F08D5B8A01}' = 'Windows PowerShell' - '{5399E694-6CE5-4D6C-8FCE-1D8870FDCBA0}' = 'Windows 搜索' - '{F978C3D4-6F3D-4360-99F1-5F3C7A2C8C0D}' = 'OneDrive' - '{A0396A93-DC06-4AEF-BEAF-9A8F65E1D6C0}' = 'OneDrive' - '{8AB3A2F0-EF1C-4E99-8E6A-0D6E0B88C5A5}' = 'OneDrive' - '{3C8A3F87-34FB-4A3B-8B5A-6F5E3C8D9A2B}' = 'Visual Studio' - '{9F6C8B1E-3D4A-4C9F-B5E2-7A8D9C0F1E3B}' = 'Git' - '{A8B9C0D1-E2F3-4A5B-6C7D-8E9F0A1B2C3D}' = '7-Zip' -} -function Resolve-ExtName($clsid, $fallback) { - # Level 0: 友好名称映射表 - if ($friendlyNames.ContainsKey($clsid)) { - return $friendlyNames[$clsid] +function Resolve-ExtName($clsid, $fallback, $directName = $null) { + # Level 0: handler key 的非 CLSID Default 值(最贴近当前系统实际) + # 支持 @dll,-id 和 ms-resource: 两种间接格式,以及普通字符串 + if ($directName) { + if ($directName.StartsWith('@') -or $directName.StartsWith('ms-resource:')) { + try { + $resolved = [CmHelper]::ResolveIndirect($directName) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } + } catch {} + } else { + $lc = $directName.ToLower() + if ($lc -notmatch '外壳服务对象' -and + $lc -notmatch 'shell service object' -and + $lc -notmatch 'shell extension') { return $directName } + } } if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { $clsidPath = 'HKCR:\\CLSID\\' + $clsid @@ -283,7 +277,7 @@ function Resolve-ExtName($clsid, $fallback) { # 注意:FriendlyTypeName 是 COM 类型描述(如"外壳服务对象"),不是菜单名,已移除 $raw = $clsidKey.GetValue('LocalizedString') if ($raw) { - if ($raw.StartsWith('@')) { + if ($raw.StartsWith('@') -or $raw.StartsWith('ms-resource:')) { try { $resolved = [CmHelper]::ResolveIndirect($raw) if ($resolved -and $resolved.Length -ge 2) { return $resolved } @@ -301,7 +295,7 @@ function Resolve-ExtName($clsid, $fallback) { # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) $muiVerb = $clsidKey.GetValue('MUIVerb') if ($muiVerb) { - if ($muiVerb.StartsWith('@')) { + if ($muiVerb.StartsWith('@') -or $muiVerb.StartsWith('ms-resource:')) { try { $resolved = [CmHelper]::ResolveIndirect($muiVerb) if ($resolved -and $resolved.Length -ge 2) { return $resolved } @@ -324,16 +318,27 @@ 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 } + $defaultVal = $_.GetValue('') $cleanName = $handlerKeyName -replace '^-+', '' - $displayName = Resolve-ExtName $clsid $cleanName + # 实际 CLSID:键名若为 CLSID 格式则优先;否则检查默认值是否为 CLSID + $actualClsid = $cleanName + if ($cleanName -notmatch '^\{[0-9A-Fa-f-]+\}$' -and + $defaultVal -match '^\{[0-9A-Fa-f-]+\}$') { + $actualClsid = $defaultVal + } + # 直接名称:仅当键名是 CLSID 格式且默认值是非 CLSID 字符串时 + $directName = $null + if ($actualClsid -eq $cleanName -and $defaultVal -and + $defaultVal -notmatch '^\{[0-9A-Fa-f-]+\}$' -and $defaultVal.Length -ge 2) { + $directName = $defaultVal + } + $displayName = Resolve-ExtName $actualClsid $cleanName $directName $displayName = Format-DisplayName $displayName $isEnabled = -not $handlerKeyName.StartsWith('-') $regKey = '${shellexSubPath}\\' + $cleanName $dllPath = $null - if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { - $inprocPath = 'HKCR:\\CLSID\\' + $clsid + '\\InprocServer32' + if ($actualClsid -match '^\\{[0-9A-Fa-f-]+\\}$') { + $inprocPath = 'HKCR:\\CLSID\\' + $actualClsid + '\\InprocServer32' if (Test-Path -LiteralPath $inprocPath) { $raw = (Get-Item -LiteralPath $inprocPath).GetValue('') if ($raw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($raw) } @@ -341,7 +346,7 @@ $result = @($handlers | ForEach-Object { } [PSCustomObject]@{ name = [string]$displayName - command = [string]$clsid + command = [string]$actualClsid iconPath = $null isEnabled = [bool]$isEnabled source = [string]$handlerKeyName diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 2b7fccc..beb2907 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -213,13 +213,55 @@ describe('PowerShellBridge', () => { expect(script).toContain('dllPath'); }); - it('应包含友好名称映射表', () => { + it('不应包含硬编码 friendlyNames 映射表(已移除,让 SHLoadIndirectString 自动本地化)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('friendlyNames'); - expect(script).toContain('Windows Defender'); + expect(script).not.toContain('$friendlyNames'); + expect(script).not.toContain('friendlyNames.ContainsKey'); + }); + + it('Resolve-ExtName 应支持可选 $directName 参数作为 Level 0', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('$directName = $null'); + expect(script).toContain('Level 0: handler key'); + // Level 0 的位置应在 Level 1 LocalizedString 之前 + const level0Idx = script.indexOf('Level 0: handler key'); + const level1Idx = script.indexOf('Level 1: LocalizedString'); + expect(level0Idx).toBeGreaterThan(0); + expect(level1Idx).toBeGreaterThan(level0Idx); + }); + + it('CmHelper.ResolveIndirect 应支持 ms-resource: 前缀', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('ms-resource:'); + expect(script).toContain('!s.StartsWith("ms-resource:")'); + }); + + it('LocalizedString 和 MUIVerb 应同时检查 @ 和 ms-resource: 前缀', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toMatch(/StartsWith\('@'\)\s*-or\s*\$\w+\.StartsWith\('ms-resource:'\)/); + }); + + it('ForEach 循环应使用 $actualClsid 分离 CLSID 与 Default 值', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('$actualClsid'); + expect(script).toContain('$defaultVal'); + // command 字段应使用 $actualClsid,而非旧的 $clsid + expect(script).toContain('command = [string]$actualClsid'); }); }); From c42d3aa51b72a5132ca4a15c5848965681995146 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 05:21:02 +0800 Subject: [PATCH 10/31] =?UTF-8?q?feat(shellex):=20=E6=96=B0=E5=A2=9E=20Res?= =?UTF-8?q?olve-ExtName=20Level=201.7/2.5/3=20=E5=90=8D=E7=A7=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Level 0 重构:directName 仅处理间接格式(@dll,-id / ms-resource:), 普通字符串降级至 Level 3,避免英文技术名抢占本地化结果 - Level 1.7:预建 CommandStore 反向索引,通过 ExplorerCommandHandler CLSID 反查 MUIVerb,支持 Taskband Pin 等 ImplementsVerbs 扩展 - Level 2.5:读取 InprocServer32 DLL 的 FileDescription/ProductName, 过滤泛型描述(shell extension、context menu 等)后作为兜底名称, 解决 YunShellExt 等第三方扩展无法显示中文产品名的问题 - Level 3:directName 普通字符串兜底,位于所有 CLSID 查询链之后 - 更新测试:调整两个已废弃测试名/断言,新增 Level 2.5 专项测试(共 28 个) Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/PowerShellBridge.ts | 83 +++++++++++++++---- .../main/services/PowerShellBridge.test.ts | 66 +++++++++++++-- 2 files changed, 125 insertions(+), 24 deletions(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 3080552..aa2cd21 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -254,26 +254,18 @@ public class CmHelper { } } function Resolve-ExtName($clsid, $fallback, $directName = $null) { - # Level 0: handler key 的非 CLSID Default 值(最贴近当前系统实际) - # 支持 @dll,-id 和 ms-resource: 两种间接格式,以及普通字符串 - if ($directName) { - if ($directName.StartsWith('@') -or $directName.StartsWith('ms-resource:')) { - try { - $resolved = [CmHelper]::ResolveIndirect($directName) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } else { - $lc = $directName.ToLower() - if ($lc -notmatch '外壳服务对象' -and - $lc -notmatch 'shell service object' -and - $lc -notmatch 'shell extension') { return $directName } - } + # Level 0: directName(仅间接格式:@dll,-id 或 ms-resource:,最高本地化优先级) + if ($directName -and ($directName.StartsWith('@') -or $directName.StartsWith('ms-resource:'))) { + try { + $resolved = [CmHelper]::ResolveIndirect($directName) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } + } catch {} } if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { $clsidPath = 'HKCR:\\CLSID\\' + $clsid if (Test-Path -LiteralPath $clsidPath) { $clsidKey = Get-Item -LiteralPath $clsidPath - # Level 1: LocalizedString(专为 Shell 扩展显示名设计) + # Level 1: LocalizedString(专为 Shell 扩展显示名设计,自动多语言) # 注意:FriendlyTypeName 是 COM 类型描述(如"外壳服务对象"),不是菜单名,已移除 $raw = $clsidKey.GetValue('LocalizedString') if ($raw) { @@ -302,10 +294,51 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { } catch {} } elseif ($muiVerb.Length -ge 2) { return $muiVerb } } + # Level 1.7: CommandStore 反向查找(ExplorerCommandHandler = $clsid → MUIVerb) + # 适用于通过 ImplementsVerbs 注册但 CLSID 自身无本地化字段的 shell 扩展(如 Taskband Pin) + if ($cmdStoreVerbs.ContainsKey($clsid)) { return $cmdStoreVerbs[$clsid] } # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) $def = $clsidKey.GetValue('') if ($def -and $def.Length -ge 2) { return [string]$def } } + # Level 2.5: InprocServer32 DLL FileDescription/ProductName + # 适用于无本地化注册表字段的第三方扩展(如 YunShellExt → 阿里云盘) + $inprocPath2 = $clsidPath + '\\InprocServer32' + if (Test-Path -LiteralPath $inprocPath2) { + $dllRaw2 = (Get-Item -LiteralPath $inprocPath2).GetValue('') + if ($dllRaw2) { + $dllExp = [System.Environment]::ExpandEnvironmentVariables($dllRaw2) + if ($dllExp -and (Test-Path -LiteralPath $dllExp -PathType Leaf)) { + try { + $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) + foreach ($cand in @($vi.FileDescription, $vi.ProductName)) { + if ($cand -and $cand.Length -ge 2 -and $cand.Length -le 64) { + $lc = $cand.ToLower() + if ($lc -notmatch 'shell\s*(extension|ext|common)' -and + $lc -notmatch 'context\s*menu' -and + $lc -notmatch 'ctx\s*menu' -and + $lc -notmatch '外壳服务对象' -and + $lc -notmatch '\bshell service' -and + $lc -notmatch '\bcom (object|server|class)' -and + $lc -notmatch '\.dll$' -and + $lc -notmatch '^microsoft windows') { + return $cand + } + } + } + } catch {} + } + } + } + } + # Level 3: directName 普通字符串兜底(优先 CLSID 本地化后再用英文名) + if ($directName -and + -not $directName.StartsWith('@') -and + -not $directName.StartsWith('ms-resource:')) { + $lc = $directName.ToLower() + if ($lc -notmatch '外壳服务对象' -and + $lc -notmatch 'shell service object' -and + $lc -notmatch 'shell extension') { return $directName } } return $fallback } @@ -313,6 +346,26 @@ function Format-DisplayName($name) { if (-not $name) { return $name } return $name.Trim() } +# 预建 CommandStore 反向索引:ExplorerCommandHandler(CLSID) → 已解析的 MUIVerb +# 仅扫描一次,供 Resolve-ExtName Level 1.7 使用 +$cmdStoreVerbs = @{} +$cmdStorePath = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell' +if (Test-Path -LiteralPath $cmdStorePath) { + Get-ChildItem -LiteralPath $cmdStorePath | ForEach-Object { + $handler = $_.GetValue('ExplorerCommandHandler') + if ($handler -match '^\\{[0-9A-Fa-f-]+\\}$' -and -not $cmdStoreVerbs.ContainsKey($handler)) { + $mv = $_.GetValue('MUIVerb') + if ($mv) { + if ($mv.StartsWith('@') -or $mv.StartsWith('ms-resource:')) { + try { + $r = [CmHelper]::ResolveIndirect($mv) + if ($r -and $r.Length -ge 2) { $cmdStoreVerbs[$handler] = $r } + } catch {} + } elseif ($mv.Length -ge 2) { $cmdStoreVerbs[$handler] = $mv } + } + } + } +} $shellexPath = 'HKCR:\\${shellexSubPath}' if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } $handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index beb2907..9b3a895 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -128,24 +128,22 @@ describe('PowerShellBridge', () => { expect(script).not.toContain('ReadDllStrings'); }); - it('不应包含 DLL VersionInfo 字段(已移除 FileVersionInfo 级别)', () => { + it('仅使用 FileDescription/ProductName,不扫描 InternalName/OriginalFilename', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); expect(script).not.toContain('InternalName'); expect(script).not.toContain('OriginalFilename'); - expect(script).not.toContain('FileDescription'); - expect(script).not.toContain('FileVersionInfo'); }); - it('不应以 foreach 遍历 VersionInfo 字段(DLL VersionInfo 已移除)', () => { + it('Level 2.5 使用 FileVersionInfo::GetVersionInfo,不遍历所有字段', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).not.toContain('FileVersionInfo'); - expect(script).not.toContain('ProductName'); + expect(script).toContain('FileVersionInfo'); + expect(script).toContain('ProductName'); }); it('应将 CLSID Default 值作为 Level 2 兜底', () => { @@ -228,14 +226,44 @@ describe('PowerShellBridge', () => { ); expect(script).toContain('$directName = $null'); - expect(script).toContain('Level 0: handler key'); - // Level 0 的位置应在 Level 1 LocalizedString 之前 - const level0Idx = script.indexOf('Level 0: handler key'); + expect(script).toContain('Level 0: directName'); + // Level 0(间接格式)的位置应在 Level 1 LocalizedString 之前 + const level0Idx = script.indexOf('Level 0: directName'); const level1Idx = script.indexOf('Level 1: LocalizedString'); expect(level0Idx).toBeGreaterThan(0); expect(level1Idx).toBeGreaterThan(level0Idx); }); + it('plain string directName 应降级到 CLSID 查询链之后(Level 3)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('Level 3: directName'); + // Level 3 注释必须在 Level 2 之后 + const level2Idx = script.indexOf('Level 2: CLSID 默认值'); + const level3Idx = script.indexOf('Level 3: directName'); + expect(level2Idx).toBeGreaterThan(0); + expect(level3Idx).toBeGreaterThan(level2Idx); + }); + + it('应预建 CommandStore 反向索引并在 Level 1.7 查找', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // 预建索引存在 + expect(script).toContain('cmdStoreVerbs'); + expect(script).toContain('CommandStore'); + expect(script).toContain('ExplorerCommandHandler'); + // Level 1.7 存在且位于 Level 1.5 与 Level 2 之间 + const level15Idx = script.indexOf('Level 1.5:'); + const level17Idx = script.indexOf('Level 1.7:'); + const level2Idx = script.indexOf('Level 2: CLSID 默认值'); + expect(level17Idx).toBeGreaterThan(level15Idx); + expect(level2Idx).toBeGreaterThan(level17Idx); + }); + it('CmHelper.ResolveIndirect 应支持 ms-resource: 前缀', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' @@ -263,6 +291,26 @@ describe('PowerShellBridge', () => { // command 字段应使用 $actualClsid,而非旧的 $clsid expect(script).toContain('command = [string]$actualClsid'); }); + + it('InprocServer32 DLL FileDescription/ProductName 应作为 Level 2.5', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 2.5 注释存在 + expect(script).toContain('Level 2.5:'); + // 使用 FileVersionInfo::GetVersionInfo + expect(script).toContain('FileVersionInfo]::GetVersionInfo'); + // 包含过滤关键词 + expect(script).toMatch(/shell.*extension/i); + expect(script).toMatch(/context.*menu/i); + // Level 2.5 位于 Level 2 之后、Level 3 之前 + const level2Idx = script.indexOf('Level 2: CLSID 默认值'); + const level25Idx = script.indexOf('Level 2.5:'); + const level3Idx = script.indexOf('Level 3: directName'); + expect(level25Idx).toBeGreaterThan(level2Idx); + expect(level3Idx).toBeGreaterThan(level25Idx); + }); }); describe('buildSetEnabledScript', () => { From 0551a7053ec5c18a587b80623faaa9822a7162b6 Mon Sep 17 00:00:00 2001 From: tanzz Date: Sat, 14 Mar 2026 23:19:22 +0800 Subject: [PATCH 11/31] feat(PowerShellBridge): add generic name filtering for shell extension names Add Test-IsGenericName function to centralize filtering of generic COM/shell descriptions. The function checks for patterns like "context menu", "class" suffix, and placeholders. Update all name resolution levels (1.5 MUIVerb, 2 CLSID Default, 2.5 DLL metadata) to use this common filter. Add corresponding test cases to verify the filtering behavior. --- src/main/services/PowerShellBridge.ts | 53 +++++++++-------- .../main/services/PowerShellBridge.test.ts | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index aa2cd21..4c60287 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -253,6 +253,27 @@ public class CmHelper { try { Add-Type -TypeDefinition $src -ErrorAction Stop; $helperLoaded = $true } catch {} } } +function Test-IsGenericName($name) { + if (-not $name -or $name.Length -lt 2) { return $true } + $lc = $name.ToLower() + # Group A: COM/Shell 技术内部描述 + if ($lc -match '外壳服务对象') { return $true } + if ($lc -match 'shell service object') { return $true } + if ($lc -match 'shell\\s*(extension|ext|common)') { return $true } + if ($lc -match 'context\\s*menu') { return $true } + if ($lc -match 'ctx\\s*menu') { return $true } + if ($lc -match '\\bshell service') { return $true } + if ($lc -match '\\bcom (object|server|class)') { return $true } + if ($lc -match '\\.dll$') { return $true } + if ($lc -match '^microsoft windows') { return $true } + # Group B: COM 类名后缀(新增) + if ($lc -match '\\s+class$') { return $true } + # Group C: 占位符/未完成文本(新增) + if ($lc -match '^todo:') { return $true } + if ($lc -match '<[^>]+>') { return $true } + if ($lc -match '^(n/a|na|none|unknown|untitled)$') { return $true } + return $false +} function Resolve-ExtName($clsid, $fallback, $directName = $null) { # Level 0: directName(仅间接格式:@dll,-id 或 ms-resource:,最高本地化优先级) if ($directName -and ($directName.StartsWith('@') -or $directName.StartsWith('ms-resource:'))) { @@ -276,12 +297,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { } catch {} } elseif ($raw.Length -ge 2) { # 过滤泛型 COM 类型描述,这类值不适合作为菜单显示名 - $lc = $raw.ToLower() - if ($lc -notmatch '外壳服务对象' -and - $lc -notmatch 'shell service object' -and - $lc -notmatch 'shell extension') { - return $raw - } + if (-not (Test-IsGenericName $raw)) { return $raw } } } # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) @@ -292,14 +308,18 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { $resolved = [CmHelper]::ResolveIndirect($muiVerb) if ($resolved -and $resolved.Length -ge 2) { return $resolved } } catch {} - } elseif ($muiVerb.Length -ge 2) { return $muiVerb } + } elseif ($muiVerb.Length -ge 2) { + if (-not (Test-IsGenericName $muiVerb)) { return $muiVerb } + } } # Level 1.7: CommandStore 反向查找(ExplorerCommandHandler = $clsid → MUIVerb) # 适用于通过 ImplementsVerbs 注册但 CLSID 自身无本地化字段的 shell 扩展(如 Taskband Pin) if ($cmdStoreVerbs.ContainsKey($clsid)) { return $cmdStoreVerbs[$clsid] } # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) $def = $clsidKey.GetValue('') - if ($def -and $def.Length -ge 2) { return [string]$def } + if ($def -and $def.Length -ge 2) { + if (-not (Test-IsGenericName $def)) { return [string]$def } + } } # Level 2.5: InprocServer32 DLL FileDescription/ProductName # 适用于无本地化注册表字段的第三方扩展(如 YunShellExt → 阿里云盘) @@ -313,17 +333,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) foreach ($cand in @($vi.FileDescription, $vi.ProductName)) { if ($cand -and $cand.Length -ge 2 -and $cand.Length -le 64) { - $lc = $cand.ToLower() - if ($lc -notmatch 'shell\s*(extension|ext|common)' -and - $lc -notmatch 'context\s*menu' -and - $lc -notmatch 'ctx\s*menu' -and - $lc -notmatch '外壳服务对象' -and - $lc -notmatch '\bshell service' -and - $lc -notmatch '\bcom (object|server|class)' -and - $lc -notmatch '\.dll$' -and - $lc -notmatch '^microsoft windows') { - return $cand - } + if (-not (Test-IsGenericName $cand)) { return $cand } } } } catch {} @@ -335,10 +345,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { if ($directName -and -not $directName.StartsWith('@') -and -not $directName.StartsWith('ms-resource:')) { - $lc = $directName.ToLower() - if ($lc -notmatch '外壳服务对象' -and - $lc -notmatch 'shell service object' -and - $lc -notmatch 'shell extension') { return $directName } + if (-not (Test-IsGenericName $directName)) { return $directName } } return $fallback } diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 9b3a895..0a53459 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -199,6 +199,27 @@ describe('PowerShellBridge', () => { expect(muiVerbIdx).toBeGreaterThan(localizedIdx); expect(level2Idx).toBeGreaterThan(muiVerbIdx); + + // Level 1.5 MUIVerb 应调用 Test-IsGenericName 过滤 + expect(script).toMatch(/Level 1\.5[\s\S]{0,600}Test-IsGenericName/); + }); + + it('Level 1.5 MUIVerb 和 Level 2 CLSID Default 应过滤泛型描述', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 1.5 MUIVerb 非间接分支调用 Test-IsGenericName + const level15Start = script.indexOf('Level 1.5:'); + const level17Start = script.indexOf('Level 1.7:'); + const muiVerbBlock = script.slice(level15Start, level17Start); + expect(muiVerbBlock).toContain('Test-IsGenericName'); + + // Level 2 Default 调用 Test-IsGenericName + const level2Start = script.indexOf('Level 2:'); + const level25Start = script.indexOf('Level 2.5:'); + const level2Block = script.slice(level2Start, level25Start); + expect(level2Block).toContain('Test-IsGenericName'); }); it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { @@ -292,6 +313,43 @@ describe('PowerShellBridge', () => { expect(script).toContain('command = [string]$actualClsid'); }); + it('应包含 Test-IsGenericName 函数定义,位于 Resolve-ExtName 之前', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const testFnIdx = script.indexOf('function Test-IsGenericName'); + const resolveFnIdx = script.indexOf('function Resolve-ExtName'); + expect(testFnIdx).toBeGreaterThan(0); + expect(resolveFnIdx).toBeGreaterThan(testFnIdx); + }); + + it('Test-IsGenericName 应包含 context\\s*menu、class 后缀和占位符过滤模式', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const fnStart = script.indexOf('function Test-IsGenericName'); + const fnEnd = script.indexOf('function Resolve-ExtName'); + const fnBody = script.slice(fnStart, fnEnd); + expect(fnBody).toContain("context\\s*menu"); // Case 1: Quark AI Context Menu + expect(fnBody).toContain("\\s+class$"); // Case 2: PcyybContextnMenu Class + expect(fnBody).toContain("^todo:"); // Case 3: TODO: + expect(fnBody).toContain("<[^>]+>"); // Case 3: + }); + + it('Level 2.5 应调用 Test-IsGenericName 且保留长度上限 -le 64', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const level25Start = script.indexOf('Level 2.5:'); + const level3Start = script.indexOf('Level 3: directName'); + const block = script.slice(level25Start, level3Start); + expect(block).toContain('Test-IsGenericName'); + expect(block).toContain('-le 64'); + }); + it('InprocServer32 DLL FileDescription/ProductName 应作为 Level 2.5', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' From f033cccd609b19699c9cb4a0dea11753fab38fd5 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 19 Mar 2026 03:09:25 +0800 Subject: [PATCH 12/31] refactor(registry): improve menu item resolution and caching feat(utils): add shared escapeHtml utility function fix(backup): validate backup existence before deletion fix(ipc): handle closed window case in backup handlers perf(menu): implement scene preloading and request queuing test(history): add operation history service tests docs: add shell extension name resolution guide --- docs/shell-extension-name-resolution.md | 279 ++++++++++++++ src/main/index.ts | 17 +- src/main/ipc/backup.ts | 6 +- src/main/ipc/registry.ts | 2 +- src/main/services/BackupService.ts | 20 +- src/main/services/MenuManagerService.ts | 48 ++- src/main/services/OperationHistoryService.ts | 14 +- src/main/services/PowerShellBridge.ts | 284 +++++++++----- src/main/services/RegistryService.ts | 18 +- src/renderer/global.d.ts | 78 ++-- src/renderer/main.ts | 17 +- src/renderer/pages/backupPage.ts | 5 +- src/renderer/pages/historyPage.ts | 5 +- src/renderer/pages/mainPage.ts | 95 +++-- src/renderer/utils/debug.ts | 8 +- src/renderer/utils/html.ts | 8 + tests/unit/main/ipc/registry.test.ts | 2 +- .../unit/main/services/BackupService.test.ts | 14 +- .../main/services/MenuManagerService.test.ts | 2 +- .../services/OperationHistoryService.test.ts | 177 +++++++++ .../main/services/PowerShellBridge.test.ts | 355 +++++++++++++++++- 21 files changed, 1246 insertions(+), 208 deletions(-) create mode 100644 docs/shell-extension-name-resolution.md create mode 100644 src/renderer/utils/html.ts create mode 100644 tests/unit/main/services/OperationHistoryService.test.ts 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/src/main/index.ts b/src/main/index.ts index 926c28b..4eea09d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,8 +1,10 @@ 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 { OperationRecordRepo } from './data/repositories/OperationRecordRepo'; import { BackupSnapshotRepo } from './data/repositories/BackupSnapshotRepo'; @@ -54,7 +56,7 @@ function createWindow(): void { } } -function initServices(): void { +function initServices(): MenuManagerService { const db = getDatabase(); const ps = new PowerShellBridge(); const registry = new RegistryService(ps); @@ -68,13 +70,24 @@ function initServices(): void { registerHistoryHandlers(history, menuManager); registerBackupHandlers(backup); registerSystemHandlers(); + 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/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 0771c6a..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 { @@ -176,6 +190,22 @@ export class MenuManagerService { } } + /** + * 后台预热所有场景(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'); + } + /** * 获取缓存统计信息 */ 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 4c60287..e9c3880 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -13,29 +13,77 @@ 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)); + const { stdout, stderr } = await execFileAsync( + PS_EXE, + ['-NonInteractive', '-NoProfile', '-OutputFormat', 'Text', '-Command', script], + { 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 +91,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(); @@ -84,18 +132,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; @@ -112,6 +160,77 @@ ${script} return parsed as T; } + /** 加载或编译 CmHelper.dll(缓存至 %LOCALAPPDATA%\ContextMaster\),设置 $helperLoaded */ + private buildCmHelperBlock(): string { + return `$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 ($helperLoaded) { + try { if ([CmHelper]::Ver -ne '2026.3') { $helperLoaded = $false } } catch { $helperLoaded = $false } +} +if (-not $helperLoaded) { + $src = @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public class CmHelper { + public static readonly string Ver = "2026.3"; + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); + public static string ResolveIndirect(string s) { + if (string.IsNullOrEmpty(s) || + (!s.StartsWith("@") && !s.StartsWith("ms-resource:"))) return null; + var sb = new StringBuilder(512); + return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; + } + [DllImport("version.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern uint GetFileVersionInfoSize(string lp, out uint h); + [DllImport("version.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern bool GetFileVersionInfo(string lp, uint h, uint n, byte[] d); + [DllImport("version.dll", SetLastError=false)] + static extern bool VerQueryValue(byte[] d, string s, out IntPtr p, out uint l); + public static string[] GetLocalizedVerStrings(string path) { + uint h; uint sz = GetFileVersionInfoSize(path, out h); + if (sz == 0) return null; + byte[] data = new byte[sz]; + if (!GetFileVersionInfo(path, h, sz, data)) return null; + IntPtr tp; uint tl; + if (!VerQueryValue(data, @"\\VarFileInfo\\Translation", out tp, out tl) || tl < 4) return null; + int uiLang = System.Globalization.CultureInfo.CurrentUICulture.LCID; + var trans = new System.Collections.Generic.List(); + for (uint i = 0; i < tl / 4; i++) { + short lang = System.Runtime.InteropServices.Marshal.ReadInt16(tp, (int)(i * 4)); + short cp = System.Runtime.InteropServices.Marshal.ReadInt16(tp, (int)(i * 4 + 2)); + string key = string.Format("{0:X4}{1:X4}", (ushort)lang, (ushort)cp); + if ((int)(ushort)lang == uiLang) trans.Insert(0, key); else trans.Add(key); + } + foreach (var key in trans) { + IntPtr p; uint l; + string fd = null, pn = null; + if (VerQueryValue(data, @"\\StringFileInfo\\" + key + @"\\FileDescription", out p, out l) && l > 0) + fd = System.Runtime.InteropServices.Marshal.PtrToStringUni(p); + if (VerQueryValue(data, @"\\StringFileInfo\\" + key + @"\\ProductName", out p, out l) && l > 0) + pn = System.Runtime.InteropServices.Marshal.PtrToStringUni(p); + if (fd != null || pn != null) return new string[] { fd ?? "", pn ?? "" }; + } + return null; + } +} +'@ + 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 {} + } +}`; + } + /** * 构建扫描指定注册表路径下所有子键的脚本 * 返回 JSON 数组,每项含菜单条目信息 @@ -121,24 +240,13 @@ ${script} return ` $ErrorActionPreference = 'SilentlyContinue' New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null -try { - Add-Type -TypeDefinition @' -using System; using System.Runtime.InteropServices; using System.Text; -public class CmShell { - [DllImport("shlwapi.dll", CharSet=CharSet.Unicode)] - static extern int SHLoadIndirectString(string s, StringBuilder b, int c, IntPtr r); - public static string Resolve(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; - } -} -'@ -ErrorAction Stop -} catch {} +${this.buildCmHelperBlock()} function Resolve-MenuName($raw) { if (-not $raw) { return $null } - if ($raw -match '^@') { - try { $r = [CmShell]::Resolve($raw); if ($r) { return $r } } catch {} + if ($raw -match '^@' -or $raw -match '^ms-resource:') { + if ($helperLoaded) { + try { $r = [CmHelper]::ResolveIndirect($raw); if ($r) { return $r } } catch {} + } return $null } return $raw @@ -222,48 +330,18 @@ Write-Output '{"ok":true}' 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; -public class CmHelper { - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); - public static string ResolveIndirect(string s) { - if (string.IsNullOrEmpty(s) || - (!s.StartsWith("@") && !s.StartsWith("ms-resource:"))) return null; - var sb = new StringBuilder(512); - return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; - } -} -'@ - 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 {} - } -} +${this.buildCmHelperBlock()} function Test-IsGenericName($name) { if (-not $name -or $name.Length -lt 2) { return $true } $lc = $name.ToLower() # Group A: COM/Shell 技术内部描述 if ($lc -match '外壳服务对象') { return $true } - if ($lc -match 'shell service object') { return $true } - if ($lc -match 'shell\\s*(extension|ext|common)') { return $true } - if ($lc -match 'context\\s*menu') { return $true } - if ($lc -match 'ctx\\s*menu') { return $true } - if ($lc -match '\\bshell service') { return $true } - if ($lc -match '\\bcom (object|server|class)') { return $true } + if ($lc -match '^(context|ctx)\\s*menu(\\s*(handler|ext(ension)?|provider|manager))?$') { return $true } + if ($lc -match '^shell\\s*(extension|ext|common)(\\s*(handler|provider|class))?$') { return $true } + # Group A: "* Shell Extension" 后缀(COM 类描述,非用户可见名称,如 "Vim Shell Extension") + if ($lc -match 'shell\\s+extension$') { return $true } + if ($lc -match '^shell\\s*service(\\s*object)?$') { return $true } + if ($lc -match '^com\\s*(object|server|class)$') { return $true } if ($lc -match '\\.dll$') { return $true } if ($lc -match '^microsoft windows') { return $true } # Group B: COM 类名后缀(新增) @@ -272,6 +350,16 @@ function Test-IsGenericName($name) { if ($lc -match '^todo:') { return $true } if ($lc -match '<[^>]+>') { return $true } if ($lc -match '^(n/a|na|none|unknown|untitled)$') { return $true } + # Group D: 句子式描述 / 内部调试标记 + if ($lc -match '^(a|an|the)\\s+') { return $true } # 冠词开头句子(如 "A small project for...") + if ($lc -match '^\\(.+\\)$') { return $true } # 括号完全包裹(如 "(调试)"、"(Debug)") + return $false +} +# 判断 plain string 是否"无用":为空/过短、与键名相同(开发者占位符)或泛型 COM 描述 +function Test-IsUselessPlain($value, $fallback) { + if (-not $value -or $value.Length -lt 2) { return $true } + if ($value -ieq $fallback) { return $true } # 等于键名 → 无信息量 + if (Test-IsGenericName $value) { return $true } # COM/Shell 泛型术语 return $false } function Resolve-ExtName($clsid, $fallback, $directName = $null) { @@ -296,8 +384,27 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { if ($resolved -and $resolved.Length -ge 2) { return $resolved } } catch {} } elseif ($raw.Length -ge 2) { - # 过滤泛型 COM 类型描述,这类值不适合作为菜单显示名 - if (-not (Test-IsGenericName $raw)) { return $raw } + # 过滤泛型 COM 类型描述,这类值不适合作为菜单显示名;与键名相同的值跳过让 Level 2.5 执行 + if (-not (Test-IsUselessPlain $raw $fallback)) { return $raw } + } + } + # Level 1.3: Sibling Shell Key MUIVerb(通用方案) + # 适用于既注册 shellex 又注册 shell verb 的扩展(如 gvim → HKCR:\\*\\shell\\gvim\\MUIVerb) + # $shellPath 为脚本级变量,由 $shellexPath 推导,无需修改函数签名 + if ($shellPath) { + $siblingVerbPath = Join-Path $shellPath $fallback + if (Test-Path -LiteralPath $siblingVerbPath) { + $siblingMUI = (Get-Item -LiteralPath $siblingVerbPath).GetValue('MUIVerb') + if ($siblingMUI) { + if ($siblingMUI.StartsWith('@') -or $siblingMUI.StartsWith('ms-resource:')) { + try { + $resolved = [CmHelper]::ResolveIndirect($siblingMUI) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } + } catch {} + } elseif ($siblingMUI.Length -ge 2) { + if (-not (Test-IsUselessPlain $siblingMUI $fallback)) { return $siblingMUI } + } + } } } # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) @@ -309,7 +416,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { if ($resolved -and $resolved.Length -ge 2) { return $resolved } } catch {} } elseif ($muiVerb.Length -ge 2) { - if (-not (Test-IsGenericName $muiVerb)) { return $muiVerb } + if (-not (Test-IsUselessPlain $muiVerb $fallback)) { return $muiVerb } } } # Level 1.7: CommandStore 反向查找(ExplorerCommandHandler = $clsid → MUIVerb) @@ -318,7 +425,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) $def = $clsidKey.GetValue('') if ($def -and $def.Length -ge 2) { - if (-not (Test-IsGenericName $def)) { return [string]$def } + if (-not (Test-IsUselessPlain $def $fallback)) { return [string]$def } } } # Level 2.5: InprocServer32 DLL FileDescription/ProductName @@ -330,8 +437,12 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { $dllExp = [System.Environment]::ExpandEnvironmentVariables($dllRaw2) if ($dllExp -and (Test-Path -LiteralPath $dllExp -PathType Leaf)) { try { - $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) - foreach ($cand in @($vi.FileDescription, $vi.ProductName)) { + $vs = [CmHelper]::GetLocalizedVerStrings($dllExp) + $candidates = if ($vs) { $vs } else { + $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) + @($vi.FileDescription, $vi.ProductName) + } + foreach ($cand in $candidates) { if ($cand -and $cand.Length -ge 2 -and $cand.Length -le 64) { if (-not (Test-IsGenericName $cand)) { return $cand } } @@ -345,7 +456,7 @@ function Resolve-ExtName($clsid, $fallback, $directName = $null) { if ($directName -and -not $directName.StartsWith('@') -and -not $directName.StartsWith('ms-resource:')) { - if (-not (Test-IsGenericName $directName)) { return $directName } + if (-not (Test-IsUselessPlain $directName $fallback)) { return $directName } } return $fallback } @@ -375,6 +486,11 @@ if (Test-Path -LiteralPath $cmdStorePath) { } $shellexPath = 'HKCR:\\${shellexSubPath}' if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } +# 推导 sibling shell 路径(仅适用于 shellex\\ContextMenuHandlers 路径) +$shellPath = $null +if ($shellexPath -match '\\\\shellex\\\\ContextMenuHandlers$') { + $shellPath = $shellexPath -replace '\\\\shellex\\\\ContextMenuHandlers$', '\\shell' +} $handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } $result = @($handlers | ForEach-Object { $handlerKeyName = $_.PSChildName diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 8d7b367..68a4b09 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -56,7 +56,7 @@ export class RegistryService { * 获取指定场景下的所有菜单条目(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) { @@ -66,14 +66,14 @@ export class RegistryService { const basePath = SCENE_REGISTRY_PATHS[scene]; const shellexPath = SCENE_SHELLEX_PATHS[scene]; - + try { // 并行读取 Classic Shell 命令 + Shell 扩展(COM ContextMenuHandlers) const script = this.ps.buildGetItemsScript(basePath); const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); const [raw, shellexRaw] = await Promise.all([ - this.ps.execute(script), - this.ps.execute(shellexScript).catch((e) => { + this.ps.execute(script, priority), + this.ps.execute(shellexScript, priority).catch((e) => { log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); return [] as PsMenuItemRaw[]; }), @@ -149,14 +149,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')}`); + } } /** 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/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..f28016d 100644 --- a/src/renderer/pages/backupPage.ts +++ b/src/renderer/pages/backupPage.ts @@ -2,6 +2,7 @@ import '../api/bridge'; import type { BackupSnapshot } from '../../shared/types'; import { BackupType } from '../../shared/enums'; import { t, registerRefreshCallback } from '../i18n'; +import { escapeHtml } from '../utils/html'; let backups: BackupSnapshot[] = []; @@ -159,10 +160,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 e785dba..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', @@ -31,6 +32,9 @@ 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(); updateSceneHeader(currentScene); @@ -44,17 +48,45 @@ export function refreshCurrentContent(): void { registerRefreshCallback(refreshCurrentContent); -export async function loadScene(scene: MenuScene): Promise { +export async function loadScene(scene: MenuScene, forceRefresh = false): Promise { if (loadingScene) { - pendingScene = scene; // 记录最新请求,加载完后执行 + 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(); @@ -66,6 +98,7 @@ export async function loadScene(scene: MenuScene): Promise { showError(`${t('main.loadFailed')}: ${result.error}`); } else { currentItems = result.data; + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); updateSceneHeader(scene); renderItems(); updateStatusBar(scene); @@ -79,6 +112,13 @@ export async function loadScene(scene: MenuScene): Promise { } } +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() }); + } +} + // ── 渲染条目列表 ── export function renderItems(): void { const listEl = document.getElementById('itemList'); @@ -184,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 }) @@ -384,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; @@ -439,36 +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 targetScenes = allScenes.filter((scene) => scene !== skipScene); - // 并行加载所有场景的 badge 数量 - const results = await Promise.all( - targetScenes.map(async (scene) => { - try { - const result = await window.api.getMenuItems(scene); - return { scene, result }; - } catch (e) { - return { scene, result: { success: false, error: String(e) } }; - } - }) - ); - - // 更新所有 badge - for (const { scene, result } of results) { + 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.success && 'data' in result) { + 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/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/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/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 51adff7..d821f33 100644 --- a/tests/unit/main/services/MenuManagerService.test.ts +++ b/tests/unit/main/services/MenuManagerService.test.ts @@ -59,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 0a53459..23dace4 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -55,11 +55,12 @@ describe('PowerShellBridge', () => { expect(script).toContain("match '^@'"); }); - it('应包含 CmShell Add-Type 以调用 SHLoadIndirectString', () => { + it('应通过 CmHelper 调用 SHLoadIndirectString(复用缓存 DLL,不再内联编译)', () => { const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - expect(script).toContain('CmShell'); + expect(script).toContain('CmHelper'); expect(script).toContain('SHLoadIndirectString'); + expect(script).not.toContain('CmShell'); }); it('名称回退顺序:MUIVerb → Default → 键名', () => { @@ -176,6 +177,74 @@ describe('PowerShellBridge', () => { expect(script).not.toMatch(/public static string\[\] ReadDllStrings/); }); + it('"Quark AI Context Menu" 不应被泛型名过滤器误判', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // 新规则采用首锚 ^,确认 fnBody 中 context 规则已添加锚点 + const fnStart = script.indexOf('function Test-IsGenericName'); + const fnEnd = script.indexOf('function Resolve-ExtName'); + const fnBody = script.slice(fnStart, fnEnd); + expect(fnBody).toMatch(/\^.*context.*menu/i); + + // 用 JS 模拟新正则:带前缀的产品名不应被匹配 + const ctxRegex = /^(context|ctx)\s*menu(\s*(handler|ext(ension)?|provider|manager))?$/i; + expect(ctxRegex.test('quark ai context menu')).toBe(false); + expect(ctxRegex.test('context menu')).toBe(true); + expect(ctxRegex.test('context menu handler')).toBe(true); + }); + + it('"* Shell Extension" 后缀应被识别为 COM 类描述并过滤', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // 确认新增的 shell\s+extension$ 规则存在于 Test-IsGenericName 中 + const fnStart = script.indexOf('function Test-IsGenericName'); + const fnEnd = script.indexOf('function Test-IsUselessPlain'); + const fnBody = script.slice(fnStart, fnEnd); + expect(fnBody).toMatch(/shell\\s\+extension\$/); + + // 用 JS 模拟:以 "shell extension" 结尾的值应被过滤(COM 类描述,非用户可见名) + const shellExtSuffixRegex = /shell\s+extension$/i; + expect(shellExtSuffixRegex.test('vim shell extension')).toBe(true); // 过滤 → 回退到 "gvim" + expect(shellExtSuffixRegex.test('winrar shell extension')).toBe(true); // 过滤 → 回退到 "WinRAR" + expect(shellExtSuffixRegex.test('shell extension')).toBe(true); // 过滤 + + // 不误杀不以 "shell extension" 结尾的产品名 + expect(shellExtSuffixRegex.test('winrar')).toBe(false); + expect(shellExtSuffixRegex.test('quark ai context menu')).toBe(false); + expect(shellExtSuffixRegex.test('百度网盘')).toBe(false); + }); + + it('CmHelper 源码应包含 GetLocalizedVerStrings 方法', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('GetLocalizedVerStrings'); + expect(script).toContain('GetFileVersionInfoSize'); + expect(script).toContain('VarFileInfo'); + }); + + it('Level 2.5 应优先使用 GetLocalizedVerStrings,并以 FileVersionInfo 作为 fallback', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const level25Start = script.indexOf('Level 2.5:'); + const level3Start = script.indexOf('Level 3: directName'); + const block = script.slice(level25Start, level3Start); + + expect(block).toContain('GetLocalizedVerStrings'); + expect(block).toContain('FileVersionInfo]::GetVersionInfo'); + // GetLocalizedVerStrings 应在 FileVersionInfo 之前(作为主路径) + expect(block.indexOf('GetLocalizedVerStrings')).toBeLessThan( + block.indexOf('FileVersionInfo]::GetVersionInfo') + ); + }); + it('应包含 Level 1 LocalizedString/FriendlyTypeName 解析', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' @@ -200,8 +269,8 @@ describe('PowerShellBridge', () => { expect(muiVerbIdx).toBeGreaterThan(localizedIdx); expect(level2Idx).toBeGreaterThan(muiVerbIdx); - // Level 1.5 MUIVerb 应调用 Test-IsGenericName 过滤 - expect(script).toMatch(/Level 1\.5[\s\S]{0,600}Test-IsGenericName/); + // Level 1.5 MUIVerb 应调用 Test-IsUselessPlain 过滤(统一替换内联条件) + expect(script).toMatch(/Level 1\.5[\s\S]{0,600}Test-IsUselessPlain/); }); it('Level 1.5 MUIVerb 和 Level 2 CLSID Default 应过滤泛型描述', () => { @@ -209,17 +278,17 @@ describe('PowerShellBridge', () => { 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Level 1.5 MUIVerb 非间接分支调用 Test-IsGenericName + // Level 1.5 MUIVerb 非间接分支调用 Test-IsUselessPlain(统一替换内联条件) const level15Start = script.indexOf('Level 1.5:'); const level17Start = script.indexOf('Level 1.7:'); const muiVerbBlock = script.slice(level15Start, level17Start); - expect(muiVerbBlock).toContain('Test-IsGenericName'); + expect(muiVerbBlock).toContain('Test-IsUselessPlain'); - // Level 2 Default 调用 Test-IsGenericName + // Level 2 Default 调用 Test-IsUselessPlain const level2Start = script.indexOf('Level 2:'); const level25Start = script.indexOf('Level 2.5:'); const level2Block = script.slice(level2Start, level25Start); - expect(level2Block).toContain('Test-IsGenericName'); + expect(level2Block).toContain('Test-IsUselessPlain'); }); it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { @@ -324,7 +393,31 @@ describe('PowerShellBridge', () => { expect(resolveFnIdx).toBeGreaterThan(testFnIdx); }); - it('Test-IsGenericName 应包含 context\\s*menu、class 后缀和占位符过滤模式', () => { + it('应包含 Test-IsUselessPlain 函数,位于 Test-IsGenericName 之后、Resolve-ExtName 之前', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const genericFnIdx = script.indexOf('function Test-IsGenericName'); + const uselessFnIdx = script.indexOf('function Test-IsUselessPlain'); + const resolveFnIdx = script.indexOf('function Resolve-ExtName'); + expect(uselessFnIdx).toBeGreaterThan(genericFnIdx); + expect(resolveFnIdx).toBeGreaterThan(uselessFnIdx); + }); + + it('Test-IsUselessPlain 应使用 -ieq 判断值等于键名(而非 -ine)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const fnStart = script.indexOf('function Test-IsUselessPlain'); + const fnEnd = script.indexOf('function Resolve-ExtName'); + const fnBody = script.slice(fnStart, fnEnd); + expect(fnBody).toContain('-ieq $fallback'); + expect(fnBody).toContain('Test-IsGenericName'); + }); + + it('Test-IsGenericName 应包含首锚 context/ctx 规则、class 后缀和占位符过滤模式', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); @@ -332,10 +425,11 @@ describe('PowerShellBridge', () => { const fnStart = script.indexOf('function Test-IsGenericName'); const fnEnd = script.indexOf('function Resolve-ExtName'); const fnBody = script.slice(fnStart, fnEnd); - expect(fnBody).toContain("context\\s*menu"); // Case 1: Quark AI Context Menu - expect(fnBody).toContain("\\s+class$"); // Case 2: PcyybContextnMenu Class - expect(fnBody).toContain("^todo:"); // Case 3: TODO: - expect(fnBody).toContain("<[^>]+>"); // Case 3: + // 新规则采用首锚 ^,context/ctx 规则中含首锚和 context|ctx 分组 + expect(fnBody).toMatch(/\^.*context.*ctx.*menu/); // Case 1: 首锚 context/ctx 规则 + expect(fnBody).toContain("\\s+class$"); // Case 2: PcyybContextnMenu Class + expect(fnBody).toContain("^todo:"); // Case 3: TODO: + expect(fnBody).toContain("<[^>]+>"); // Case 3: }); it('Level 2.5 应调用 Test-IsGenericName 且保留长度上限 -le 64', () => { @@ -369,6 +463,241 @@ describe('PowerShellBridge', () => { expect(level25Idx).toBeGreaterThan(level2Idx); expect(level3Idx).toBeGreaterThan(level25Idx); }); + + it('Level 1 plain string 等于 fallback(键名)时应跳过,让 Level 2.5 执行', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 1 plain string 分支应调用 Test-IsUselessPlain(已统一替换内联条件) + const level1Start = script.indexOf('Level 1: LocalizedString'); + const level15Start = script.indexOf('Level 1.5: MUIVerb'); + const level1Block = script.slice(level1Start, level15Start); + expect(level1Block).toContain('Test-IsUselessPlain'); + }); + + it('Level 1.5 MUIVerb plain string 等于 fallback 时应跳过', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 1.5 plain string 分支应调用 Test-IsUselessPlain + const level15Start = script.indexOf('Level 1.5: MUIVerb'); + const level17Start = script.indexOf('Level 1.7:'); + const level15Block = script.slice(level15Start, level17Start); + expect(level15Block).toContain('Test-IsUselessPlain'); + }); + + it('Level 2 CLSID Default 等于 fallback 时应跳过', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 2 Default 检查分支应调用 Test-IsUselessPlain + const level2Start = script.indexOf('Level 2: CLSID 默认值'); + const level25Start = script.indexOf('Level 2.5:'); + const level2Block = script.slice(level2Start, level25Start); + expect(level2Block).toContain('Test-IsUselessPlain'); + }); + + it('Level 3 directName 等于 fallback 时应跳过(修复漏洞)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // Level 3 plain string 分支应调用 Test-IsUselessPlain(修复前只有 Test-IsGenericName) + const level3Start = script.indexOf('Level 3: directName'); + const returnFallback = script.indexOf('return $fallback', level3Start); + const level3Block = script.slice(level3Start, returnFallback); + expect(level3Block).toContain('Test-IsUselessPlain'); + }); + + it('CmHelper 源码应包含 Ver 版本常量 "2026.3"', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // C# $src 中应包含 Ver 字段 + expect(script).toContain('public static readonly string Ver = "2026.3"'); + }); + + it('应包含 Level 1.3 Sibling Shell Key MUIVerb,位于 Level 1 与 Level 1.5 之间', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('Level 1.3:'); + expect(script).toContain('$shellPath'); + expect(script).toContain('$siblingVerbPath'); + expect(script).toContain('$siblingMUI'); + + const level1Idx = script.indexOf('Level 1: LocalizedString'); + const level13Idx = script.indexOf('Level 1.3:'); + const level15Idx = script.indexOf('Level 1.5: MUIVerb'); + expect(level13Idx).toBeGreaterThan(level1Idx); + expect(level15Idx).toBeGreaterThan(level13Idx); + }); + + it('$shellPath 应在 ForEach 循环前由 $shellexPath 推导', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // $shellPath 赋值语句应在 $handlers = Get-ChildItem 之前 + const shellPathIdx = script.indexOf('$shellPath = $null'); + const handlersIdx = script.indexOf('$handlers = Get-ChildItem'); + expect(shellPathIdx).toBeGreaterThan(0); + expect(handlersIdx).toBeGreaterThan(shellPathIdx); + + // 包含 ContextMenuHandlers 结尾检测 + expect(script).toContain('ContextMenuHandlers$'); + }); + + it('Test-IsGenericName 应包含 Group D 冠词开头和括号包裹规则', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + const fnStart = script.indexOf('function Test-IsGenericName'); + const fnEnd = script.indexOf('function Test-IsUselessPlain'); + const fnBody = script.slice(fnStart, fnEnd); + + // Group D 注释和规则应存在 + expect(fnBody).toContain('Group D'); + expect(fnBody).toContain('a|an|the'); // 冠词规则 + expect(fnBody).toContain("'^\\(.+\\)$'"); // 括号规则(PS 单引号内 \( 匹配字面量 () + }); + + it('Test-IsGenericName Group D JS 模拟:冠词开头句子应被过滤', () => { + // 模拟 PS 正则 '^(a|an|the)\s+' + const articleRegex = /^(a|an|the)\s+/i; + expect(articleRegex.test('a small project for the context menu of gvim!')).toBe(true); + expect(articleRegex.test('an extension handler')).toBe(true); + expect(articleRegex.test('the shell service')).toBe(true); + // 不误杀普通产品名 + expect(articleRegex.test('Adobe Acrobat')).toBe(false); + expect(articleRegex.test('百度网盘')).toBe(false); + expect(articleRegex.test('7-Zip (64-bit)')).toBe(false); + }); + + it('Test-IsGenericName Group D JS 模拟:括号完全包裹的字符串应被过滤', () => { + // 模拟 PS 正则 '^\(.+\)$' + const parenRegex = /^\(.+\)$/; + expect(parenRegex.test('(调试)')).toBe(true); + expect(parenRegex.test('(Debug)')).toBe(true); + expect(parenRegex.test('(unknown)')).toBe(true); + // 不误杀括号在中间或两端不完整的情况 + expect(parenRegex.test('7-Zip (64-bit)')).toBe(false); + expect(parenRegex.test('Adobe Acrobat')).toBe(false); + expect(parenRegex.test('百度网盘')).toBe(false); + }); + + it('加载 DLL 后应立即校验 CmHelper.Ver,版本不匹配时重置 $helperLoaded', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + // 版本校验块:读取 [CmHelper]::Ver 并与 '2026.3' 比较 + expect(script).toContain("[CmHelper]::Ver -ne '2026.3'"); + // 版本校验块位于 DLL 加载块之后、$src 编译块之前 + const dllLoadIdx = script.indexOf('Add-Type -Path $cmDll'); + const verCheckIdx = script.indexOf("[CmHelper]::Ver -ne '2026.3'"); + const compileSrcIdx = script.indexOf('Add-Type -TypeDefinition $src'); + expect(verCheckIdx).toBeGreaterThan(dllLoadIdx); + expect(compileSrcIdx).toBeGreaterThan(verCheckIdx); + }); + }); + + 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', () => { From 6a72dfc5ea8785d97a1ba6ea282b488555de4097 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 01:31:09 +0800 Subject: [PATCH 13/31] =?UTF-8?q?refactor(renderer):=20=E7=94=A8=20HTML5?= =?UTF-8?q?=20=20=E6=9B=BF=E4=BB=A3=E5=8E=9F=E7=94=9F=20prompt/con?= =?UTF-8?q?firm/alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron 不支持原生弹窗 API,新增 dialog.ts 工具模块封装 showPrompt / showConfirm / showAlert,使用 元素实现 自定义弹窗,backupPage 中所有弹窗调用已全部替换。 Co-Authored-By: Claude Opus 4.7 --- src/renderer/index.html | 46 ++++++++++++++ src/renderer/pages/backupPage.ts | 23 +++---- src/renderer/utils/dialog.ts | 101 +++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/renderer/utils/dialog.ts diff --git a/src/renderer/index.html b/src/renderer/index.html index 9b2bd4f..840a2cf 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; } @@ -515,6 +525,42 @@
+ + +
新建备份
+
+
+ +
+ +
+ + + +
确认操作
+
+
+
+ +
+ + + +
提示
+
+
+
+ +
+ diff --git a/src/renderer/pages/backupPage.ts b/src/renderer/pages/backupPage.ts index f28016d..a4592b4 100644 --- a/src/renderer/pages/backupPage.ts +++ b/src/renderer/pages/backupPage.ts @@ -3,6 +3,7 @@ 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[] = []; @@ -15,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; @@ -75,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')}` ); @@ -83,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); @@ -98,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; @@ -106,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(); @@ -122,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); @@ -139,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(); } 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(); + }); +} From 40d2fda7c186cbb540b8cbd78b137da8fa5a82fa Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 03:35:47 +0800 Subject: [PATCH 14/31] =?UTF-8?q?refactor(shellex):=20=E5=B0=86=20Shell=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=90=8D=E7=A7=B0=E8=A7=A3=E6=9E=90=E4=BB=8E?= =?UTF-8?q?=20PS/C#=20=E8=BF=81=E7=A7=BB=E5=88=B0=20TypeScript/koffi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 koffi FFI 直接调用 Windows API 替代内嵌 C# 编译 + 200 行 PS 解析脚本,消除三语言嵌套。核心变更: - 新增 Win32Shell.ts: koffi 封装 SHLoadIndirectString + GetFileVersionInfo - 新增 ShellExtNameResolver.ts: TypeScript 名称解析引擎(Level 0→3 回退链 + Test-IsGenericName Group A-D 过滤规则 + CommandStoreIndex) - 删除 buildCmHelperBlock(): 不再运行时编译 C# 源码 - 简化 PS 脚本: Classic Shell / ShellExt 均只返回原始注册表值 - 注册表缓存 + 依赖注入: resolver 通过构造函数注入,方便测试 mock Co-Authored-By: Claude Opus 4.7 --- package.json | 3 +- pnpm-lock.yaml | 8 + pnpm-workspace.yaml | 1 + src/main/index.ts | 15 +- src/main/services/PowerShellBridge.ts | 362 +++--------- src/main/services/RegistryService.ts | 70 +-- src/main/services/ShellExtNameResolver.ts | 194 +++++++ src/main/services/Win32Shell.ts | 167 ++++++ .../main/services/PowerShellBridge.test.ts | 519 +++--------------- .../main/services/RegistryService.test.ts | 269 ++++----- .../services/ShellExtNameResolver.test.ts | 419 ++++++++++++++ vite.main.config.ts | 2 +- 12 files changed, 1113 insertions(+), 916 deletions(-) create mode 100644 src/main/services/ShellExtNameResolver.ts create mode 100644 src/main/services/Win32Shell.ts create mode 100644 tests/unit/main/services/ShellExtNameResolver.test.ts 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 4eea09d..a1e889b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,8 @@ 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'; @@ -59,13 +61,24 @@ function createWindow(): 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); + 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); diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index e9c3880..46f3be8 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -160,107 +160,21 @@ ${script} return parsed as T; } - /** 加载或编译 CmHelper.dll(缓存至 %LOCALAPPDATA%\ContextMaster\),设置 $helperLoaded */ - private buildCmHelperBlock(): string { - return `$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 ($helperLoaded) { - try { if ([CmHelper]::Ver -ne '2026.3') { $helperLoaded = $false } } catch { $helperLoaded = $false } -} -if (-not $helperLoaded) { - $src = @' -using System; -using System.Runtime.InteropServices; -using System.Text; -public class CmHelper { - public static readonly string Ver = "2026.3"; - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); - public static string ResolveIndirect(string s) { - if (string.IsNullOrEmpty(s) || - (!s.StartsWith("@") && !s.StartsWith("ms-resource:"))) return null; - var sb = new StringBuilder(512); - return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; - } - [DllImport("version.dll", CharSet=CharSet.Unicode, SetLastError=true)] - static extern uint GetFileVersionInfoSize(string lp, out uint h); - [DllImport("version.dll", CharSet=CharSet.Unicode, SetLastError=true)] - static extern bool GetFileVersionInfo(string lp, uint h, uint n, byte[] d); - [DllImport("version.dll", SetLastError=false)] - static extern bool VerQueryValue(byte[] d, string s, out IntPtr p, out uint l); - public static string[] GetLocalizedVerStrings(string path) { - uint h; uint sz = GetFileVersionInfoSize(path, out h); - if (sz == 0) return null; - byte[] data = new byte[sz]; - if (!GetFileVersionInfo(path, h, sz, data)) return null; - IntPtr tp; uint tl; - if (!VerQueryValue(data, @"\\VarFileInfo\\Translation", out tp, out tl) || tl < 4) return null; - int uiLang = System.Globalization.CultureInfo.CurrentUICulture.LCID; - var trans = new System.Collections.Generic.List(); - for (uint i = 0; i < tl / 4; i++) { - short lang = System.Runtime.InteropServices.Marshal.ReadInt16(tp, (int)(i * 4)); - short cp = System.Runtime.InteropServices.Marshal.ReadInt16(tp, (int)(i * 4 + 2)); - string key = string.Format("{0:X4}{1:X4}", (ushort)lang, (ushort)cp); - if ((int)(ushort)lang == uiLang) trans.Insert(0, key); else trans.Add(key); - } - foreach (var key in trans) { - IntPtr p; uint l; - string fd = null, pn = null; - if (VerQueryValue(data, @"\\StringFileInfo\\" + key + @"\\FileDescription", out p, out l) && l > 0) - fd = System.Runtime.InteropServices.Marshal.PtrToStringUni(p); - if (VerQueryValue(data, @"\\StringFileInfo\\" + key + @"\\ProductName", out p, out l) && l > 0) - pn = System.Runtime.InteropServices.Marshal.PtrToStringUni(p); - if (fd != null || pn != null) return new string[] { fd ?? "", pn ?? "" }; - } - return null; - } -} -'@ - 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 {} - } -}`; - } - /** - * 构建扫描指定注册表路径下所有子键的脚本 - * 返回 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 -${this.buildCmHelperBlock()} -function Resolve-MenuName($raw) { - if (-not $raw) { return $null } - if ($raw -match '^@' -or $raw -match '^ms-resource:') { - if ($helperLoaded) { - try { $r = [CmHelper]::ResolveIndirect($raw); if ($r) { return $r } } catch {} - } - return $null - } - return $raw -} $basePath = 'HKCR:\\${hkcrSubPath}' if (-not (Test-Path -LiteralPath $basePath)) { Write-Output '[]'; exit } $subKeys = Get-ChildItem -LiteralPath $basePath | Where-Object { $_.PSIsContainer } $result = @($subKeys | ForEach-Object { $key = $_ $keyName = $key.PSChildName - $name = Resolve-MenuName ($key.GetValue('MUIVerb')) - if (-not $name) { $name = Resolve-MenuName ($key.GetValue('')) } - if (-not $name) { $name = Resolve-MenuName ($key.GetValue('LocalizedDisplayName')) } - if (-not $name) { $name = $keyName } $iconPath = $key.GetValue('Icon') $isEnabled = ($key.GetValue('LegacyDisable') -eq $null) $commandSubKey = Join-Path $key.PSPath 'command' @@ -271,13 +185,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 @@ -320,173 +235,16 @@ Write-Output '{"ok":true}' /** * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 - * 使用三级级联策略解析本地化名称: - * 1. LocalizedString → SHLoadIndirectString(@ 格式)或直接使用 - * 2. CLSID 默认值(可靠、ASCII-safe) - * 3. 处理程序键名(最终兜底) - * 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 -${this.buildCmHelperBlock()} -function Test-IsGenericName($name) { - if (-not $name -or $name.Length -lt 2) { return $true } - $lc = $name.ToLower() - # Group A: COM/Shell 技术内部描述 - if ($lc -match '外壳服务对象') { return $true } - if ($lc -match '^(context|ctx)\\s*menu(\\s*(handler|ext(ension)?|provider|manager))?$') { return $true } - if ($lc -match '^shell\\s*(extension|ext|common)(\\s*(handler|provider|class))?$') { return $true } - # Group A: "* Shell Extension" 后缀(COM 类描述,非用户可见名称,如 "Vim Shell Extension") - if ($lc -match 'shell\\s+extension$') { return $true } - if ($lc -match '^shell\\s*service(\\s*object)?$') { return $true } - if ($lc -match '^com\\s*(object|server|class)$') { return $true } - if ($lc -match '\\.dll$') { return $true } - if ($lc -match '^microsoft windows') { return $true } - # Group B: COM 类名后缀(新增) - if ($lc -match '\\s+class$') { return $true } - # Group C: 占位符/未完成文本(新增) - if ($lc -match '^todo:') { return $true } - if ($lc -match '<[^>]+>') { return $true } - if ($lc -match '^(n/a|na|none|unknown|untitled)$') { return $true } - # Group D: 句子式描述 / 内部调试标记 - if ($lc -match '^(a|an|the)\\s+') { return $true } # 冠词开头句子(如 "A small project for...") - if ($lc -match '^\\(.+\\)$') { return $true } # 括号完全包裹(如 "(调试)"、"(Debug)") - return $false -} -# 判断 plain string 是否"无用":为空/过短、与键名相同(开发者占位符)或泛型 COM 描述 -function Test-IsUselessPlain($value, $fallback) { - if (-not $value -or $value.Length -lt 2) { return $true } - if ($value -ieq $fallback) { return $true } # 等于键名 → 无信息量 - if (Test-IsGenericName $value) { return $true } # COM/Shell 泛型术语 - return $false -} -function Resolve-ExtName($clsid, $fallback, $directName = $null) { - # Level 0: directName(仅间接格式:@dll,-id 或 ms-resource:,最高本地化优先级) - if ($directName -and ($directName.StartsWith('@') -or $directName.StartsWith('ms-resource:'))) { - try { - $resolved = [CmHelper]::ResolveIndirect($directName) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } - if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { - $clsidPath = 'HKCR:\\CLSID\\' + $clsid - if (Test-Path -LiteralPath $clsidPath) { - $clsidKey = Get-Item -LiteralPath $clsidPath - # Level 1: LocalizedString(专为 Shell 扩展显示名设计,自动多语言) - # 注意:FriendlyTypeName 是 COM 类型描述(如"外壳服务对象"),不是菜单名,已移除 - $raw = $clsidKey.GetValue('LocalizedString') - if ($raw) { - if ($raw.StartsWith('@') -or $raw.StartsWith('ms-resource:')) { - try { - $resolved = [CmHelper]::ResolveIndirect($raw) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } elseif ($raw.Length -ge 2) { - # 过滤泛型 COM 类型描述,这类值不适合作为菜单显示名;与键名相同的值跳过让 Level 2.5 执行 - if (-not (Test-IsUselessPlain $raw $fallback)) { return $raw } - } - } - # Level 1.3: Sibling Shell Key MUIVerb(通用方案) - # 适用于既注册 shellex 又注册 shell verb 的扩展(如 gvim → HKCR:\\*\\shell\\gvim\\MUIVerb) - # $shellPath 为脚本级变量,由 $shellexPath 推导,无需修改函数签名 - if ($shellPath) { - $siblingVerbPath = Join-Path $shellPath $fallback - if (Test-Path -LiteralPath $siblingVerbPath) { - $siblingMUI = (Get-Item -LiteralPath $siblingVerbPath).GetValue('MUIVerb') - if ($siblingMUI) { - if ($siblingMUI.StartsWith('@') -or $siblingMUI.StartsWith('ms-resource:')) { - try { - $resolved = [CmHelper]::ResolveIndirect($siblingMUI) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } elseif ($siblingMUI.Length -ge 2) { - if (-not (Test-IsUselessPlain $siblingMUI $fallback)) { return $siblingMUI } - } - } - } - } - # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) - $muiVerb = $clsidKey.GetValue('MUIVerb') - if ($muiVerb) { - if ($muiVerb.StartsWith('@') -or $muiVerb.StartsWith('ms-resource:')) { - try { - $resolved = [CmHelper]::ResolveIndirect($muiVerb) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } elseif ($muiVerb.Length -ge 2) { - if (-not (Test-IsUselessPlain $muiVerb $fallback)) { return $muiVerb } - } - } - # Level 1.7: CommandStore 反向查找(ExplorerCommandHandler = $clsid → MUIVerb) - # 适用于通过 ImplementsVerbs 注册但 CLSID 自身无本地化字段的 shell 扩展(如 Taskband Pin) - if ($cmdStoreVerbs.ContainsKey($clsid)) { return $cmdStoreVerbs[$clsid] } - # Level 2: CLSID 默认值(与参考脚本 (default) 逻辑一致,可靠、ASCII-safe) - $def = $clsidKey.GetValue('') - if ($def -and $def.Length -ge 2) { - if (-not (Test-IsUselessPlain $def $fallback)) { return [string]$def } - } - } - # Level 2.5: InprocServer32 DLL FileDescription/ProductName - # 适用于无本地化注册表字段的第三方扩展(如 YunShellExt → 阿里云盘) - $inprocPath2 = $clsidPath + '\\InprocServer32' - if (Test-Path -LiteralPath $inprocPath2) { - $dllRaw2 = (Get-Item -LiteralPath $inprocPath2).GetValue('') - if ($dllRaw2) { - $dllExp = [System.Environment]::ExpandEnvironmentVariables($dllRaw2) - if ($dllExp -and (Test-Path -LiteralPath $dllExp -PathType Leaf)) { - try { - $vs = [CmHelper]::GetLocalizedVerStrings($dllExp) - $candidates = if ($vs) { $vs } else { - $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) - @($vi.FileDescription, $vi.ProductName) - } - foreach ($cand in $candidates) { - if ($cand -and $cand.Length -ge 2 -and $cand.Length -le 64) { - if (-not (Test-IsGenericName $cand)) { return $cand } - } - } - } catch {} - } - } - } - } - # Level 3: directName 普通字符串兜底(优先 CLSID 本地化后再用英文名) - if ($directName -and - -not $directName.StartsWith('@') -and - -not $directName.StartsWith('ms-resource:')) { - if (-not (Test-IsUselessPlain $directName $fallback)) { return $directName } - } - return $fallback -} -function Format-DisplayName($name) { - if (-not $name) { return $name } - return $name.Trim() -} -# 预建 CommandStore 反向索引:ExplorerCommandHandler(CLSID) → 已解析的 MUIVerb -# 仅扫描一次,供 Resolve-ExtName Level 1.7 使用 -$cmdStoreVerbs = @{} -$cmdStorePath = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell' -if (Test-Path -LiteralPath $cmdStorePath) { - Get-ChildItem -LiteralPath $cmdStorePath | ForEach-Object { - $handler = $_.GetValue('ExplorerCommandHandler') - if ($handler -match '^\\{[0-9A-Fa-f-]+\\}$' -and -not $cmdStoreVerbs.ContainsKey($handler)) { - $mv = $_.GetValue('MUIVerb') - if ($mv) { - if ($mv.StartsWith('@') -or $mv.StartsWith('ms-resource:')) { - try { - $r = [CmHelper]::ResolveIndirect($mv) - if ($r -and $r.Length -ge 2) { $cmdStoreVerbs[$handler] = $r } - } catch {} - } elseif ($mv.Length -ge 2) { $cmdStoreVerbs[$handler] = $mv } - } - } - } -} $shellexPath = 'HKCR:\\${shellexSubPath}' if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } -# 推导 sibling shell 路径(仅适用于 shellex\\ContextMenuHandlers 路径) +# 推导 sibling shell 路径 $shellPath = $null if ($shellexPath -match '\\\\shellex\\\\ContextMenuHandlers$') { $shellPath = $shellexPath -replace '\\\\shellex\\\\ContextMenuHandlers$', '\\shell' @@ -496,46 +254,86 @@ $result = @($handlers | ForEach-Object { $handlerKeyName = $_.PSChildName $defaultVal = $_.GetValue('') $cleanName = $handlerKeyName -replace '^-+', '' - # 实际 CLSID:键名若为 CLSID 格式则优先;否则检查默认值是否为 CLSID $actualClsid = $cleanName - if ($cleanName -notmatch '^\{[0-9A-Fa-f-]+\}$' -and - $defaultVal -match '^\{[0-9A-Fa-f-]+\}$') { + if ($cleanName -notmatch '^\\{[0-9A-Fa-f-]+\\}$' -and + $defaultVal -match '^\\{[0-9A-Fa-f-]+\\}$') { $actualClsid = $defaultVal } - # 直接名称:仅当键名是 CLSID 格式且默认值是非 CLSID 字符串时 - $directName = $null - if ($actualClsid -eq $cleanName -and $defaultVal -and - $defaultVal -notmatch '^\{[0-9A-Fa-f-]+\}$' -and $defaultVal.Length -ge 2) { - $directName = $defaultVal - } - $displayName = Resolve-ExtName $actualClsid $cleanName $directName - $displayName = Format-DisplayName $displayName - $isEnabled = -not $handlerKeyName.StartsWith('-') - $regKey = '${shellexSubPath}\\' + $cleanName + # 读取 CLSID 子键原始值 + $clsidLocalizedString = $null + $clsidMUIVerb = $null + $clsidDefault = $null $dllPath = $null if ($actualClsid -match '^\\{[0-9A-Fa-f-]+\\}$') { - $inprocPath = 'HKCR:\\CLSID\\' + $actualClsid + '\\InprocServer32' - if (Test-Path -LiteralPath $inprocPath) { - $raw = (Get-Item -LiteralPath $inprocPath).GetValue('') - if ($raw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($raw) } + $clsidPath = 'HKCR:\\CLSID\\' + $actualClsid + if (Test-Path -LiteralPath $clsidPath) { + $clsidKey = Get-Item -LiteralPath $clsidPath + 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) } + } + } + } + # 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 } } } + $isEnabled = -not $handlerKeyName.StartsWith('-') + $regKey = '${shellexSubPath}\\' + $cleanName [PSCustomObject]@{ - name = [string]$displayName - command = [string]$actualClsid - iconPath = $null - isEnabled = [bool]$isEnabled - source = [string]$handlerKeyName - registryKey = [string]$regKey - subKeyName = [string]$handlerKeyName - itemType = 'ShellExt' - dllPath = $dllPath + handlerKeyName = [string]$handlerKeyName + cleanName = [string]$cleanName + defaultVal = [string]$defaultVal + isEnabled = [bool]$isEnabled + actualClsid = [string]$actualClsid + clsidLocalizedString = $clsidLocalizedString + clsidMUIVerb = $clsidMUIVerb + clsidDefault = $clsidDefault + dllPath = $dllPath + 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' diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 68a4b09..b73697f 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -2,6 +2,7 @@ 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 完全一致 @@ -27,28 +28,25 @@ 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 - dllPath?: string | null; -} - 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, cache?: RegistryCache) { + constructor( + ps: PowerShellBridge, + resolver: ShellExtNameResolver, + cmdStoreIndex: CommandStoreIndex, + cache?: RegistryCache, + ) { this.ps = ps; + this.resolver = resolver; + this.cmdStoreIndex = cmdStoreIndex; this.cache = cache ?? new RegistryCache(); } @@ -72,30 +70,45 @@ export class RegistryService { const script = this.ps.buildGetItemsScript(basePath); const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); const [raw, shellexRaw] = await Promise.all([ - this.ps.execute(script, priority), - this.ps.execute(shellexScript, priority).catch((e) => { + this.ps.execute(script, priority), + this.ps.execute(shellexScript, priority).catch((e) => { log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); - return [] as PsMenuItemRaw[]; + return [] as PsRawShellExtItem[]; }), ]); - const items = Array.isArray(raw) ? raw : (raw ? [raw] : []); + const classicItems = Array.isArray(raw) ? raw : (raw ? [raw] : []); const shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; - const result = [...items, ...shellexItems].map((r) => ({ + // Classic Shell 条目:通过 resolver 解析名称 + const classicEntries: MenuItemEntry[] = classicItems.map((r: PsRawClassicItem) => ({ id: this.nextId++, - name: this.cleanDisplayName( - (r.name && !r.name.startsWith('@')) ? r.name : (r.subKeyName || r.name) - ), + name: this.cleanDisplayName(this.resolver.resolveClassicName(r)), command: r.command, - iconPath: r.iconPath, + 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) => ({ + id: this.nextId++, + name: this.cleanDisplayName(this.resolver.resolveExtName(r, this.cmdStoreIndex)), + command: r.actualClsid, + iconPath: null, isEnabled: r.isEnabled, - source: r.source || this.inferSource(r.subKeyName), + source: r.handlerKeyName, menuScene: scene, registryKey: r.registryKey, - type: this.determineType(r.itemType, r.command), + type: MenuItemType.ShellExt, dllPath: r.dllPath ?? null, })); + const result = [...classicEntries, ...shellexEntries]; + // 写入缓存 this.cache.set(scene, result); @@ -225,10 +238,6 @@ 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 @@ -240,9 +249,4 @@ export class RegistryService { .trim(); } - private determineType(itemType?: string, command?: string): MenuItemType { - if (itemType === 'ShellExt') return MenuItemType.ShellExt; - if (command && command.trim()) return MenuItemType.Custom; - return MenuItemType.System; - } } diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts new file mode 100644 index 0000000..0ed6ccd --- /dev/null +++ b/src/main/services/ShellExtNameResolver.ts @@ -0,0 +1,194 @@ +import { IWin32Shell } from './Win32Shell'; +import log from '../utils/logger'; + +// ---- 数据契约: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; + 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(clsid: string): string | null { + return this.map.get(clsid.toLowerCase()) ?? null; + } + + invalidate(): void { + this.map.clear(); + } +} + +// ---- Shell 扩展名称解析器 ---- + +export class ShellExtNameResolver { + constructor(private readonly win32: IWin32Shell) {} + + /** 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; + } + } + + 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) return resolved; + } catch { /* fall through */ } + } + + // 以下 Level 需 CLSID 路径存在 + if (raw.actualClsid) { + // Level 1: CLSID.LocalizedString + if (raw.clsidLocalizedString) { + if (raw.clsidLocalizedString.startsWith('@') || raw.clsidLocalizedString.startsWith('ms-resource:')) { + try { + const resolved = this.win32.resolveIndirect(raw.clsidLocalizedString); + if (resolved && resolved.length >= 2) return resolved; + } catch { /* fall through */ } + } else if (raw.clsidLocalizedString.length >= 2) { + if (!isUselessPlain(raw.clsidLocalizedString, fallback)) return raw.clsidLocalizedString; + } + } + + // Level 1.3: Sibling Shell Key MUIVerb + if (raw.siblingMUIVerb) { + if (raw.siblingMUIVerb.startsWith('@') || raw.siblingMUIVerb.startsWith('ms-resource:')) { + try { + const resolved = this.win32.resolveIndirect(raw.siblingMUIVerb); + if (resolved && resolved.length >= 2) return resolved; + } catch { /* fall through */ } + } else if (raw.siblingMUIVerb.length >= 2) { + if (!isUselessPlain(raw.siblingMUIVerb, fallback)) return raw.siblingMUIVerb; + } + } + + // Level 1.5: CLSID.MUIVerb + if (raw.clsidMUIVerb) { + if (raw.clsidMUIVerb.startsWith('@') || raw.clsidMUIVerb.startsWith('ms-resource:')) { + try { + const resolved = this.win32.resolveIndirect(raw.clsidMUIVerb); + if (resolved && resolved.length >= 2) return resolved; + } catch { /* fall through */ } + } else if (raw.clsidMUIVerb.length >= 2) { + if (!isUselessPlain(raw.clsidMUIVerb, fallback)) return raw.clsidMUIVerb; + } + } + + // Level 1.7: CommandStore 反向索引 + const cmdVerb = cmdStore.get(raw.actualClsid); + if (cmdVerb) return cmdVerb; + + // Level 2: CLSID 默认值 + if (raw.clsidDefault && raw.clsidDefault.length >= 2) { + if (!isUselessPlain(raw.clsidDefault, fallback)) return raw.clsidDefault; + } + } + + // Level 2.5: InprocServer32 DLL FileDescription/ProductName + if (raw.dllPath) { + const dllName = this.win32.getFileVersionInfo(raw.dllPath); + if (dllName && dllName.length >= 2 && dllName.length <= 64) { + if (!isGenericName(dllName)) return dllName; + } + } + + // Level 3: directName plain 字符串 + if (raw.defaultVal && + !raw.defaultVal.startsWith('@') && + !raw.defaultVal.startsWith('ms-resource:')) { + if (!isUselessPlain(raw.defaultVal, fallback)) return raw.defaultVal; + } + + // Fallback: 键名 + return fallback; + } +} diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts new file mode 100644 index 0000000..c8d6948 --- /dev/null +++ b/src/main/services/Win32Shell.ts @@ -0,0 +1,167 @@ +import koffi from 'koffi'; +import log from '../utils/logger'; + +export interface IWin32Shell { + resolveIndirect(source: string): string | null; + getFileVersionInfo(dllPath: string): string | null; +} + +// koffi out-param types — 返回数组 [ret, out1, out2, ...] +type Out2 = [A, B]; +type Out3 = [A, B, C]; + +export class Win32Shell implements IWin32Shell { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private resolveIndirectFn: (...args: any[]) => number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getFileVersionInfoSizeW: (...args: any[]) => Out2; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getFileVersionInfoW: (...args: any[]) => boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private verQueryValueW: (...args: any[]) => Out3; + + // 缓存已解析的字符串,避免重复 FFI 调用 + private indirectCache = new Map(); + private versionCache = new Map(); + + constructor() { + const shlwapi = koffi.load('shlwapi.dll'); + const version = koffi.load('version.dll'); + + // HRESULT SHLoadIndirectString(PCWSTR pszSource, PWSTR pszOutBuf, UINT cchOutBuf, void **ppvReserved) + this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ + 'str16', + 'char16 *', + 'uint32', + 'void *', + ]); + + // DWORD GetFileVersionInfoSizeW(LPCWSTR lptstrFilename, LPDWORD lpdwHandle) + this.getFileVersionInfoSizeW = version.func('__stdcall', 'GetFileVersionInfoSizeW', 'uint32', [ + 'str16', + koffi.out('uint32 *'), + ]); + + // BOOL GetFileVersionInfoW(LPCWSTR lptstrFilename, DWORD dwHandle, DWORD dwLen, LPVOID lpData) + this.getFileVersionInfoW = version.func('__stdcall', 'GetFileVersionInfoW', 'bool', [ + 'str16', + 'uint32', + 'uint32', + 'void *', + ]); + + // BOOL VerQueryValueW(LPCVOID pBlock, LPCWSTR lpSubBlock, LPVOID *lplpBuffer, PUINT puLen) + this.verQueryValueW = version.func('__stdcall', 'VerQueryValueW', 'bool', [ + 'void *', + 'str16', + koffi.out(koffi.pointer('void *')), + koffi.out('uint32 *'), + ]); + } + + resolveIndirect(source: string): string | 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(1024); + const hr = this.resolveIndirectFn(source, buf, 512, null); + if (hr === 0) { + const result = buf.toString('utf16le').replace(/\0[\s\S]*$/, ''); + this.indirectCache.set(source, result || null); + return result || null; + } + this.indirectCache.set(source, null); + return null; + } catch (e) { + log.warn('[Win32Shell] SHLoadIndirectString failed:', String(e)); + this.indirectCache.set(source, null); + return null; + } + } + + getFileVersionInfo(dllPath: string): string | null { + const cached = this.versionCache.get(dllPath); + if (cached !== undefined) return cached; + + try { + // Step 1: Get buffer size + const result = this.getFileVersionInfoSizeW(dllPath, [0]); + const size = Array.isArray(result) ? result[0] : (result as unknown as number); + if (!size || size === 0) { + this.versionCache.set(dllPath, null); + return null; + } + + // Step 2: Read version info + const data = Buffer.alloc(size as number); + if (!this.getFileVersionInfoW(dllPath, 0, size as number, data)) { + this.versionCache.set(dllPath, null); + return null; + } + + // Step 3: Query translation table + const vtResult = this.verQueryValueW(data, '\\VarFileInfo\\Translation'); + const transPtr = Array.isArray(vtResult) ? vtResult[1] : null; + const transLen = Array.isArray(vtResult) ? vtResult[2] : 0; + if (!transPtr || (transLen as number) < 4) { + this.versionCache.set(dllPath, null); + return null; + } + + // Read language/codepage pairs + const langKeys: string[] = []; + const numPairs = (transLen as number) / 4; + for (let i = 0; i < numPairs; i++) { + const offset = i * 4; + const lang = this.readUInt16(transPtr as bigint, offset); + const cp = this.readUInt16(transPtr as bigint, offset + 2); + langKeys.push( + `${lang.toString(16).padStart(4, '0').toUpperCase()}${cp.toString(16).padStart(4, '0').toUpperCase()}` + ); + } + + // Step 4: Query FileDescription / ProductName for each language + for (const langKey of langKeys) { + const descResult = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\FileDescription`); + const descPtr = Array.isArray(descResult) ? descResult[1] : null; + const descLen = Array.isArray(descResult) ? descResult[2] : 0; + if (descPtr && (descLen as number) > 0) { + const desc = koffi.decode(descPtr as bigint, 'str16'); + if (desc && desc.length >= 2 && desc.length <= 64) { + this.versionCache.set(dllPath, desc); + return desc; + } + } + + const prodResult = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\ProductName`); + const prodPtr = Array.isArray(prodResult) ? prodResult[1] : null; + const prodLen = Array.isArray(prodResult) ? prodResult[2] : 0; + if (prodPtr && (prodLen as number) > 0) { + const prod = koffi.decode(prodPtr as bigint, 'str16'); + if (prod && prod.length >= 2 && prod.length <= 64) { + this.versionCache.set(dllPath, prod); + return prod; + } + } + } + + this.versionCache.set(dllPath, null); + return null; + } catch (e) { + log.warn('[Win32Shell] GetFileVersionInfo failed for', dllPath, ':', String(e)); + this.versionCache.set(dllPath, null); + return null; + } + } + + private readUInt16(ptr: bigint, offset: number): number { + // 用 koffi.as 在 ptr+offset 地址处读取 uint16 + const addr = BigInt(Number(ptr) + offset); + const p = koffi.as(addr, 'uint16 *'); + return koffi.decode(p, 'uint16') as unknown as number; + } +} diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 23dace4..43eba49 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -42,253 +42,76 @@ describe('PowerShellBridge', () => { expect(script).toContain('ConvertTo-Json'); }); - it('应读取 MUIVerb 作为首选名称', () => { + it('应输出 rawMUIVerb / rawDefault / rawLocalizedDisplayName 原始字段', () => { const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - expect(script).toContain("GetValue('MUIVerb')"); - }); - - it('应包含 Resolve-MenuName 函数用于解析间接字符串', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - - expect(script).toContain('Resolve-MenuName'); - expect(script).toContain("match '^@'"); + expect(script).toContain('rawMUIVerb'); + expect(script).toContain('rawDefault'); + expect(script).toContain('rawLocalizedDisplayName'); }); - it('应通过 CmHelper 调用 SHLoadIndirectString(复用缓存 DLL,不再内联编译)', () => { + it('不应包含 CmHelper 或 Resolve-MenuName(名称解析已移至 TS 层)', () => { const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - expect(script).toContain('CmHelper'); - expect(script).toContain('SHLoadIndirectString'); - expect(script).not.toContain('CmShell'); - }); - - it('名称回退顺序:MUIVerb → Default → 键名', () => { - const script = bridge.buildGetItemsScript('*\\shell'); - - // MUIVerb 先被尝试 - const muiVerbIdx = script.indexOf("GetValue('MUIVerb')"); - // Default 次之 - const defaultIdx = script.indexOf("GetValue('')"); - // 键名最后 - const fallbackIdx = script.indexOf('$name = $keyName'); - - expect(muiVerbIdx).toBeGreaterThan(0); - expect(defaultIdx).toBeGreaterThan(muiVerbIdx); - expect(fallbackIdx).toBeGreaterThan(defaultIdx); + expect(script).not.toContain('CmHelper'); + expect(script).not.toContain('Resolve-MenuName'); + expect(script).not.toContain('SHLoadIndirectString'); }); it('应将正确的注册表路径嵌入脚本', () => { const script = bridge.buildGetItemsScript('*\\shell'); - // 模板字面量中 \\ 在 PS 脚本里生成单个 \,所以检查单反斜杠路径 expect(script).toContain('HKCR:\\*\\shell'); }); - it('Resolve-MenuName 不应包含热键清理 -replace(热键清理已移至 TS 层)', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - - // 提取 Resolve-MenuName 函数体,确认不含 -replace 热键正则 - const fnMatch = script.match(/function Resolve-MenuName[\s\S]*?\n\}/); - expect(fnMatch).not.toBeNull(); - const fnBody = fnMatch![0]; - expect(fnBody).not.toContain('-replace'); - }); - - it('名称检测链应包含 LocalizedDisplayName 作为第三优先级', () => { + it('不应包含热键清理逻辑(热键清理已移至 TS 层 cleanDisplayName)', () => { const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); - - expect(script).toContain("GetValue('LocalizedDisplayName')"); - // 顺序:MUIVerb → Default → LocalizedDisplayName → 键名 - const muiIdx = script.indexOf("GetValue('MUIVerb')"); - const defIdx = script.indexOf("GetValue('')"); - const localIdx = script.indexOf("GetValue('LocalizedDisplayName')"); - const fallbackIdx = script.indexOf('$name = $keyName'); - - expect(localIdx).toBeGreaterThan(defIdx); - expect(fallbackIdx).toBeGreaterThan(localIdx); + // -replace 只用于键名前缀剥离($handlerKeyName -replace '^-+'),不含加速键正则 + expect(script).not.toMatch(/\(&\\w\)|\(\\w\)|&\\w/); }); }); describe('buildGetShellExtItemsScript', () => { - it('不应包含 ReadDllStrings(已移除 DLL 字符串扫描)', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).not.toContain('ReadDllStrings'); - expect(script).not.toContain('LoadLibraryEx'); - expect(script).not.toContain('LOAD_AS_DATA'); - }); - - it('不应包含 1-500 范围的字符串表扫描', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).not.toContain('1, 500'); - expect(script).not.toContain('ReadDllStrings'); - }); - - it('仅使用 FileDescription/ProductName,不扫描 InternalName/OriginalFilename', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).not.toContain('InternalName'); - expect(script).not.toContain('OriginalFilename'); - }); - - it('Level 2.5 使用 FileVersionInfo::GetVersionInfo,不遍历所有字段', () => { + it('不应包含 CmHelper / Resolve-ExtName / Test-IsGenericName(解析已移至 TS)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('FileVersionInfo'); - expect(script).toContain('ProductName'); + 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('应将 CLSID Default 值作为 Level 2 兜底', () => { + it('不应包含 CommandStore 索引构建(已移至独立脚本)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('Level 2: CLSID 默认值'); - expect(script).not.toContain('Level 3: CLSID'); + expect(script).not.toContain('cmdStoreVerbs'); + expect(script).not.toContain('CommandStore'); }); - it('Format-DisplayName 不应包含热键清理正则(热键清理已移至 TS 层)', () => { + it('应输出 handlerKeyName / cleanName / defaultVal 原始字段', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Format-DisplayName 函数体不应包含 -replace 热键正则 - const fnMatch = script.match(/function Format-DisplayName[\s\S]*?\n\}/); - expect(fnMatch).not.toBeNull(); - const fnBody = fnMatch![0]; - expect(fnBody).not.toContain('-replace'); + expect(script).toContain('handlerKeyName'); + expect(script).toContain('cleanName'); + expect(script).toContain('defaultVal'); }); - it('CmHelper 源码中不应包含 ReadDllStrings 方法', () => { + it('应输出 CLSID 子键原始字段 (clsidLocalizedString / clsidMUIVerb / clsidDefault)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // 源码里不再有 ReadDllStrings 定义 - expect(script).not.toMatch(/public static string\[\] ReadDllStrings/); - }); - - it('"Quark AI Context Menu" 不应被泛型名过滤器误判', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // 新规则采用首锚 ^,确认 fnBody 中 context 规则已添加锚点 - const fnStart = script.indexOf('function Test-IsGenericName'); - const fnEnd = script.indexOf('function Resolve-ExtName'); - const fnBody = script.slice(fnStart, fnEnd); - expect(fnBody).toMatch(/\^.*context.*menu/i); - - // 用 JS 模拟新正则:带前缀的产品名不应被匹配 - const ctxRegex = /^(context|ctx)\s*menu(\s*(handler|ext(ension)?|provider|manager))?$/i; - expect(ctxRegex.test('quark ai context menu')).toBe(false); - expect(ctxRegex.test('context menu')).toBe(true); - expect(ctxRegex.test('context menu handler')).toBe(true); - }); - - it('"* Shell Extension" 后缀应被识别为 COM 类描述并过滤', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // 确认新增的 shell\s+extension$ 规则存在于 Test-IsGenericName 中 - const fnStart = script.indexOf('function Test-IsGenericName'); - const fnEnd = script.indexOf('function Test-IsUselessPlain'); - const fnBody = script.slice(fnStart, fnEnd); - expect(fnBody).toMatch(/shell\\s\+extension\$/); - - // 用 JS 模拟:以 "shell extension" 结尾的值应被过滤(COM 类描述,非用户可见名) - const shellExtSuffixRegex = /shell\s+extension$/i; - expect(shellExtSuffixRegex.test('vim shell extension')).toBe(true); // 过滤 → 回退到 "gvim" - expect(shellExtSuffixRegex.test('winrar shell extension')).toBe(true); // 过滤 → 回退到 "WinRAR" - expect(shellExtSuffixRegex.test('shell extension')).toBe(true); // 过滤 - - // 不误杀不以 "shell extension" 结尾的产品名 - expect(shellExtSuffixRegex.test('winrar')).toBe(false); - expect(shellExtSuffixRegex.test('quark ai context menu')).toBe(false); - expect(shellExtSuffixRegex.test('百度网盘')).toBe(false); - }); - - it('CmHelper 源码应包含 GetLocalizedVerStrings 方法', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toContain('GetLocalizedVerStrings'); - expect(script).toContain('GetFileVersionInfoSize'); - expect(script).toContain('VarFileInfo'); - }); - - it('Level 2.5 应优先使用 GetLocalizedVerStrings,并以 FileVersionInfo 作为 fallback', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const level25Start = script.indexOf('Level 2.5:'); - const level3Start = script.indexOf('Level 3: directName'); - const block = script.slice(level25Start, level3Start); - - expect(block).toContain('GetLocalizedVerStrings'); - expect(block).toContain('FileVersionInfo]::GetVersionInfo'); - // GetLocalizedVerStrings 应在 FileVersionInfo 之前(作为主路径) - expect(block.indexOf('GetLocalizedVerStrings')).toBeLessThan( - block.indexOf('FileVersionInfo]::GetVersionInfo') - ); - }); - - it('应包含 Level 1 LocalizedString/FriendlyTypeName 解析', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toContain('LocalizedString'); - expect(script).toContain('FriendlyTypeName'); - }); - - it('应包含 Level 1.5 MUIVerb 解析,位于 LocalizedString 与 CLSID Default 之间', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toContain('MUIVerb'); - expect(script).toContain('Level 1.5: MUIVerb'); - - const localizedIdx = script.indexOf('LocalizedString'); - const muiVerbIdx = script.indexOf('Level 1.5: MUIVerb'); - const level2Idx = script.indexOf('Level 2: CLSID 默认值'); - - expect(muiVerbIdx).toBeGreaterThan(localizedIdx); - expect(level2Idx).toBeGreaterThan(muiVerbIdx); - - // Level 1.5 MUIVerb 应调用 Test-IsUselessPlain 过滤(统一替换内联条件) - expect(script).toMatch(/Level 1\.5[\s\S]{0,600}Test-IsUselessPlain/); - }); - - it('Level 1.5 MUIVerb 和 Level 2 CLSID Default 应过滤泛型描述', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // Level 1.5 MUIVerb 非间接分支调用 Test-IsUselessPlain(统一替换内联条件) - const level15Start = script.indexOf('Level 1.5:'); - const level17Start = script.indexOf('Level 1.7:'); - const muiVerbBlock = script.slice(level15Start, level17Start); - expect(muiVerbBlock).toContain('Test-IsUselessPlain'); - - // Level 2 Default 调用 Test-IsUselessPlain - const level2Start = script.indexOf('Level 2:'); - const level25Start = script.indexOf('Level 2.5:'); - const level2Block = script.slice(level2Start, level25Start); - expect(level2Block).toContain('Test-IsUselessPlain'); + expect(script).toContain('clsidLocalizedString'); + expect(script).toContain('clsidMUIVerb'); + expect(script).toContain('clsidDefault'); }); it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { @@ -301,310 +124,114 @@ describe('PowerShellBridge', () => { expect(script).toContain('dllPath'); }); - it('不应包含硬编码 friendlyNames 映射表(已移除,让 SHLoadIndirectString 自动本地化)', () => { + it('应输出 siblingMUIVerb 字段', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).not.toContain('$friendlyNames'); - expect(script).not.toContain('friendlyNames.ContainsKey'); + expect(script).toContain('siblingMUIVerb'); }); - it('Resolve-ExtName 应支持可选 $directName 参数作为 Level 0', () => { + it('应包含 sibling shell 路径推导逻辑', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('$directName = $null'); - expect(script).toContain('Level 0: directName'); - // Level 0(间接格式)的位置应在 Level 1 LocalizedString 之前 - const level0Idx = script.indexOf('Level 0: directName'); - const level1Idx = script.indexOf('Level 1: LocalizedString'); - expect(level0Idx).toBeGreaterThan(0); - expect(level1Idx).toBeGreaterThan(level0Idx); - }); - - it('plain string directName 应降级到 CLSID 查询链之后(Level 3)', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toContain('Level 3: directName'); - // Level 3 注释必须在 Level 2 之后 - const level2Idx = script.indexOf('Level 2: CLSID 默认值'); - const level3Idx = script.indexOf('Level 3: directName'); - expect(level2Idx).toBeGreaterThan(0); - expect(level3Idx).toBeGreaterThan(level2Idx); - }); - - it('应预建 CommandStore 反向索引并在 Level 1.7 查找', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // 预建索引存在 - expect(script).toContain('cmdStoreVerbs'); - expect(script).toContain('CommandStore'); - expect(script).toContain('ExplorerCommandHandler'); - // Level 1.7 存在且位于 Level 1.5 与 Level 2 之间 - const level15Idx = script.indexOf('Level 1.5:'); - const level17Idx = script.indexOf('Level 1.7:'); - const level2Idx = script.indexOf('Level 2: CLSID 默认值'); - expect(level17Idx).toBeGreaterThan(level15Idx); - expect(level2Idx).toBeGreaterThan(level17Idx); - }); - - it('CmHelper.ResolveIndirect 应支持 ms-resource: 前缀', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toContain('ms-resource:'); - expect(script).toContain('!s.StartsWith("ms-resource:")'); - }); - - it('LocalizedString 和 MUIVerb 应同时检查 @ 和 ms-resource: 前缀', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - expect(script).toMatch(/StartsWith\('@'\)\s*-or\s*\$\w+\.StartsWith\('ms-resource:'\)/); + expect(script).toContain('$shellPath'); + expect(script).toContain('$siblingVerbPath'); + expect(script).toContain('ContextMenuHandlers$'); }); - it('ForEach 循环应使用 $actualClsid 分离 CLSID 与 Default 值', () => { + it('ForEach 循环应使用 $actualClsid 和 $defaultVal 分离 CLSID', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); expect(script).toContain('$actualClsid'); - expect(script).toContain('$defaultVal'); - // command 字段应使用 $actualClsid,而非旧的 $clsid - expect(script).toContain('command = [string]$actualClsid'); - }); - - it('应包含 Test-IsGenericName 函数定义,位于 Resolve-ExtName 之前', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const testFnIdx = script.indexOf('function Test-IsGenericName'); - const resolveFnIdx = script.indexOf('function Resolve-ExtName'); - expect(testFnIdx).toBeGreaterThan(0); - expect(resolveFnIdx).toBeGreaterThan(testFnIdx); - }); - - it('应包含 Test-IsUselessPlain 函数,位于 Test-IsGenericName 之后、Resolve-ExtName 之前', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const genericFnIdx = script.indexOf('function Test-IsGenericName'); - const uselessFnIdx = script.indexOf('function Test-IsUselessPlain'); - const resolveFnIdx = script.indexOf('function Resolve-ExtName'); - expect(uselessFnIdx).toBeGreaterThan(genericFnIdx); - expect(resolveFnIdx).toBeGreaterThan(uselessFnIdx); - }); - - it('Test-IsUselessPlain 应使用 -ieq 判断值等于键名(而非 -ine)', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const fnStart = script.indexOf('function Test-IsUselessPlain'); - const fnEnd = script.indexOf('function Resolve-ExtName'); - const fnBody = script.slice(fnStart, fnEnd); - expect(fnBody).toContain('-ieq $fallback'); - expect(fnBody).toContain('Test-IsGenericName'); - }); - - it('Test-IsGenericName 应包含首锚 context/ctx 规则、class 后缀和占位符过滤模式', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const fnStart = script.indexOf('function Test-IsGenericName'); - const fnEnd = script.indexOf('function Resolve-ExtName'); - const fnBody = script.slice(fnStart, fnEnd); - // 新规则采用首锚 ^,context/ctx 规则中含首锚和 context|ctx 分组 - expect(fnBody).toMatch(/\^.*context.*ctx.*menu/); // Case 1: 首锚 context/ctx 规则 - expect(fnBody).toContain("\\s+class$"); // Case 2: PcyybContextnMenu Class - expect(fnBody).toContain("^todo:"); // Case 3: TODO: - expect(fnBody).toContain("<[^>]+>"); // Case 3: - }); - - it('Level 2.5 应调用 Test-IsGenericName 且保留长度上限 -le 64', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - const level25Start = script.indexOf('Level 2.5:'); - const level3Start = script.indexOf('Level 3: directName'); - const block = script.slice(level25Start, level3Start); - expect(block).toContain('Test-IsGenericName'); - expect(block).toContain('-le 64'); }); - it('InprocServer32 DLL FileDescription/ProductName 应作为 Level 2.5', () => { + it('不应包含硬编码 friendlyNames 映射表', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Level 2.5 注释存在 - expect(script).toContain('Level 2.5:'); - // 使用 FileVersionInfo::GetVersionInfo - expect(script).toContain('FileVersionInfo]::GetVersionInfo'); - // 包含过滤关键词 - expect(script).toMatch(/shell.*extension/i); - expect(script).toMatch(/context.*menu/i); - // Level 2.5 位于 Level 2 之后、Level 3 之前 - const level2Idx = script.indexOf('Level 2: CLSID 默认值'); - const level25Idx = script.indexOf('Level 2.5:'); - const level3Idx = script.indexOf('Level 3: directName'); - expect(level25Idx).toBeGreaterThan(level2Idx); - expect(level3Idx).toBeGreaterThan(level25Idx); - }); - - it('Level 1 plain string 等于 fallback(键名)时应跳过,让 Level 2.5 执行', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // Level 1 plain string 分支应调用 Test-IsUselessPlain(已统一替换内联条件) - const level1Start = script.indexOf('Level 1: LocalizedString'); - const level15Start = script.indexOf('Level 1.5: MUIVerb'); - const level1Block = script.slice(level1Start, level15Start); - expect(level1Block).toContain('Test-IsUselessPlain'); + expect(script).not.toContain('$friendlyNames'); + expect(script).not.toContain('friendlyNames.ContainsKey'); }); - it('Level 1.5 MUIVerb plain string 等于 fallback 时应跳过', () => { + it('不应包含热键清理逻辑(已移至 TS 层)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Level 1.5 plain string 分支应调用 Test-IsUselessPlain - const level15Start = script.indexOf('Level 1.5: MUIVerb'); - const level17Start = script.indexOf('Level 1.7:'); - const level15Block = script.slice(level15Start, level17Start); - expect(level15Block).toContain('Test-IsUselessPlain'); + // -replace 只用于键名前缀剥离,不含加速键正则 + expect(script).not.toMatch(/\(&\\w\)|\(\\w\)|&\\w/); }); - it('Level 2 CLSID Default 等于 fallback 时应跳过', () => { + it('不应包含 ReadDllStrings(已移除)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Level 2 Default 检查分支应调用 Test-IsUselessPlain - const level2Start = script.indexOf('Level 2: CLSID 默认值'); - const level25Start = script.indexOf('Level 2.5:'); - const level2Block = script.slice(level2Start, level25Start); - expect(level2Block).toContain('Test-IsUselessPlain'); + expect(script).not.toContain('ReadDllStrings'); + expect(script).not.toContain('LoadLibraryEx'); }); - it('Level 3 directName 等于 fallback 时应跳过(修复漏洞)', () => { + it('不应包含 CmHelper.Ver 版本校验', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // Level 3 plain string 分支应调用 Test-IsUselessPlain(修复前只有 Test-IsGenericName) - const level3Start = script.indexOf('Level 3: directName'); - const returnFallback = script.indexOf('return $fallback', level3Start); - const level3Block = script.slice(level3Start, returnFallback); - expect(level3Block).toContain('Test-IsUselessPlain'); + expect(script).not.toContain("[CmHelper]::Ver"); }); - it('CmHelper 源码应包含 Ver 版本常量 "2026.3"', () => { + it('不应包含 Level 级别注释(Level 逻辑已移至 TS)', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - // C# $src 中应包含 Ver 字段 - expect(script).toContain('public static readonly string Ver = "2026.3"'); + 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('应包含 Level 1.3 Sibling Shell Key MUIVerb,位于 Level 1 与 Level 1.5 之间', () => { + it('不应包含 C# 源码 Add-Type 编译', () => { const script = bridge.buildGetShellExtItemsScript( 'DesktopBackground\\shellex\\ContextMenuHandlers' ); - expect(script).toContain('Level 1.3:'); - expect(script).toContain('$shellPath'); - expect(script).toContain('$siblingVerbPath'); - expect(script).toContain('$siblingMUI'); - - const level1Idx = script.indexOf('Level 1: LocalizedString'); - const level13Idx = script.indexOf('Level 1.3:'); - const level15Idx = script.indexOf('Level 1.5: MUIVerb'); - expect(level13Idx).toBeGreaterThan(level1Idx); - expect(level15Idx).toBeGreaterThan(level13Idx); + expect(script).not.toContain('using System;'); + expect(script).not.toContain('Add-Type -TypeDefinition'); }); + }); - it('$shellPath 应在 ForEach 循环前由 $shellexPath 推导', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); - - // $shellPath 赋值语句应在 $handlers = Get-ChildItem 之前 - const shellPathIdx = script.indexOf('$shellPath = $null'); - const handlersIdx = script.indexOf('$handlers = Get-ChildItem'); - expect(shellPathIdx).toBeGreaterThan(0); - expect(handlersIdx).toBeGreaterThan(shellPathIdx); + describe('buildCommandStoreScript', () => { + it('应包含 CommandStore\\shell 路径', () => { + const script = bridge.buildCommandStoreScript(); - // 包含 ContextMenuHandlers 结尾检测 - expect(script).toContain('ContextMenuHandlers$'); + expect(script).toContain('CommandStore\\shell'); }); - it('Test-IsGenericName 应包含 Group D 冠词开头和括号包裹规则', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); + it('应读取 ExplorerCommandHandler 和 MUIVerb', () => { + const script = bridge.buildCommandStoreScript(); - const fnStart = script.indexOf('function Test-IsGenericName'); - const fnEnd = script.indexOf('function Test-IsUselessPlain'); - const fnBody = script.slice(fnStart, fnEnd); - - // Group D 注释和规则应存在 - expect(fnBody).toContain('Group D'); - expect(fnBody).toContain('a|an|the'); // 冠词规则 - expect(fnBody).toContain("'^\\(.+\\)$'"); // 括号规则(PS 单引号内 \( 匹配字面量 () + expect(script).toContain('ExplorerCommandHandler'); + expect(script).toContain("GetValue('MUIVerb')"); }); - it('Test-IsGenericName Group D JS 模拟:冠词开头句子应被过滤', () => { - // 模拟 PS 正则 '^(a|an|the)\s+' - const articleRegex = /^(a|an|the)\s+/i; - expect(articleRegex.test('a small project for the context menu of gvim!')).toBe(true); - expect(articleRegex.test('an extension handler')).toBe(true); - expect(articleRegex.test('the shell service')).toBe(true); - // 不误杀普通产品名 - expect(articleRegex.test('Adobe Acrobat')).toBe(false); - expect(articleRegex.test('百度网盘')).toBe(false); - expect(articleRegex.test('7-Zip (64-bit)')).toBe(false); - }); + it('应输出 clsid 和 muiverb 字段', () => { + const script = bridge.buildCommandStoreScript(); - it('Test-IsGenericName Group D JS 模拟:括号完全包裹的字符串应被过滤', () => { - // 模拟 PS 正则 '^\(.+\)$' - const parenRegex = /^\(.+\)$/; - expect(parenRegex.test('(调试)')).toBe(true); - expect(parenRegex.test('(Debug)')).toBe(true); - expect(parenRegex.test('(unknown)')).toBe(true); - // 不误杀括号在中间或两端不完整的情况 - expect(parenRegex.test('7-Zip (64-bit)')).toBe(false); - expect(parenRegex.test('Adobe Acrobat')).toBe(false); - expect(parenRegex.test('百度网盘')).toBe(false); + expect(script).toContain('clsid'); + expect(script).toContain('muiverb'); }); - it('加载 DLL 后应立即校验 CmHelper.Ver,版本不匹配时重置 $helperLoaded', () => { - const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' - ); + it('不应包含 CmHelper 或 SHLoadIndirectString', () => { + const script = bridge.buildCommandStoreScript(); - // 版本校验块:读取 [CmHelper]::Ver 并与 '2026.3' 比较 - expect(script).toContain("[CmHelper]::Ver -ne '2026.3'"); - // 版本校验块位于 DLL 加载块之后、$src 编译块之前 - const dllLoadIdx = script.indexOf('Add-Type -Path $cmDll'); - const verCheckIdx = script.indexOf("[CmHelper]::Ver -ne '2026.3'"); - const compileSrcIdx = script.indexOf('Add-Type -TypeDefinition $src'); - expect(verCheckIdx).toBeGreaterThan(dllLoadIdx); - expect(compileSrcIdx).toBeGreaterThan(verCheckIdx); + expect(script).not.toContain('CmHelper'); + expect(script).not.toContain('SHLoadIndirectString'); }); }); diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 0a40e56..2baca91 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -1,67 +1,106 @@ 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), + getFileVersionInfo: vi.fn().mockReturnValue(null), + }; + 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, - source: 'TestApp', - menuScene: MenuScene.File, - type: MenuItemType.Custom, - }); + 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('名称净化(热键清理)', () => { + describe('名称净化(热键清理 cleanDisplayName)', () => { it('应清理带括号加速键整体,不留残余括号', async () => { const cases = [ { input: '使用 Visual Studio 打开(&V)', expected: '使用 Visual Studio 打开' }, @@ -70,19 +109,23 @@ describe('RegistryService', () => { ]; 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 = [{ - name: input, - command: '', - iconPath: null, + subKeyName: 'TestItem', + rawMUIVerb: input, + rawDefault: null, + rawLocalizedDisplayName: null, + rawIcon: null, isEnabled: true, - source: '', + command: '', registryKey: 'DesktopBackground\\Shell\\TestItem', - subKeyName: 'TestItem', }]; - // 每次调用需要新的 service 实例(避免缓存) - const freshService = new RegistryService(mockPs); - mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + freshPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); const result = await freshService.getMenuItems(MenuScene.Desktop); @@ -93,34 +136,17 @@ describe('RegistryService', () => { }); }); - describe('名称净化(@ 间接字符串兜底)', () => { - it('以 @ 开头的名称应被 subKeyName 替换', async () => { + describe('resolver passes through to cleanDisplayName', () => { + it('正常名称应通过 resolver 并正确显示', async () => { const rawItems = [{ - name: '@%SystemRoot%\\system32\\shell32.dll,-1234', - command: '', - iconPath: null, - isEnabled: true, - source: '', - registryKey: 'DesktopBackground\\Shell\\TestItem', subKeyName: 'TestItem', - }]; - - mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); - - const result = await service.getMenuItems(MenuScene.Desktop); - - expect(result[0].name).toBe('TestItem'); - }); - - it('正常名称不应被替换', async () => { - const rawItems = [{ - name: '在桌面上显示', - command: '', - iconPath: null, + rawMUIVerb: null, + rawDefault: '在桌面上显示', + rawLocalizedDisplayName: null, + rawIcon: null, isEnabled: true, - source: '', + command: '', registryKey: 'DesktopBackground\\Shell\\TestItem', - subKeyName: 'TestItem', }]; mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); @@ -130,34 +156,19 @@ describe('RegistryService', () => { expect(result[0].name).toBe('在桌面上显示'); }); - it('subKeyName 为空时 @ 名称应保留原值', async () => { - const rawItems = [{ - name: '@unresolved', - command: '', - iconPath: null, - isEnabled: true, - source: '', - registryKey: 'DesktopBackground\\Shell\\', - subKeyName: '', - }]; - - mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); - - const result = await service.getMenuItems(MenuScene.Desktop); - - expect(result[0].name).toBe('@unresolved'); - }); - - it('ShellExt 条目的 @ 名称也应净化为 subKeyName', async () => { + it('ShellExt 条目名称应由 resolver 决定', async () => { const shellextItems = [{ - name: '@%SystemRoot%\\system32\\shell32.dll,-9999', - command: '{645FF040-5081-101B-9F08-00AA002F954E}', - iconPath: null, + handlerKeyName: 'DesktopSlideshow', + cleanName: 'DesktopSlideshow', + defaultVal: '@windows.immersivecontrolpanel.dll,-1', isEnabled: true, - source: 'DesktopSlideshow', + actualClsid: '{2CC2D03E-B04A-43BE-A6BE-8C20E6A64F87}', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: null, + siblingMUIVerb: null, registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\DesktopSlideshow', - subKeyName: 'DesktopSlideshow', - itemType: 'ShellExt', }]; mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); @@ -167,43 +178,22 @@ describe('RegistryService', () => { expect(result).toHaveLength(1); expect(result[0].name).toBe('DesktopSlideshow'); }); - - it('模拟 DesktopSlideshow 问题场景:错误名称应被键名替换', async () => { - // 模拟 Level 3 DLL 扫描可能返回的错误名称(现已移除该逻辑) - // RegistryService 的 @ 兜底层作为最终保险 - const shellextItems = [{ - name: '@windows.immersivecontrolpanel.dll,-1', - command: '{2CC2D03E-B04A-43BE-A6BE-8C20E6A64F87}', - iconPath: null, - isEnabled: true, - source: 'DesktopSlideshow', - registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\DesktopSlideshow', - subKeyName: 'DesktopSlideshow', - itemType: 'ShellExt', - }]; - - mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); - - const result = await service.getMenuItems(MenuScene.Desktop); - - expect(result[0].name).toBe('DesktopSlideshow'); - expect(result[0].name).not.toContain('@'); - expect(result[0].name).not.toContain('电池'); - }); }); describe('dllPath 字段透传', () => { it('ShellExt 条目应将 dllPath 传入 MenuItemEntry', async () => { const shellextItems = [{ - name: 'gvim Shell Extension', - command: '{51EEE242-AD87-11d3-9C1E-0090278BBD99}', - iconPath: null, + handlerKeyName: 'gvim', + cleanName: 'gvim', + defaultVal: '', isEnabled: true, - source: 'gvim', - registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\gvim', - subKeyName: 'gvim', - itemType: 'ShellExt', + 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); @@ -214,35 +204,16 @@ describe('RegistryService', () => { expect(result[0].dllPath).toBe('C:\\Program Files\\Vim\\vim91\\gvimext.dll'); }); - it('无 DLL 路径的 ShellExt 条目 dllPath 应为 null', async () => { - const shellextItems = [{ - name: 'SomeExt', - command: '{12345678-1234-1234-1234-123456789ABC}', - iconPath: null, - isEnabled: true, - source: 'SomeExt', - registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\SomeExt', - subKeyName: 'SomeExt', - itemType: 'ShellExt', - dllPath: null, - }]; - - mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); - - const result = await service.getMenuItems(MenuScene.Desktop); - - expect(result[0].dllPath).toBeNull(); - }); - it('Classic Shell 条目 dllPath 应为 null', async () => { const rawItems = [{ - name: 'Classic Item', - command: 'cmd.exe', - iconPath: null, + subKeyName: 'Classic', + rawMUIVerb: null, + rawDefault: 'Classic Item', + rawLocalizedDisplayName: null, + rawIcon: null, isEnabled: true, - source: '', + command: 'cmd.exe', registryKey: 'DesktopBackground\\Shell\\Classic', - subKeyName: 'Classic', }]; mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); @@ -259,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..cf9de68 --- /dev/null +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -0,0 +1,419 @@ +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(): IWin32Shell { + return { + resolveIndirect: vi.fn().mockReturnValue(null), + getFileVersionInfo: vi.fn().mockReturnValue(null), + }; +} + +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, + 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', () => { + vi.mocked(win32.getFileVersionInfo).mockReturnValue('阿里云盘'); + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllPath: 'C:\\Program Files\\YunShellExt\\YunShellExt64.dll', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('阿里云盘'); + }); + + it('Level 2.5: 应过滤泛型 DLL 描述(如 "Vim Shell Extension")', () => { + vi.mocked(win32.getFileVersionInfo).mockReturnValue('Vim Shell Extension'); + const item = createShellExtItem({ + dllPath: 'C:\\Vim\\gvimext.dll', + }); + 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'); + }); + }); + + // ---- 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'], }, }, }); From 55bd431e3848df57e30e2e94838a2e6470e01c3b Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 03:46:01 +0800 Subject: [PATCH 15/31] =?UTF-8?q?fix(shellex):=20=E4=BF=AE=E5=A4=8D=20reso?= =?UTF-8?q?lveIndirect=20FFI=20=E7=B1=BB=E5=9E=8B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=B8=8E=20DLL=20=E8=AF=AD=E8=A8=80=E4=BC=98=E5=85=88=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveIndirect 输出缓冲区类型从 'char16 *' 改为 'void *', 修复 koffi 无法正确传递 Buffer 地址导致 SHLoadIndirectString 失败 - getFileVersionInfo 新增 GetUserDefaultUILanguage 获取系统 UI 语言, Translation 表匹配项插入队首(修复始终返回英文名的问题) - readUInt16 指针运算改用 bigint 直接偏移(修复 64 位地址精度丢失) - ShellExtNameResolver 各 Level 增加 debug 日志,便于追踪解析路径 Co-Authored-By: Claude Opus 4.7 --- src/main/services/ShellExtNameResolver.ts | 58 ++++++++--- src/main/services/Win32Shell.ts | 118 +++++++++++++--------- 2 files changed, 114 insertions(+), 62 deletions(-) diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index 0ed6ccd..b5d26d3 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -121,7 +121,10 @@ export class ShellExtNameResolver { if (raw.defaultVal && (raw.defaultVal.startsWith('@') || raw.defaultVal.startsWith('ms-resource:'))) { try { const resolved = this.win32.resolveIndirect(raw.defaultVal); - if (resolved && resolved.length >= 2) return resolved; + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 0 (directName indirect): "${resolved}"`); + return resolved; + } } catch { /* fall through */ } } @@ -132,10 +135,16 @@ export class ShellExtNameResolver { if (raw.clsidLocalizedString.startsWith('@') || raw.clsidLocalizedString.startsWith('ms-resource:')) { try { const resolved = this.win32.resolveIndirect(raw.clsidLocalizedString); - if (resolved && resolved.length >= 2) return resolved; + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1 (LocalizedString indirect): "${resolved}"`); + return resolved; + } } catch { /* fall through */ } } else if (raw.clsidLocalizedString.length >= 2) { - if (!isUselessPlain(raw.clsidLocalizedString, fallback)) return raw.clsidLocalizedString; + if (!isUselessPlain(raw.clsidLocalizedString, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1 (LocalizedString plain): "${raw.clsidLocalizedString}"`); + return raw.clsidLocalizedString; + } } } @@ -144,10 +153,16 @@ export class ShellExtNameResolver { if (raw.siblingMUIVerb.startsWith('@') || raw.siblingMUIVerb.startsWith('ms-resource:')) { try { const resolved = this.win32.resolveIndirect(raw.siblingMUIVerb); - if (resolved && resolved.length >= 2) return resolved; + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1.3 (sibling MUIVerb indirect): "${resolved}"`); + return resolved; + } } catch { /* fall through */ } } else if (raw.siblingMUIVerb.length >= 2) { - if (!isUselessPlain(raw.siblingMUIVerb, fallback)) return raw.siblingMUIVerb; + if (!isUselessPlain(raw.siblingMUIVerb, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1.3 (sibling MUIVerb): "${raw.siblingMUIVerb}"`); + return raw.siblingMUIVerb; + } } } @@ -156,20 +171,32 @@ export class ShellExtNameResolver { if (raw.clsidMUIVerb.startsWith('@') || raw.clsidMUIVerb.startsWith('ms-resource:')) { try { const resolved = this.win32.resolveIndirect(raw.clsidMUIVerb); - if (resolved && resolved.length >= 2) return resolved; + if (resolved && resolved.length >= 2) { + log.debug(`[NameResolver] ${fallback} → Level 1.5 (MUIVerb indirect): "${resolved}"`); + return resolved; + } } catch { /* fall through */ } } else if (raw.clsidMUIVerb.length >= 2) { - if (!isUselessPlain(raw.clsidMUIVerb, fallback)) return raw.clsidMUIVerb; + if (!isUselessPlain(raw.clsidMUIVerb, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 1.5 (MUIVerb): "${raw.clsidMUIVerb}"`); + return raw.clsidMUIVerb; + } } } // Level 1.7: CommandStore 反向索引 const cmdVerb = cmdStore.get(raw.actualClsid); - if (cmdVerb) return cmdVerb; + if (cmdVerb) { + log.debug(`[NameResolver] ${fallback} → Level 1.7 (CommandStore): "${cmdVerb}"`); + return cmdVerb; + } // Level 2: CLSID 默认值 if (raw.clsidDefault && raw.clsidDefault.length >= 2) { - if (!isUselessPlain(raw.clsidDefault, fallback)) return raw.clsidDefault; + if (!isUselessPlain(raw.clsidDefault, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default): "${raw.clsidDefault}"`); + return raw.clsidDefault; + } } } @@ -177,7 +204,11 @@ export class ShellExtNameResolver { if (raw.dllPath) { const dllName = this.win32.getFileVersionInfo(raw.dllPath); if (dllName && dllName.length >= 2 && dllName.length <= 64) { - if (!isGenericName(dllName)) return dllName; + 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`); } } @@ -185,10 +216,13 @@ export class ShellExtNameResolver { if (raw.defaultVal && !raw.defaultVal.startsWith('@') && !raw.defaultVal.startsWith('ms-resource:')) { - if (!isUselessPlain(raw.defaultVal, fallback)) return raw.defaultVal; + if (!isUselessPlain(raw.defaultVal, fallback)) { + log.debug(`[NameResolver] ${fallback} → Level 3 (directName plain): "${raw.defaultVal}"`); + return raw.defaultVal; + } } - // Fallback: 键名 + log.debug(`[NameResolver] ${fallback} → Fallback (key name)`); return fallback; } } diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index c8d6948..40304d4 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -6,43 +6,40 @@ export interface IWin32Shell { getFileVersionInfo(dllPath: string): string | null; } -// koffi out-param types — 返回数组 [ret, out1, out2, ...] -type Out2 = [A, B]; -type Out3 = [A, B, C]; - export class Win32Shell implements IWin32Shell { // eslint-disable-next-line @typescript-eslint/no-explicit-any private resolveIndirectFn: (...args: any[]) => number; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getFileVersionInfoSizeW: (...args: any[]) => Out2; + private getFileVersionInfoSizeW: (...args: any[]) => [number, number]; // eslint-disable-next-line @typescript-eslint/no-explicit-any private getFileVersionInfoW: (...args: any[]) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private verQueryValueW: (...args: any[]) => Out3; + private verQueryValueW: (...args: any[]) => [boolean, bigint | null, number]; - // 缓存已解析的字符串,避免重复 FFI 调用 private indirectCache = new Map(); private versionCache = new Map(); + // 用户 UI 语言 LCID,用于 DLL 版本信息优先匹配 + private readonly uiLangId: number; + constructor() { const shlwapi = koffi.load('shlwapi.dll'); const version = koffi.load('version.dll'); // HRESULT SHLoadIndirectString(PCWSTR pszSource, PWSTR pszOutBuf, UINT cchOutBuf, void **ppvReserved) + // pszOutBuf 是 caller-provided writable buffer → 用 'void *' 让 koffi 正确传递 Buffer 地址 this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ - 'str16', - 'char16 *', - 'uint32', - 'void *', + 'str16', // PCWSTR pszSource + 'void *', // PWSTR pszOutBuf (fix: 原 'char16 *' 可能导致 Buffer 传递异常) + 'uint32', // UINT cchOutBuf + 'void *', // void **ppvReserved (always NULL) ]); - // DWORD GetFileVersionInfoSizeW(LPCWSTR lptstrFilename, LPDWORD lpdwHandle) this.getFileVersionInfoSizeW = version.func('__stdcall', 'GetFileVersionInfoSizeW', 'uint32', [ 'str16', koffi.out('uint32 *'), ]); - // BOOL GetFileVersionInfoW(LPCWSTR lptstrFilename, DWORD dwHandle, DWORD dwLen, LPVOID lpData) this.getFileVersionInfoW = version.func('__stdcall', 'GetFileVersionInfoW', 'bool', [ 'str16', 'uint32', @@ -50,13 +47,25 @@ export class Win32Shell implements IWin32Shell { 'void *', ]); - // BOOL VerQueryValueW(LPCVOID pBlock, LPCWSTR lpSubBlock, LPVOID *lplpBuffer, PUINT puLen) this.verQueryValueW = version.func('__stdcall', 'VerQueryValueW', 'bool', [ 'void *', 'str16', koffi.out(koffi.pointer('void *')), koffi.out('uint32 *'), ]); + + // 获取当前线程 UI 语言 LCID 的主语言 ID + // 中文(简体) = 0x0804 → langId = 0x04, 英语(美国) = 0x0409 → langId = 0x09 + try { + const buf = Buffer.alloc(4); + // GetUserDefaultUILanguage() returns LANGID (16-bit) + const kernel32 = koffi.load('kernel32.dll'); + const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); + this.uiLangId = getLangId() & 0xFF; // 主语言 ID + log.debug(`[Win32Shell] UI language ID: 0x${this.uiLangId.toString(16).padStart(2, '0')}`); + } catch { + this.uiLangId = 0x09; // fallback: English + } } resolveIndirect(source: string): string | null { @@ -71,13 +80,15 @@ export class Win32Shell implements IWin32Shell { const hr = this.resolveIndirectFn(source, buf, 512, 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 with HRESULT 0x${(hr >>> 0).toString(16)} for "${source.substring(0, 50)}..."`); this.indirectCache.set(source, null); return null; } catch (e) { - log.warn('[Win32Shell] SHLoadIndirectString failed:', String(e)); + log.warn('[Win32Shell] SHLoadIndirectString exception:', String(e)); this.indirectCache.set(source, null); return null; } @@ -88,67 +99,70 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - // Step 1: Get buffer size - const result = this.getFileVersionInfoSizeW(dllPath, [0]); - const size = Array.isArray(result) ? result[0] : (result as unknown as number); + const [size] = this.getFileVersionInfoSizeW(dllPath, [0]); if (!size || size === 0) { this.versionCache.set(dllPath, null); return null; } - // Step 2: Read version info - const data = Buffer.alloc(size as number); - if (!this.getFileVersionInfoW(dllPath, 0, size as number, data)) { + const data = Buffer.alloc(size); + if (!this.getFileVersionInfoW(dllPath, 0, size, data)) { + log.debug(`[Win32Shell] GetFileVersionInfoW failed for "${dllPath}"`); this.versionCache.set(dllPath, null); return null; } - // Step 3: Query translation table - const vtResult = this.verQueryValueW(data, '\\VarFileInfo\\Translation'); - const transPtr = Array.isArray(vtResult) ? vtResult[1] : null; - const transLen = Array.isArray(vtResult) ? vtResult[2] : 0; - if (!transPtr || (transLen as number) < 4) { + // Query translation table + const [, transPtr, transLen] = this.verQueryValueW(data, '\\VarFileInfo\\Translation'); + if (!transPtr || transLen < 4) { + log.debug(`[Win32Shell] No Translation table in "${dllPath}"`); this.versionCache.set(dllPath, null); return null; } - // Read language/codepage pairs + // 读取语言/代码页对,UI 语言优先(修复:原版本按文件顺序遍历,始终返回英文) const langKeys: string[] = []; - const numPairs = (transLen as number) / 4; + const uiLangPrefixed: string[] = []; + const numPairs = transLen / 4; for (let i = 0; i < numPairs; i++) { const offset = i * 4; - const lang = this.readUInt16(transPtr as bigint, offset); - const cp = this.readUInt16(transPtr as bigint, offset + 2); - langKeys.push( - `${lang.toString(16).padStart(4, '0').toUpperCase()}${cp.toString(16).padStart(4, '0').toUpperCase()}` - ); + const lang = this.readUInt16(transPtr, offset); + const cp = this.readUInt16(transPtr, offset + 2); + const key = `${lang.toString(16).padStart(4, '0').toUpperCase()}${cp.toString(16).padStart(4, '0').toUpperCase()}`; + // UI 语言匹配的插入队首 + if ((lang & 0xFF) === this.uiLangId) { + uiLangPrefixed.push(key); + } else { + langKeys.push(key); + } } - - // Step 4: Query FileDescription / ProductName for each language - for (const langKey of langKeys) { - const descResult = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\FileDescription`); - const descPtr = Array.isArray(descResult) ? descResult[1] : null; - const descLen = Array.isArray(descResult) ? descResult[2] : 0; - if (descPtr && (descLen as number) > 0) { - const desc = koffi.decode(descPtr as bigint, 'str16'); + const orderedKeys = [...uiLangPrefixed, ...langKeys]; + log.debug(`[Win32Shell] "${dllPath}" languages: ${orderedKeys.join(', ')} (UI lang 0x${this.uiLangId.toString(16)})`); + + // Query FileDescription / ProductName for each language (UI language first) + for (const langKey of orderedKeys) { + const [, descPtr, descLen] = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\FileDescription`); + if (descPtr && descLen > 0) { + const desc = koffi.decode(descPtr, 'str16'); if (desc && desc.length >= 2 && desc.length <= 64) { + log.debug(`[Win32Shell] FileDescription for "${dllPath}" [${langKey}]: "${desc}"`); this.versionCache.set(dllPath, desc); return desc; } } - const prodResult = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\ProductName`); - const prodPtr = Array.isArray(prodResult) ? prodResult[1] : null; - const prodLen = Array.isArray(prodResult) ? prodResult[2] : 0; - if (prodPtr && (prodLen as number) > 0) { - const prod = koffi.decode(prodPtr as bigint, 'str16'); + const [, prodPtr, prodLen] = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\ProductName`); + if (prodPtr && prodLen > 0) { + const prod = koffi.decode(prodPtr, 'str16'); if (prod && prod.length >= 2 && prod.length <= 64) { + log.debug(`[Win32Shell] ProductName for "${dllPath}" [${langKey}]: "${prod}"`); this.versionCache.set(dllPath, prod); return prod; } } } + log.debug(`[Win32Shell] No suitable version string found in "${dllPath}"`); this.versionCache.set(dllPath, null); return null; } catch (e) { @@ -158,10 +172,14 @@ export class Win32Shell implements IWin32Shell { } } + /** + * 从 koffi 指针地址读取 uint16 值 + * ptr: koffi out 参数返回的 bigint 地址 + * offset: 字节偏移量 + */ private readUInt16(ptr: bigint, offset: number): number { - // 用 koffi.as 在 ptr+offset 地址处读取 uint16 - const addr = BigInt(Number(ptr) + offset); - const p = koffi.as(addr, 'uint16 *'); - return koffi.decode(p, 'uint16') as unknown as number; + // koffi.as 将 bigint 地址解释为指定类型的指针 + const typed = koffi.as(ptr + BigInt(offset), 'uint16 *'); + return koffi.decode(typed, 'uint16') as unknown as number; } } From 789280c1d83b4be321bb5f6488dc6afa81636bbb Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:02:29 +0800 Subject: [PATCH 16/31] =?UTF-8?q?fix(shellex):=20resolveIndirect=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20koffi.alloc=20+=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=A0=87=E5=87=86=E8=B0=93=E8=AF=8D=E7=BF=BB=E8=AF=91=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveIndirect 输出缓冲区从 Buffer.alloc 改为 koffi.alloc('char16') + koffi.decode,修复 Node.js Buffer 与 koffi 类型系统不兼容导致 SHLoadIndirectString 运行时失败 - Win32Shell 暴露 uiLanguage 属性(GetUserDefaultUILanguage) - 新增 35 个标准 shell 动词翻译表(open→打开, edit→编辑, runas→以 管理员身份运行 等),作为 Classic/ShellExt 两路径的最后回退 - ShellExtNameResolver 构造函数接受 language 参数 Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 2 +- src/main/services/ShellExtNameResolver.ts | 70 ++++++++++++++++++- src/main/services/Win32Shell.ts | 16 ++++- .../main/services/RegistryService.test.ts | 1 + .../services/ShellExtNameResolver.test.ts | 60 +++++++++++++++- 5 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index a1e889b..32467dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,7 +62,7 @@ function initServices(): MenuManagerService { const db = getDatabase(); const ps = new PowerShellBridge(); const win32Shell = new Win32Shell(); - const resolver = new ShellExtNameResolver(win32Shell); + const resolver = new ShellExtNameResolver(win32Shell, win32Shell.uiLanguage); const cmdStoreIndex = new CommandStoreIndex(); const registry = new RegistryService(ps, resolver, cmdStoreIndex); const opRepo = new OperationRecordRepo(db); diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index b5d26d3..5196ccb 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -1,6 +1,47 @@ 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 { @@ -87,10 +128,23 @@ export class CommandStoreIndex { } } +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 { - constructor(private readonly win32: IWin32Shell) {} + private readonly language: 'zh' | 'en'; + + constructor(private readonly win32: IWin32Shell, language: 'zh' | 'en' = 'zh') { + this.language = language; + } /** Classic Shell 条目名称解析 */ resolveClassicName(raw: PsRawClassicItem): string { @@ -110,6 +164,13 @@ export class ShellExtNameResolver { } } + // 标准谓词翻译:open → 打开, edit → 编辑, ... + const translated = translateStandardVerb(raw.subKeyName, this.language); + if (translated) { + log.debug(`[NameResolver] Standard verb "${raw.subKeyName}" → "${translated}"`); + return translated; + } + return raw.subKeyName; } @@ -222,6 +283,13 @@ export class ShellExtNameResolver { } } + // 标准谓词翻译:对 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 index 40304d4..d006d34 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -4,6 +4,7 @@ import log from '../utils/logger'; export interface IWin32Shell { resolveIndirect(source: string): string | null; getFileVersionInfo(dllPath: string): string | null; + readonly uiLanguage: 'zh' | 'en'; } export class Win32Shell implements IWin32Shell { @@ -19,9 +20,15 @@ export class Win32Shell implements IWin32Shell { private indirectCache = new Map(); private versionCache = new Map(); - // 用户 UI 语言 LCID,用于 DLL 版本信息优先匹配 + // 用户 UI 语言 LCID(主语言 ID),用于 DLL 版本信息优先匹配 private readonly uiLangId: number; + /** 暴露给 ShellExtNameResolver 用于标准谓词翻译 */ + get uiLanguage(): 'zh' | 'en' { + // 中文主语言 ID = 0x04 (简体/繁体均适用) + return this.uiLangId === 0x04 ? 'zh' : 'en'; + } + constructor() { const shlwapi = koffi.load('shlwapi.dll'); const version = koffi.load('version.dll'); @@ -76,14 +83,17 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - const buf = Buffer.alloc(1024); + // 使用 koffi.alloc 分配输出缓冲区(Node.js Buffer.alloc 与 koffi 类型系统不兼容) + const buf = koffi.alloc('char16', 512); const hr = this.resolveIndirectFn(source, buf, 512, null); if (hr === 0) { - const result = buf.toString('utf16le').replace(/\0[\s\S]*$/, ''); + const result = koffi.decode(buf, 'str16'); + koffi.free(buf); log.debug(`[Win32Shell] SHLoadIndirectString("${source.substring(0, 50)}...") → "${result}"`); this.indirectCache.set(source, result || null); return result || null; } + koffi.free(buf); log.debug(`[Win32Shell] SHLoadIndirectString failed with HRESULT 0x${(hr >>> 0).toString(16)} for "${source.substring(0, 50)}..."`); this.indirectCache.set(source, null); return null; diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 2baca91..5e7158d 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -24,6 +24,7 @@ function createMockResolver(resolveClassicName?: (raw: any) => string, resolveEx const win32: IWin32Shell = { resolveIndirect: vi.fn().mockReturnValue(null), getFileVersionInfo: vi.fn().mockReturnValue(null), + uiLanguage: 'zh', }; const resolver = new ShellExtNameResolver(win32); if (resolveClassicName) vi.spyOn(resolver, 'resolveClassicName').mockImplementation(resolveClassicName); diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts index cf9de68..fea11e0 100644 --- a/tests/unit/main/services/ShellExtNameResolver.test.ts +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -7,10 +7,11 @@ import { } from '@/main/services/ShellExtNameResolver'; import { IWin32Shell } from '@/main/services/Win32Shell'; -function createMockWin32(): IWin32Shell { +function createMockWin32(lang: 'zh' | 'en' = 'zh'): IWin32Shell { return { resolveIndirect: vi.fn().mockReturnValue(null), getFileVersionInfo: vi.fn().mockReturnValue(null), + uiLanguage: lang, }; } @@ -389,6 +390,63 @@ describe('ShellExtNameResolver', () => { }); }); + // ---- 标准谓词翻译 ---- + + 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', () => { From 5f26cbf3b0fefa9f61d8c96d6e6b054e9f533667 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:20:05 +0800 Subject: [PATCH 17/31] =?UTF-8?q?fix(shellex):=20=E5=9B=9E=E9=80=80=20koff?= =?UTF-8?q?i.alloc=20=E2=86=92=20Buffer.alloc=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20decode('str16')=20segfault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测 koffi.alloc('char16') + decode('str16') 在 Windows 上导致 进程崩溃 (exit 139 segfault)。根因是 koffi 2.16.1 的 str16 decode 对 char16 数组指针存在兼容性问题。 正确方案: Buffer.alloc(2048) + 'void *' 参数 + buf.toString('utf16le') 同时加固: - Win32Shell 构造函数 try-catch koffi 初始化失败时降级 (koffiAvailable=false) - RegistryService 逐条 try-catch 保护 resolver 调用,单条失败使用 cleanName Co-Authored-By: Claude Opus 4.7 --- src/main/services/RegistryService.ts | 72 ++++++++++++------- src/main/services/Win32Shell.ts | 104 +++++++++++++-------------- 2 files changed, 96 insertions(+), 80 deletions(-) diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index b73697f..4f22c97 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -79,33 +79,51 @@ export class RegistryService { const classicItems = Array.isArray(raw) ? raw : (raw ? [raw] : []); const shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; - // Classic Shell 条目:通过 resolver 解析名称 - const classicEntries: MenuItemEntry[] = classicItems.map((r: PsRawClassicItem) => ({ - id: this.nextId++, - name: this.cleanDisplayName(this.resolver.resolveClassicName(r)), - 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) => ({ - id: this.nextId++, - name: this.cleanDisplayName(this.resolver.resolveExtName(r, this.cmdStoreIndex)), - command: r.actualClsid, - iconPath: null, - isEnabled: r.isEnabled, - source: r.handlerKeyName, - menuScene: scene, - registryKey: r.registryKey, - type: MenuItemType.ShellExt, - dllPath: r.dllPath ?? null, - })); + // 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]; diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index d006d34..89ba864 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -9,16 +9,17 @@ export interface IWin32Shell { export class Win32Shell implements IWin32Shell { // eslint-disable-next-line @typescript-eslint/no-explicit-any - private resolveIndirectFn: (...args: any[]) => number; + private resolveIndirectFn?: (...args: any[]) => number; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getFileVersionInfoSizeW: (...args: any[]) => [number, number]; + private getFileVersionInfoSizeW?: (...args: any[]) => [number, number]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getFileVersionInfoW: (...args: any[]) => boolean; + private getFileVersionInfoW?: (...args: any[]) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private verQueryValueW: (...args: any[]) => [boolean, bigint | null, number]; + private verQueryValueW?: (...args: any[]) => [boolean, bigint | null, number]; private indirectCache = new Map(); private versionCache = new Map(); + private koffiAvailable = true; // 用户 UI 语言 LCID(主语言 ID),用于 DLL 版本信息优先匹配 private readonly uiLangId: number; @@ -30,52 +31,49 @@ export class Win32Shell implements IWin32Shell { } constructor() { - const shlwapi = koffi.load('shlwapi.dll'); - const version = koffi.load('version.dll'); - - // HRESULT SHLoadIndirectString(PCWSTR pszSource, PWSTR pszOutBuf, UINT cchOutBuf, void **ppvReserved) - // pszOutBuf 是 caller-provided writable buffer → 用 'void *' 让 koffi 正确传递 Buffer 地址 - this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ - 'str16', // PCWSTR pszSource - 'void *', // PWSTR pszOutBuf (fix: 原 'char16 *' 可能导致 Buffer 传递异常) - 'uint32', // UINT cchOutBuf - 'void *', // void **ppvReserved (always NULL) - ]); - - this.getFileVersionInfoSizeW = version.func('__stdcall', 'GetFileVersionInfoSizeW', 'uint32', [ - 'str16', - koffi.out('uint32 *'), - ]); - - this.getFileVersionInfoW = version.func('__stdcall', 'GetFileVersionInfoW', 'bool', [ - 'str16', - 'uint32', - 'uint32', - 'void *', - ]); - - this.verQueryValueW = version.func('__stdcall', 'VerQueryValueW', 'bool', [ - 'void *', - 'str16', - koffi.out(koffi.pointer('void *')), - koffi.out('uint32 *'), - ]); - - // 获取当前线程 UI 语言 LCID 的主语言 ID - // 中文(简体) = 0x0804 → langId = 0x04, 英语(美国) = 0x0409 → langId = 0x09 try { - const buf = Buffer.alloc(4); + const shlwapi = koffi.load('shlwapi.dll'); + const version = koffi.load('version.dll'); + + this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ + 'str16', // PCWSTR pszSource + 'void *', // PWSTR pszOutBuf + 'uint32', // UINT cchOutBuf + 'void *', // void **ppvReserved + ]); + + this.getFileVersionInfoSizeW = version.func('__stdcall', 'GetFileVersionInfoSizeW', 'uint32', [ + 'str16', + koffi.out('uint32 *'), + ]); + + this.getFileVersionInfoW = version.func('__stdcall', 'GetFileVersionInfoW', 'bool', [ + 'str16', + 'uint32', + 'uint32', + 'void *', + ]); + + this.verQueryValueW = version.func('__stdcall', 'VerQueryValueW', 'bool', [ + 'void *', + 'str16', + koffi.out(koffi.pointer('void *')), + koffi.out('uint32 *'), + ]); + // GetUserDefaultUILanguage() returns LANGID (16-bit) const kernel32 = koffi.load('kernel32.dll'); const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); - this.uiLangId = getLangId() & 0xFF; // 主语言 ID - log.debug(`[Win32Shell] UI language ID: 0x${this.uiLangId.toString(16).padStart(2, '0')}`); - } catch { - this.uiLangId = 0x09; // fallback: English + this.uiLangId = getLangId() & 0xFF; + } catch (e) { + log.error('[Win32Shell] Failed to initialize koffi FFI:', 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; } @@ -83,17 +81,16 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - // 使用 koffi.alloc 分配输出缓冲区(Node.js Buffer.alloc 与 koffi 类型系统不兼容) - const buf = koffi.alloc('char16', 512); - const hr = this.resolveIndirectFn(source, buf, 512, null); + // Buffer.alloc + 'void *' 才能在 koffi 中正确传递预分配输出缓冲区 + // koffi.alloc('char16') + decode('str16') 会导致 segfault + const buf = Buffer.alloc(2048); + const hr = this.resolveIndirectFn(source, buf, 1024, null); if (hr === 0) { - const result = koffi.decode(buf, 'str16'); - koffi.free(buf); + 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; } - koffi.free(buf); log.debug(`[Win32Shell] SHLoadIndirectString failed with HRESULT 0x${(hr >>> 0).toString(16)} for "${source.substring(0, 50)}..."`); this.indirectCache.set(source, null); return null; @@ -105,25 +102,26 @@ export class Win32Shell implements IWin32Shell { } getFileVersionInfo(dllPath: string): string | null { + if (!this.koffiAvailable || !this.getFileVersionInfoSizeW) return null; const cached = this.versionCache.get(dllPath); if (cached !== undefined) return cached; try { - const [size] = this.getFileVersionInfoSizeW(dllPath, [0]); + const [size] = this.getFileVersionInfoSizeW!(dllPath, [0]); if (!size || size === 0) { this.versionCache.set(dllPath, null); return null; } const data = Buffer.alloc(size); - if (!this.getFileVersionInfoW(dllPath, 0, size, data)) { + if (!this.getFileVersionInfoW!(dllPath, 0, size, data)) { log.debug(`[Win32Shell] GetFileVersionInfoW failed for "${dllPath}"`); this.versionCache.set(dllPath, null); return null; } // Query translation table - const [, transPtr, transLen] = this.verQueryValueW(data, '\\VarFileInfo\\Translation'); + const [, transPtr, transLen] = this.verQueryValueW!(data, '\\VarFileInfo\\Translation'); if (!transPtr || transLen < 4) { log.debug(`[Win32Shell] No Translation table in "${dllPath}"`); this.versionCache.set(dllPath, null); @@ -151,7 +149,7 @@ export class Win32Shell implements IWin32Shell { // Query FileDescription / ProductName for each language (UI language first) for (const langKey of orderedKeys) { - const [, descPtr, descLen] = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\FileDescription`); + const [, descPtr, descLen] = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\FileDescription`); if (descPtr && descLen > 0) { const desc = koffi.decode(descPtr, 'str16'); if (desc && desc.length >= 2 && desc.length <= 64) { @@ -161,7 +159,7 @@ export class Win32Shell implements IWin32Shell { } } - const [, prodPtr, prodLen] = this.verQueryValueW(data, `\\StringFileInfo\\${langKey}\\ProductName`); + const [, prodPtr, prodLen] = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\ProductName`); if (prodPtr && prodLen > 0) { const prod = koffi.decode(prodPtr, 'str16'); if (prod && prod.length >= 2 && prod.length <= 64) { From ed5b67f4c7a77a522c1c6a69d1b0ecc71a9d54c8 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:30:58 +0800 Subject: [PATCH 18/31] =?UTF-8?q?fix(shellex):=20CommandStore=20Level=201.?= =?UTF-8?q?7=20=E9=97=B4=E6=8E=A5=20MUIVerb=20=E6=9C=AA=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E8=8B=B1=E6=96=87=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 PS 脚本在构建 CommandStore 索引时同步解析了 @dll,-id 格式的 MUIVerb (通过 CmHelper.ResolveIndirect)。迁移到 TS 后存储了原始值 但 Level 1.7 查找时直接返回了 @shell32.dll,-37423 原始字符串。 修复后先调用 resolveIndirect 解析,如失败则让后续 Level 继续尝试。 案例: {a2a9545d-...} StartMenuPin 的 CommandStore MUIVerb 是 @shell32.dll,-37423 → resolveIndirect → "固定到"开始"屏幕" Co-Authored-By: Claude Opus 4.7 --- src/main/services/ShellExtNameResolver.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index 5196ccb..df771a0 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -248,8 +248,17 @@ export class ShellExtNameResolver { // Level 1.7: CommandStore 反向索引 const cmdVerb = cmdStore.get(raw.actualClsid); if (cmdVerb) { - log.debug(`[NameResolver] ${fallback} → Level 1.7 (CommandStore): "${cmdVerb}"`); - return cmdVerb; + // CommandStore MUIVerb 可能是间接字符串(@dll,-id),需 resolveIndirect 解析 + 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 2: CLSID 默认值 From cc4577498bc6f1fe4a36b381e689d61405f4c1ab Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:36:32 +0800 Subject: [PATCH 19/31] =?UTF-8?q?fix(shellex):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=BC=98=E5=85=88=E7=BA=A7=20=E2=80=94=20Com?= =?UTF-8?q?mandStore=20=E4=BC=98=E5=85=88=E4=BA=8E=20plain=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心问题: Level 1 LocalizedString 的 plain text 值(如 "Start Menu Pin") 是开发者硬编码的英文名,应在 CommandStore(Windows 本地化机制)之后查询。 新优先级: Phase A: 间接格式 (@/ms-resource:) → resolveIndirect → 系统语言名称 Phase B: CommandStore 反向索引 → Windows 本地化名称 Phase C: Plain text 回退 → 开发者原始名称 ... Fallback 案例: {a2a9545d-...} StartMenuPin 旧: Level 1 plain → "Start Menu Pin" (英文) → 立即返回 ❌ 新: Level 1 indirect (无) → Phase A skip → CommandStore → @shell32.dll,-xxxxx → resolveIndirect → 中文名 ✓ Co-Authored-By: Claude Opus 4.7 --- src/main/services/ShellExtNameResolver.ts | 111 ++++++++++++---------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index df771a0..da444b4 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -189,66 +189,47 @@ export class ShellExtNameResolver { } catch { /* fall through */ } } - // 以下 Level 需 CLSID 路径存在 + // ====== Phase A: 间接格式优先(resolveIndirect 返回系统语言名称) ====== if (raw.actualClsid) { - // Level 1: CLSID.LocalizedString - if (raw.clsidLocalizedString) { - if (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 */ } - } else if (raw.clsidLocalizedString.length >= 2) { - if (!isUselessPlain(raw.clsidLocalizedString, fallback)) { - log.debug(`[NameResolver] ${fallback} → Level 1 (LocalizedString plain): "${raw.clsidLocalizedString}"`); - return raw.clsidLocalizedString; + // 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: Sibling Shell Key MUIVerb - if (raw.siblingMUIVerb) { - if (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 */ } - } else if (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.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: CLSID.MUIVerb - if (raw.clsidMUIVerb) { - if (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 */ } - } else if (raw.clsidMUIVerb.length >= 2) { - if (!isUselessPlain(raw.clsidMUIVerb, fallback)) { - log.debug(`[NameResolver] ${fallback} → Level 1.5 (MUIVerb): "${raw.clsidMUIVerb}"`); - return raw.clsidMUIVerb; + // 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 */ } } - // Level 1.7: CommandStore 反向索引 + // ====== Phase B: CommandStore(Windows 本地化机制,优先级高于 plain text) ====== const cmdVerb = cmdStore.get(raw.actualClsid); if (cmdVerb) { - // CommandStore MUIVerb 可能是间接字符串(@dll,-id),需 resolveIndirect 解析 if (cmdVerb.startsWith('@') || cmdVerb.startsWith('ms-resource:')) { const resolved = this.win32.resolveIndirect(cmdVerb); if (resolved && resolved.length >= 2) { @@ -261,6 +242,40 @@ export class ShellExtNameResolver { } } + // ====== 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)) { From 678d8314dfb29e020dba84266f79144bfe616916 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:45:21 +0800 Subject: [PATCH 20/31] =?UTF-8?q?feat(diagnose):=20=E6=B7=BB=E5=8A=A0=20ko?= =?UTF-8?q?ffi=20FFI=20=E8=BF=90=E8=A1=8C=E6=97=B6=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 IPC sys:diagnose 通道: 测试 SHLoadIndirectString + GetFileVersionInfo - Win32Shell 构造函数打印确认日志 (Initialized OK 或 FAILED) - settingsPage 新增隐藏诊断面板,点击按钮显示 koffi 状态 - CommandStoreIndex 新增 size getter 用于在 Electron 运行时验证 koffi FFI 是否正常工作。 Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 2 +- src/main/ipc/system.ts | 51 ++++++++++++++++++++++- src/main/services/ShellExtNameResolver.ts | 2 + src/main/services/Win32Shell.ts | 3 +- src/preload/index.ts | 3 ++ src/renderer/api/bridge.ts | 1 + src/renderer/index.html | 13 ++++++ src/renderer/pages/settingsPage.ts | 22 ++++++++-- src/shared/ipc-channels.ts | 1 + 9 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 32467dc..8e97a21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,7 +82,7 @@ function initServices(): MenuManagerService { registerRegistryHandlers(menuManager); registerHistoryHandlers(history, menuManager); registerBackupHandlers(backup); - registerSystemHandlers(); + registerSystemHandlers(win32Shell, cmdStoreIndex.size); return menuManager; } diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 4b596a4..1a78277 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, + cmdStoreSize?: number, +): void { ipcMain.handle( IPC.SYS_IS_ADMIN, wrapHandler(() => { @@ -114,6 +118,51 @@ export function registerSystemHandlers(): void { }) ); + ipcMain.handle( + IPC.SYS_DIAGNOSE, + wrapHandler(() => { + const result: Record = { + koffiAvailable: false, + resolveIndirectResult: null, + resolveIndirectError: null, + fileVersionResult: null, + fileVersionError: null, + uiLanguage: 'unknown', + cmdStoreSize: cmdStoreSize ?? 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); + } + + // 测试 GetFileVersionInfo + try { + const fv = win32Shell.getFileVersionInfo('C:\\Windows\\System32\\shell32.dll'); + result.fileVersionResult = fv; + log.info(`[Diagnose] getFileVersionInfo test: "${fv}"`); + } catch (e) { + result.fileVersionError = String(e); + log.error('[Diagnose] getFileVersionInfo test failed:', e); + } + + return result; + }) + ); + ipcMain.handle( IPC.WIN_MINIMIZE, wrapHandler(() => { diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index da444b4..7606843 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -119,6 +119,8 @@ export class CommandStoreIndex { } } + get size(): number { return this.map.size; } + get(clsid: string): string | null { return this.map.get(clsid.toLowerCase()) ?? null; } diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index 89ba864..ea57bca 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -65,8 +65,9 @@ export class Win32Shell implements IWin32Shell { const kernel32 = koffi.load('kernel32.dll'); const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); this.uiLangId = getLangId() & 0xFF; + log.info(`[Win32Shell] Initialized OK — koffiAvailable=true, uiLanguage=${this.uiLanguage}`); } catch (e) { - log.error('[Win32Shell] Failed to initialize koffi FFI:', String(e)); + log.error(`[Win32Shell] FAILED — koffiAvailable=false, reason: ${String(e)}`); this.koffiAvailable = false; this.uiLangId = 0x09; } 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/index.html b/src/renderer/index.html index 840a2cf..ba62632 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -465,6 +465,19 @@
+
+
诊断 (Diagnostics)
+
+
+
koffi FFI 自检
+
点击按钮检测 SHLoadIndirectString 和 GetFileVersionInfo 是否正常
+
+
+ +
+
+
+
关于
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/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', From 940bc0b40d94a8342344030bd93e2937ede6088e Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:50:02 +0800 Subject: [PATCH 21/31] =?UTF-8?q?fix(diagnose):=20cmdStoreSize=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=AE=9E=E6=97=B6=E6=9F=A5=E8=AF=A2=20+=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=80=90=E6=9D=A1=E8=A7=A3=E6=9E=90=E8=BF=BD=E8=B8=AA?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmdStoreSize 从构造时快照改为 getter 函数,诊断面板显示实时值 - getMenuItems 输出 [ResolveTrace] 日志,记录每条 ShellExt 的 最终名称 + 所有原始注册表字段(便于追踪解析路径) Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 2 +- src/main/ipc/system.ts | 4 ++-- src/main/services/RegistryService.ts | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8e97a21..e4256af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,7 +82,7 @@ function initServices(): MenuManagerService { registerRegistryHandlers(menuManager); registerHistoryHandlers(history, menuManager); registerBackupHandlers(backup); - registerSystemHandlers(win32Shell, cmdStoreIndex.size); + registerSystemHandlers(win32Shell, () => cmdStoreIndex.size); return menuManager; } diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 1a78277..7afe43e 100644 --- a/src/main/ipc/system.ts +++ b/src/main/ipc/system.ts @@ -11,7 +11,7 @@ const execFileAsync = promisify(execFile); export function registerSystemHandlers( win32Shell?: IWin32Shell, - cmdStoreSize?: number, + getCmdStoreSize?: () => number, ): void { ipcMain.handle( IPC.SYS_IS_ADMIN, @@ -128,7 +128,7 @@ export function registerSystemHandlers( fileVersionResult: null, fileVersionError: null, uiLanguage: 'unknown', - cmdStoreSize: cmdStoreSize ?? 0, + cmdStoreSize: getCmdStoreSize ? getCmdStoreSize() : 0, }; if (!win32Shell) { diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 4f22c97..4cf2b0b 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -127,6 +127,13 @@ export class RegistryService { 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 || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); + } + // 写入缓存 this.cache.set(scene, result); From e336b2c9914ea2222b6e439bead0316346ebb19c Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 04:58:00 +0800 Subject: [PATCH 22/31] =?UTF-8?q?fix(shellex):=20=E4=BF=AE=E5=A4=8D=20getF?= =?UTF-8?q?ileVersionInfo=20koffi=20out=20=E5=8F=82=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=84=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit koffi 运行时 out 参数返回 number 而非 [number,number] 元组, const [size] = ... 解构失败导致 Level 2.5 在所有 DLL 上报错: "TypeError: number X is not iterable" 修复: 所有 koffi out 参数调用改用 Array.isArray 兼容处理 Co-Authored-By: Claude Opus 4.7 --- src/main/services/Win32Shell.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index ea57bca..512b458 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -108,7 +108,9 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - const [size] = this.getFileVersionInfoSizeW!(dllPath, [0]); + // koffi out 参数在运行时可能返回 number 而非 [number,number] 元组 + const fvResult = this.getFileVersionInfoSizeW!(dllPath, [0]); + const size: number = Array.isArray(fvResult) ? fvResult[0] : (fvResult as unknown as number); if (!size || size === 0) { this.versionCache.set(dllPath, null); return null; @@ -122,7 +124,9 @@ export class Win32Shell implements IWin32Shell { } // Query translation table - const [, transPtr, transLen] = this.verQueryValueW!(data, '\\VarFileInfo\\Translation'); + const vtResult = this.verQueryValueW!(data, '\\VarFileInfo\\Translation'); + const transPtr: bigint | null = Array.isArray(vtResult) ? vtResult[1] : null; + const transLen: number = Array.isArray(vtResult) ? (vtResult[2] as number) : 0; if (!transPtr || transLen < 4) { log.debug(`[Win32Shell] No Translation table in "${dllPath}"`); this.versionCache.set(dllPath, null); @@ -150,7 +154,9 @@ export class Win32Shell implements IWin32Shell { // Query FileDescription / ProductName for each language (UI language first) for (const langKey of orderedKeys) { - const [, descPtr, descLen] = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\FileDescription`); + const descResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\FileDescription`); + const descPtr: bigint | null = Array.isArray(descResult) ? descResult[1] : null; + const descLen: number = Array.isArray(descResult) ? (descResult[2] as number) : 0; if (descPtr && descLen > 0) { const desc = koffi.decode(descPtr, 'str16'); if (desc && desc.length >= 2 && desc.length <= 64) { @@ -160,7 +166,9 @@ export class Win32Shell implements IWin32Shell { } } - const [, prodPtr, prodLen] = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\ProductName`); + const prodResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\ProductName`); + const prodPtr: bigint | null = Array.isArray(prodResult) ? prodResult[1] : null; + const prodLen: number = Array.isArray(prodResult) ? (prodResult[2] as number) : 0; if (prodPtr && prodLen > 0) { const prod = koffi.decode(prodPtr, 'str16'); if (prod && prod.length >= 2 && prod.length <= 64) { From d2700ab4e5797f8b7c6d0d4270bf7179f5fde65f Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:02:07 +0800 Subject: [PATCH 23/31] =?UTF-8?q?fix(shellex):=20koffi=20out=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=20Number=20=E5=BC=BA=E8=BD=AC=20+=20"Expected=20N=20a?= =?UTF-8?q?rguments"=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fvResult size 可能是 BigInt,需 Number() 转后才能传入后续函数 - transLen/descLen/prodLen 同样做 Number() 转换 Co-Authored-By: Claude Opus 4.7 --- src/main/services/Win32Shell.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index 512b458..4c81a26 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -108,16 +108,17 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - // koffi out 参数在运行时可能返回 number 而非 [number,number] 元组 const fvResult = this.getFileVersionInfoSizeW!(dllPath, [0]); - const size: number = Array.isArray(fvResult) ? fvResult[0] : (fvResult as unknown as number); + const rawSize = Array.isArray(fvResult) ? fvResult[0] : fvResult; + const size = Number(rawSize); if (!size || size === 0) { this.versionCache.set(dllPath, null); return null; } const data = Buffer.alloc(size); - if (!this.getFileVersionInfoW!(dllPath, 0, size, data)) { + const fvOk = this.getFileVersionInfoW!(dllPath, 0, size, data); + if (!fvOk) { log.debug(`[Win32Shell] GetFileVersionInfoW failed for "${dllPath}"`); this.versionCache.set(dllPath, null); return null; @@ -126,7 +127,7 @@ export class Win32Shell implements IWin32Shell { // Query translation table const vtResult = this.verQueryValueW!(data, '\\VarFileInfo\\Translation'); const transPtr: bigint | null = Array.isArray(vtResult) ? vtResult[1] : null; - const transLen: number = Array.isArray(vtResult) ? (vtResult[2] as number) : 0; + const transLen = Number(Array.isArray(vtResult) ? vtResult[2] : 0); if (!transPtr || transLen < 4) { log.debug(`[Win32Shell] No Translation table in "${dllPath}"`); this.versionCache.set(dllPath, null); @@ -156,7 +157,7 @@ export class Win32Shell implements IWin32Shell { for (const langKey of orderedKeys) { const descResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\FileDescription`); const descPtr: bigint | null = Array.isArray(descResult) ? descResult[1] : null; - const descLen: number = Array.isArray(descResult) ? (descResult[2] as number) : 0; + const descLen = Number(Array.isArray(descResult) ? descResult[2] : 0); if (descPtr && descLen > 0) { const desc = koffi.decode(descPtr, 'str16'); if (desc && desc.length >= 2 && desc.length <= 64) { @@ -168,7 +169,7 @@ export class Win32Shell implements IWin32Shell { const prodResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\ProductName`); const prodPtr: bigint | null = Array.isArray(prodResult) ? prodResult[1] : null; - const prodLen: number = Array.isArray(prodResult) ? (prodResult[2] as number) : 0; + const prodLen = Number(Array.isArray(prodResult) ? prodResult[2] : 0); if (prodPtr && prodLen > 0) { const prod = koffi.decode(prodPtr, 'str16'); if (prod && prod.length >= 2 && prod.length <= 64) { From ba8944fc45a271ba105cba402ca19a25ab1f2a55 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:03:27 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix(shellex):=20Level=202=20CLSID=20Defau?= =?UTF-8?q?lt=20=E8=8B=B1=E6=96=87=E5=90=8D=E6=9F=A5=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E8=A1=A8=20+=20=E6=89=A9=E5=85=85=E5=B8=B8=E8=A7=81=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Level 2 返回前调用 translateStandardVerb 尝试翻译已知英文名 - 新增 11 个常见 CLSID Default 英文名 → 中文翻译 包括: Start Menu Pin, Taskband Pin, Open With Context Menu Handler, Doubao Context Menu, Shell extensions for sharing 等 Co-Authored-By: Claude Opus 4.7 --- src/main/services/ShellExtNameResolver.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index 7606843..fc0aab5 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -40,6 +40,18 @@ const STANDARD_VERBS: Record = { 'unpintotaskbar': { zh: '从任务栏取消固定', en: 'Unpin from taskbar' }, 'pinToStart': { zh: '固定到"开始"屏幕', en: 'Pin to Start' }, 'unpinFromStart': { zh: '从"开始"屏幕取消固定', en: 'Unpin from Start' }, + + // CLSID Default 英文名 → 中文翻译(常见系统/第三方 Shell 扩展) + 'start menu pin': { zh: '固定到"开始"屏幕', en: 'Start Menu Pin' }, + 'taskband pin': { zh: '固定到任务栏', en: 'Taskband Pin' }, + 'open with context menu handler': { zh: '打开方式', en: 'Open With Context Menu Handler' }, + 'encryption context menu': { zh: '加密菜单', en: 'Encryption Context Menu' }, + 'shell extensions for sharing': { zh: '共享', en: 'Shell extensions for sharing' }, + 'new menu handler': { zh: '新建菜单', en: 'New Menu Handler' }, + 'previous versions property page': { zh: '以前的版本', en: 'Previous Versions Property Page' }, + 'work folders context menu handler': { zh: '工作文件夹', en: 'Work Folders Context Menu Handler' }, + 'shellex for cd burning': { zh: '刻录到光盘', en: 'ShellFolder for CD Burning' }, + 'doubao context menu': { zh: '豆包右键菜单', en: 'Doubao Context Menu' }, }; // ---- 数据契约:PS 脚本返回的原始数据 ---- @@ -278,9 +290,14 @@ export class ShellExtNameResolver { } } - // Level 2: CLSID 默认值 + // Level 2: CLSID 默认值(尝试翻译已知英文名) if (raw.clsidDefault && raw.clsidDefault.length >= 2) { if (!isUselessPlain(raw.clsidDefault, fallback)) { + const translated = translateStandardVerb(raw.clsidDefault, this.language); + if (translated) { + log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default translated): "${translated}"`); + return translated; + } log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default): "${raw.clsidDefault}"`); return raw.clsidDefault; } From c7efa502ac6a3002bceef6a8a041bab9ae851f0d Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:11:41 +0800 Subject: [PATCH 25/31] =?UTF-8?q?refactor(shellex):=20DLL=20FileDescriptio?= =?UTF-8?q?n=20=E4=BB=8E=20koffi=20FFI=20=E6=94=B9=E4=B8=BA=20PS=20?= =?UTF-8?q?=E5=86=85=E8=81=94=E9=87=87=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PS 枚举 handler 时通过 [FileVersionInfo]::GetVersionInfo() 采集 dllFileDescription 字段,天然支持 UI 语言,零额外 PS 进程 - 删除 Win32Shell.getFileVersionInfo() 及所有 version.dll koffi FFI 代码(历经3次修复仍不稳定:解构失败/Expected N arguments) - Win32Shell 精简为仅 resolveIndirect + uiLanguage - ShellExtNameResolver Level 2.5 改用 dllFileDescription 字段 - 回退上一提交的硬编码 CLSID 翻译表(对照翻译不可扩展) - 保留标准谓词翻译表(open→打开等真正的 shell 动词) Co-Authored-By: Claude Opus 4.7 --- docs/context-menu-parsing-logic.md | 410 ++++++++++++++++++ src/main/ipc/system.ts | 12 - src/main/services/PowerShellBridge.ts | 11 + src/main/services/RegistryService.ts | 2 +- src/main/services/ShellExtNameResolver.ts | 35 +- src/main/services/Win32Shell.ts | 140 +----- .../main/services/RegistryService.test.ts | 1 - .../services/ShellExtNameResolver.test.ts | 13 +- 8 files changed, 440 insertions(+), 184 deletions(-) create mode 100644 docs/context-menu-parsing-logic.md diff --git a/docs/context-menu-parsing-logic.md b/docs/context-menu-parsing-logic.md new file mode 100644 index 0000000..680a739 --- /dev/null +++ b/docs/context-menu-parsing-logic.md @@ -0,0 +1,410 @@ +# ContextMaster 右键菜单解析完整逻辑 + +## 目录 +1. [架构概览](#架构概览) +2. [完整数据流](#完整数据流) +3. [核心模块详解](#核心模块详解) +4. [缓存机制](#缓存机制) +5. [启用/禁用逻辑](#启用禁用逻辑) + +--- + +## 架构概览 + +ContextMaster 的右键菜单解析采用分层架构,从 UI 到底层注册表操作分为 6 个主要层次: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Renderer Layer (UI) │ +│ mainPage.ts - 用户交互 │ +└──────────────────────────┬──────────────────────────────┘ + │ IPC +┌──────────────────────────▼──────────────────────────────┐ +│ IPC Handlers (registry.ts) │ +│ Electron IPC 通信层 │ +└──────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────┐ +│ MenuManagerService.ts - 菜单管理服务 │ +│ 业务逻辑、缓存控制、事务管理 │ +└──────────────────────────┬──────────────────────────────┘ + │ +┌──────────────────────────▼──────────────────────────────┐ +│ RegistryService.ts - 注册表服务 │ +│ Classic Shell + Shell Ext 解析、缓存 │ +└──────────────────────────┬──────────────────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ │ +┌───────▼────────┐ ┌───────▼────────┐ +│PowerShell │ │ShellExtName │ +│ Bridge.ts │ │ Resolver.ts │ +│ PowerShell │ │ 名称解析器 │ +│ 脚本执行 │ │ │ +└────────────┘ └────────────┘ + │ │ +┌───────▼───────────────────────────────────┐ +│ Win32Shell.ts - Windows API │ +│ SHLoadIndirectString │ +└───────────────────────────────────────┘ +``` + +--- + +## 完整数据流 + +### 1. 用户请求菜单列表的完整流程 + +``` +用户点击场景导航 + ↓ +[Renderer: loadScene(scene) + ↓ +检查 Renderer 缓存 (2分钟 TTL) + ↓ +[IPC]: REGISTRY_GET_ITEMS + ↓ +[MenuManagerService]: getMenuItems() + ↓ +检查 MenuManager 缓存 (5分钟 TTL) / in-flight 去重 + ↓ +[RegistryService]: getMenuItems() + ↓ +检查 RegistryCache + ↓ +┌───────────────────────────────────────┐ +│ 并行执行 PowerShell 脚本: │ +│ 1. buildGetItemsScript() │ +│ - Classic Shell 条目 │ +│ 2. buildGetShellExtItemsScript() │ +│ - Shell Ext 条目 │ +└───────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────┐ +│ [PowerShellBridge]: execute() │ +│ - 信号量控制并发 (max 3) │ +│ - 执行 PowerShell.exe/pwsh.exe │ +│ - 解析 JSON 返回 │ +└───────────────────────────────────────┘ + ↓ +┌───────────────────────────────────────┐ +│ [ShellExtNameResolver]: │ +│ - resolveClassicName() │ +│ - resolveExtName() │ +│ (多级回退链解析显示名称) │ +└───────────────────────────────────────┘ + ↓ +[RegistryService]: 清理显示名称 (移除快捷键、括号等) + ↓ +写入各级缓存 + ↓ +返回 MenuItemEntry[] + ↓ +[Renderer]: 渲染列表 +``` + +### 2. 详细步骤说明 + +#### 步骤 1: Renderer 发起请求 (`mainPage.ts`) + +```typescript +// 用户点击导航 → loadScene(scene) +// 1. 检查 Renderer 本地缓存 (2分钟 TTL) +// 2. 缓存命中 → 直接显示 + 后台刷新 (剩余 <30s 时) +// 3. 缓存未命中 → 调用 window.api.getMenuItems(scene) +``` + +#### 步骤 2: IPC 通信 (`registry.ts`) + +```typescript +// IPC 通道: REGISTRY_GET_ITEMS +// 包装: wrapHandler() 统一错误处理 +// 调用: menuManager.getMenuItems(scene, false, 'high') +``` + +#### 步骤 3: MenuManagerService (`MenuManagerService.ts`) + +```typescript +// 1. 检查自身缓存 (5分钟 TTL) +// 2. 检查 in-flight 请求 (避免重复请求) +// 3. 调用 registry.getMenuItems() +// 4. 写入缓存 +``` + +#### 步骤 4: RegistryService (`RegistryService.ts`) + +```typescript +// 1. 检查 RegistryCache +// 2. 构建 PowerShell 脚本 +// 3. 并行执行两个脚本: +// - Classic Shell: HKCR\\shell +// - Shell Ext: HKCR\\shellex\ContextMenuHandlers +// 4. 解析返回的原始数据 +// 5. 通过 ShellExtNameResolver 解析显示名称 +// 6. 清理显示名称 (移除 &(X)、&X、括号等 +// 7. 写入 RegistryCache +``` + +#### 步骤 5: PowerShell 脚本执行 (`PowerShellBridge.ts`) + +```typescript +// 信号量: 最多 3 个并发 PowerShell 进程 +// 优先级队列: high 优先级插队 +// 超时: 30秒 +// 提权: executeElevated() 用于写入操作 +``` + +--- + +## 核心模块详解 + +### 1. RegistryService - 注册表服务 + +**文件**: `src/main/services/RegistryService.ts` + +**职责**: +- 协调 Classic Shell 和 Shell Ext 条目读取 +- 名称解析协调 +- 缓存管理 +- 启用/禁用操作 +- 事务回滚 + +**注册表路径映射**: + +```typescript +const SCENE_REG_ROOTS = { + Desktop: 'HKCR\\DesktopBackground\\Shell', + File: 'HKCR\\*\\shell', + Folder: 'HKCR\\Directory\\shell', + Drive: 'HKCR\\Drive\\shell', + DirectoryBackground:'HKCR\\Directory\\Background\\shell', + RecycleBin: 'HKCR\\CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell', +}; + +const SCENE_SHELLEX_PATHS = { + Desktop: 'HKCR\\DesktopBackground\\shellex\\ContextMenuHandlers', + // ... 同上面一样 +}; +``` + +### 2. PowerShellBridge - PowerShell 桥接 + +**文件**: `src/main/services/PowerShellBridge.ts` + +**职责**: +- 构建 PowerShell 脚本 +- 执行脚本并解析 JSON +- 并发控制 (信号量) +- 提权执行 (UAC) + +**关键脚本**: + +#### buildGetItemsScript - 获取 Classic Shell 条目 +```powershell +# 读取 HKCR\\shell +# 采集: +# - subKeyName (子键名) +# - MUIVerb (多语言动词) +# - (默认值) +# - LocalizedDisplayName +# - Icon +# - LegacyDisable (是否禁用) +# - command (命令子键默认值) +``` + +#### buildGetShellExtItemsScript - 获取 Shell Ext 条目 +```powershell +# 读取 HKCR\\shellex\ContextMenuHandlers +# 采集: +# - handlerKeyName (处理程序键名 +# - defaultVal (默认值 = CLSID) +# - CLSID\{clsid} 的: +# - LocalizedString +# - MUIVerb +# - (默认值) +# - InprocServer32 (DLL路径) +# - DLL FileDescription (通过 .NET FileVersionInfo) +# - sibling shell key 的 MUIVerb +``` + +### 3. ShellExtNameResolver - 名称解析器 + +**文件**: `src/main/services/ShellExtNameResolver.ts` + +**职责**: +- 解析 Classic Shell 显示名称 +- 解析 Shell Ext 显示名称 (多级回退链) +- 标准动词翻译 (open → 打开, edit → 编辑, etc.) +- 通用名称过滤 + +#### Classic Shell 名称解析优先级: +``` +1. rawMUIVerb (@间接字符串 → SHLoadIndirectString +2. rawDefault (默认值) +3. rawLocalizedDisplayName (@间接字符串 → SHLoadIndirectString) +4. 标准动词翻译 (subKeyName) +5. subKeyName (兜底) +``` + +#### Shell Ext 名称解析优先级 (多级回退链): + +``` +Level 0: directName (@间接字符串 +Level 1-indirect: CLSID.LocalizedString (@间接) +Level 1.3-indirect: sibling shell key MUIVerb (@间接) +Level 1.5-indirect: CLSID.MUIVerb (@间接) +Level 1.7: CommandStore 索引 (Windows 内置命令) +Level 1-plain: CLSID.LocalizedString (明文) +Level 1.3-plain: sibling shell key MUIVerb (明文) +Level 1.5-plain: CLSID.MUIVerb (明文) +Level 2: CLSID 默认值 +Level 2.5: DLL FileDescription +Level 3: directName (明文) +标准动词翻译 (cleanName) +Fallback: cleanName (键名) +``` + +### 4. Win32Shell - Windows API 封装 + +**文件**: `src/main/services/Win32Shell.ts` + +**职责**: +- 调用 `SHLoadIndirectString` (shlwapi.dll) +- 解析 @dll,-id 格式的间接字符串 +- 解析 ms-resource: 格式 +- 获取用户 UI 语言 + +```typescript +// 示例: +// "@shell32.dll,-51234 → "打开" +// "ms-resource:Windows.System.UserProfile.ProfileTileDisplayName" → "你的账户" +``` + +--- + +## 缓存机制 + +### 三级缓存架构 + +``` +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) + └─ 无 TTL,显式失效 +``` + +### 缓存失效时机 + +``` +- 单个条目切换 → invalidateCache(scene) +- 批量操作 → invalidateAllCache() +- 应用启动 → 无缓存,首次加载 +``` + +--- + +## 启用/禁用逻辑 + +### Classic Shell 条目 + +``` +启用: + - 删除 LegacyDisable 字符串值 + - 注册表键保持不变 + +禁用: + - 设置 LegacyDisable 字符串值 (空字符串) + - 注册表键保持不变 +``` + +### Shell Ext 条目 + +``` +启用: + - 重命名键: "-Name" → "Name" + +禁用: + - 重命名键: "Name" → "-Name" +``` + +### 事务与回滚 + +```typescript +// 批量操作前: createRollbackPoint() +// 记录所有条目原始状态 +// 失败时: rollback() → 恢复所有条目 +// 成功时: commitTransaction() +``` + +--- + +## 数据结构 + +### MenuItemEntry - 菜单条目 + +```typescript +interface MenuItemEntry { + id: number; // 自增 ID + name: string; // 显示名称 + command: string; // 命令 / CLSID + iconPath: string | null; // 图标路径 + isEnabled: boolean; // 是否启用 + source: string; // 来源 (Shell Ext 处理程序名 + menuScene: MenuScene; // 所属场景 + registryKey: string; // 注册表相对路径 + type: MenuItemType; // System / Custom / ShellExt + dllPath?: string | null; // Shell Ext DLL 路径 +} +``` + +### MenuItemType - 条目类型 + +```typescript +enum MenuItemType { + System, // 系统内置 (无 command) + Custom, // 自定义 (有 command) + ShellExt, // Shell 扩展 (COM) +} +``` + +--- + +## 关键代码位置 + +| 功能 | 文件 | 行号 | +|------|------|------| +| Classic Shell 读取 | RegistryService.ts | 57-145 | +| Shell Ext 读取 | RegistryService.ts | 106-126 | +| 名称解析 (Classic) | ShellExtNameResolver.ts | 153-178 | +| 名称解析 (ShellExt) | ShellExtNameResolver.ts | 181-319 | +| PS 脚本构建 | PowerShellBridge.ts | 168-319 | +| IPC 处理器 | registry.ts | 9-54 | +| UI 渲染 | mainPage.ts | 123-195 | + +--- + +## 性能优化 + +1. **并发控制**: PowerShellBridge 信号量 (max 3) +2. **缓存分层**: 三级缓存,TTL 递减 +3. **并行读取**: Classic + ShellExt 并行执行 +4. **Stale-while-revalidate**: Renderer 缓存命中后台刷新 +5. **预加载**: 启动时后台 preloadAllScenes() +6. **去重**: in-flight 请求避免重复加载 + +--- + +## 错误处理 + +1. **单条失败不影响整体 (per-item try/catch +2. **PowerShell 失败返回空数组 [] +3. **名称解析失败回退到键名 +4. **事务回滚机制 +5. **IPC 统一错误包装 IpcResult diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 7afe43e..58de76b 100644 --- a/src/main/ipc/system.ts +++ b/src/main/ipc/system.ts @@ -125,8 +125,6 @@ export function registerSystemHandlers( koffiAvailable: false, resolveIndirectResult: null, resolveIndirectError: null, - fileVersionResult: null, - fileVersionError: null, uiLanguage: 'unknown', cmdStoreSize: getCmdStoreSize ? getCmdStoreSize() : 0, }; @@ -149,16 +147,6 @@ export function registerSystemHandlers( log.error('[Diagnose] resolveIndirect test failed:', e); } - // 测试 GetFileVersionInfo - try { - const fv = win32Shell.getFileVersionInfo('C:\\Windows\\System32\\shell32.dll'); - result.fileVersionResult = fv; - log.info(`[Diagnose] getFileVersionInfo test: "${fv}"`); - } catch (e) { - result.fileVersionError = String(e); - log.error('[Diagnose] getFileVersionInfo test failed:', e); - } - return result; }) ); diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 46f3be8..e8b5342 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -278,6 +278,16 @@ $result = @($handlers | ForEach-Object { } } } + # DLL FileDescription(.NET FileVersionInfo,天然支持 UI 语言,无需 koffi) + $dllFileDescription = $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 + } + } catch {} + } # sibling shell key MUIVerb $siblingMUIVerb = $null if ($shellPath) { @@ -299,6 +309,7 @@ $result = @($handlers | ForEach-Object { clsidMUIVerb = $clsidMUIVerb clsidDefault = $clsidDefault dllPath = $dllPath + dllFileDescription = $dllFileDescription siblingMUIVerb = $siblingMUIVerb registryKey = [string]$regKey } diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 4cf2b0b..236f6b3 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -131,7 +131,7 @@ export class RegistryService { 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 || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); + 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 || ''}" dllDesc="${raw.dllFileDescription || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); } // 写入缓存 diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index fc0aab5..069e57c 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -40,18 +40,6 @@ const STANDARD_VERBS: Record = { 'unpintotaskbar': { zh: '从任务栏取消固定', en: 'Unpin from taskbar' }, 'pinToStart': { zh: '固定到"开始"屏幕', en: 'Pin to Start' }, 'unpinFromStart': { zh: '从"开始"屏幕取消固定', en: 'Unpin from Start' }, - - // CLSID Default 英文名 → 中文翻译(常见系统/第三方 Shell 扩展) - 'start menu pin': { zh: '固定到"开始"屏幕', en: 'Start Menu Pin' }, - 'taskband pin': { zh: '固定到任务栏', en: 'Taskband Pin' }, - 'open with context menu handler': { zh: '打开方式', en: 'Open With Context Menu Handler' }, - 'encryption context menu': { zh: '加密菜单', en: 'Encryption Context Menu' }, - 'shell extensions for sharing': { zh: '共享', en: 'Shell extensions for sharing' }, - 'new menu handler': { zh: '新建菜单', en: 'New Menu Handler' }, - 'previous versions property page': { zh: '以前的版本', en: 'Previous Versions Property Page' }, - 'work folders context menu handler': { zh: '工作文件夹', en: 'Work Folders Context Menu Handler' }, - 'shellex for cd burning': { zh: '刻录到光盘', en: 'ShellFolder for CD Burning' }, - 'doubao context menu': { zh: '豆包右键菜单', en: 'Doubao Context Menu' }, }; // ---- 数据契约:PS 脚本返回的原始数据 ---- @@ -77,6 +65,7 @@ export interface PsRawShellExtItem { clsidMUIVerb: string | null; clsidDefault: string | null; dllPath: string | null; + dllFileDescription: string | null; siblingMUIVerb: string | null; registryKey: string; } @@ -290,30 +279,22 @@ export class ShellExtNameResolver { } } - // Level 2: CLSID 默认值(尝试翻译已知英文名) + // Level 2: CLSID 默认值 if (raw.clsidDefault && raw.clsidDefault.length >= 2) { if (!isUselessPlain(raw.clsidDefault, fallback)) { - const translated = translateStandardVerb(raw.clsidDefault, this.language); - if (translated) { - log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default translated): "${translated}"`); - return translated; - } log.debug(`[NameResolver] ${fallback} → Level 2 (CLSID Default): "${raw.clsidDefault}"`); return raw.clsidDefault; } } } - // Level 2.5: InprocServer32 DLL FileDescription/ProductName - if (raw.dllPath) { - const dllName = this.win32.getFileVersionInfo(raw.dllPath); - if (dllName && dllName.length >= 2 && dllName.length <= 64) { - 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 2.5: DLL FileDescription(PS 已通过 .NET FileVersionInfo 采集,天然支持 UI 语言) + if (raw.dllFileDescription && raw.dllFileDescription.length >= 2 && raw.dllFileDescription.length <= 64) { + if (!isGenericName(raw.dllFileDescription)) { + log.debug(`[NameResolver] ${fallback} → Level 2.5 (DLL FileDescription): "${raw.dllFileDescription}"`); + return raw.dllFileDescription; } + log.debug(`[NameResolver] ${fallback} — Level 2.5 DLL "${raw.dllFileDescription}" filtered as generic`); } // Level 3: directName plain 字符串 diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index 4c81a26..cb0f131 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -3,38 +3,25 @@ import log from '../utils/logger'; export interface IWin32Shell { resolveIndirect(source: string): string | null; - getFileVersionInfo(dllPath: 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; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getFileVersionInfoSizeW?: (...args: any[]) => [number, number]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getFileVersionInfoW?: (...args: any[]) => boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private verQueryValueW?: (...args: any[]) => [boolean, bigint | null, number]; private indirectCache = new Map(); - private versionCache = new Map(); private koffiAvailable = true; - // 用户 UI 语言 LCID(主语言 ID),用于 DLL 版本信息优先匹配 private readonly uiLangId: number; - /** 暴露给 ShellExtNameResolver 用于标准谓词翻译 */ get uiLanguage(): 'zh' | 'en' { - // 中文主语言 ID = 0x04 (简体/繁体均适用) return this.uiLangId === 0x04 ? 'zh' : 'en'; } constructor() { try { const shlwapi = koffi.load('shlwapi.dll'); - const version = koffi.load('version.dll'); - this.resolveIndirectFn = shlwapi.func('__stdcall', 'SHLoadIndirectString', 'int', [ 'str16', // PCWSTR pszSource 'void *', // PWSTR pszOutBuf @@ -42,32 +29,12 @@ export class Win32Shell implements IWin32Shell { 'void *', // void **ppvReserved ]); - this.getFileVersionInfoSizeW = version.func('__stdcall', 'GetFileVersionInfoSizeW', 'uint32', [ - 'str16', - koffi.out('uint32 *'), - ]); - - this.getFileVersionInfoW = version.func('__stdcall', 'GetFileVersionInfoW', 'bool', [ - 'str16', - 'uint32', - 'uint32', - 'void *', - ]); - - this.verQueryValueW = version.func('__stdcall', 'VerQueryValueW', 'bool', [ - 'void *', - 'str16', - koffi.out(koffi.pointer('void *')), - koffi.out('uint32 *'), - ]); - - // GetUserDefaultUILanguage() returns LANGID (16-bit) const kernel32 = koffi.load('kernel32.dll'); const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); this.uiLangId = getLangId() & 0xFF; - log.info(`[Win32Shell] Initialized OK — koffiAvailable=true, uiLanguage=${this.uiLanguage}`); + log.info(`[Win32Shell] Initialized OK — uiLanguage=${this.uiLanguage}`); } catch (e) { - log.error(`[Win32Shell] FAILED — koffiAvailable=false, reason: ${String(e)}`); + log.error(`[Win32Shell] FAILED — reason: ${String(e)}`); this.koffiAvailable = false; this.uiLangId = 0x09; } @@ -82,8 +49,6 @@ export class Win32Shell implements IWin32Shell { if (cached !== undefined) return cached; try { - // Buffer.alloc + 'void *' 才能在 koffi 中正确传递预分配输出缓冲区 - // koffi.alloc('char16') + decode('str16') 会导致 segfault const buf = Buffer.alloc(2048); const hr = this.resolveIndirectFn(source, buf, 1024, null); if (hr === 0) { @@ -92,7 +57,7 @@ export class Win32Shell implements IWin32Shell { this.indirectCache.set(source, result || null); return result || null; } - log.debug(`[Win32Shell] SHLoadIndirectString failed with HRESULT 0x${(hr >>> 0).toString(16)} for "${source.substring(0, 50)}..."`); + 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) { @@ -101,103 +66,4 @@ export class Win32Shell implements IWin32Shell { return null; } } - - getFileVersionInfo(dllPath: string): string | null { - if (!this.koffiAvailable || !this.getFileVersionInfoSizeW) return null; - const cached = this.versionCache.get(dllPath); - if (cached !== undefined) return cached; - - try { - const fvResult = this.getFileVersionInfoSizeW!(dllPath, [0]); - const rawSize = Array.isArray(fvResult) ? fvResult[0] : fvResult; - const size = Number(rawSize); - if (!size || size === 0) { - this.versionCache.set(dllPath, null); - return null; - } - - const data = Buffer.alloc(size); - const fvOk = this.getFileVersionInfoW!(dllPath, 0, size, data); - if (!fvOk) { - log.debug(`[Win32Shell] GetFileVersionInfoW failed for "${dllPath}"`); - this.versionCache.set(dllPath, null); - return null; - } - - // Query translation table - const vtResult = this.verQueryValueW!(data, '\\VarFileInfo\\Translation'); - const transPtr: bigint | null = Array.isArray(vtResult) ? vtResult[1] : null; - const transLen = Number(Array.isArray(vtResult) ? vtResult[2] : 0); - if (!transPtr || transLen < 4) { - log.debug(`[Win32Shell] No Translation table in "${dllPath}"`); - this.versionCache.set(dllPath, null); - return null; - } - - // 读取语言/代码页对,UI 语言优先(修复:原版本按文件顺序遍历,始终返回英文) - const langKeys: string[] = []; - const uiLangPrefixed: string[] = []; - const numPairs = transLen / 4; - for (let i = 0; i < numPairs; i++) { - const offset = i * 4; - const lang = this.readUInt16(transPtr, offset); - const cp = this.readUInt16(transPtr, offset + 2); - const key = `${lang.toString(16).padStart(4, '0').toUpperCase()}${cp.toString(16).padStart(4, '0').toUpperCase()}`; - // UI 语言匹配的插入队首 - if ((lang & 0xFF) === this.uiLangId) { - uiLangPrefixed.push(key); - } else { - langKeys.push(key); - } - } - const orderedKeys = [...uiLangPrefixed, ...langKeys]; - log.debug(`[Win32Shell] "${dllPath}" languages: ${orderedKeys.join(', ')} (UI lang 0x${this.uiLangId.toString(16)})`); - - // Query FileDescription / ProductName for each language (UI language first) - for (const langKey of orderedKeys) { - const descResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\FileDescription`); - const descPtr: bigint | null = Array.isArray(descResult) ? descResult[1] : null; - const descLen = Number(Array.isArray(descResult) ? descResult[2] : 0); - if (descPtr && descLen > 0) { - const desc = koffi.decode(descPtr, 'str16'); - if (desc && desc.length >= 2 && desc.length <= 64) { - log.debug(`[Win32Shell] FileDescription for "${dllPath}" [${langKey}]: "${desc}"`); - this.versionCache.set(dllPath, desc); - return desc; - } - } - - const prodResult = this.verQueryValueW!(data, `\\StringFileInfo\\${langKey}\\ProductName`); - const prodPtr: bigint | null = Array.isArray(prodResult) ? prodResult[1] : null; - const prodLen = Number(Array.isArray(prodResult) ? prodResult[2] : 0); - if (prodPtr && prodLen > 0) { - const prod = koffi.decode(prodPtr, 'str16'); - if (prod && prod.length >= 2 && prod.length <= 64) { - log.debug(`[Win32Shell] ProductName for "${dllPath}" [${langKey}]: "${prod}"`); - this.versionCache.set(dllPath, prod); - return prod; - } - } - } - - log.debug(`[Win32Shell] No suitable version string found in "${dllPath}"`); - this.versionCache.set(dllPath, null); - return null; - } catch (e) { - log.warn('[Win32Shell] GetFileVersionInfo failed for', dllPath, ':', String(e)); - this.versionCache.set(dllPath, null); - return null; - } - } - - /** - * 从 koffi 指针地址读取 uint16 值 - * ptr: koffi out 参数返回的 bigint 地址 - * offset: 字节偏移量 - */ - private readUInt16(ptr: bigint, offset: number): number { - // koffi.as 将 bigint 地址解释为指定类型的指针 - const typed = koffi.as(ptr + BigInt(offset), 'uint16 *'); - return koffi.decode(typed, 'uint16') as unknown as number; - } } diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 5e7158d..9905ecf 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -23,7 +23,6 @@ function createMockPs(): MockedObject { function createMockResolver(resolveClassicName?: (raw: any) => string, resolveExtName?: (raw: any, cmdStore: any) => string): ShellExtNameResolver { const win32: IWin32Shell = { resolveIndirect: vi.fn().mockReturnValue(null), - getFileVersionInfo: vi.fn().mockReturnValue(null), uiLanguage: 'zh', }; const resolver = new ShellExtNameResolver(win32); diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts index fea11e0..fb0c060 100644 --- a/tests/unit/main/services/ShellExtNameResolver.test.ts +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -10,7 +10,6 @@ import { IWin32Shell } from '@/main/services/Win32Shell'; function createMockWin32(lang: 'zh' | 'en' = 'zh'): IWin32Shell { return { resolveIndirect: vi.fn().mockReturnValue(null), - getFileVersionInfo: vi.fn().mockReturnValue(null), uiLanguage: lang, }; } @@ -40,6 +39,7 @@ function createShellExtItem(overrides: Partial = {}): PsRawSh clsidMUIVerb: null, clsidDefault: null, dllPath: null, + dllFileDescription: null, siblingMUIVerb: null, registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\TestExt', ...overrides, @@ -242,21 +242,22 @@ describe('ShellExtNameResolver', () => { }); // Level 2.5: DLL FileDescription - it('Level 2.5: 应返回 DLL FileDescription', () => { - vi.mocked(win32.getFileVersionInfo).mockReturnValue('阿里云盘'); + it('Level 2.5: 应返回 DLL FileDescription(PS 采集)', () => { const item = createShellExtItem({ clsidLocalizedString: null, clsidMUIVerb: null, clsidDefault: null, - dllPath: 'C:\\Program Files\\YunShellExt\\YunShellExt64.dll', + dllFileDescription: '阿里云盘', }); expect(resolver.resolveExtName(item, cmdStore)).toBe('阿里云盘'); }); it('Level 2.5: 应过滤泛型 DLL 描述(如 "Vim Shell Extension")', () => { - vi.mocked(win32.getFileVersionInfo).mockReturnValue('Vim Shell Extension'); const item = createShellExtItem({ - dllPath: 'C:\\Vim\\gvimext.dll', + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllFileDescription: 'Vim Shell Extension', }); expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); // fallback }); From 4e86534c86c1e45b66cb1f9db33f3062641d9380 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:17:51 +0800 Subject: [PATCH 26/31] =?UTF-8?q?fix(shellex):=20Level=202.5=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20ProductName=20=E5=9B=9E=E9=80=80=20+=20=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E7=AD=89=E4=BA=8E=20fallback=20=E7=9A=84=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PS 脚本额外采集 dllProductName(用户可见产品名,优于组件描述) - Level 2.5: FileDescription → ProductName 顺序尝试 - 新增 isUselessPlain 检查:DLL 描述等于 fallback 时跳过 (如 YunShellExt.dll FileDescription="YunShellExt" 被跳过) - ResolveTrace 增加 dllProd 字段 Co-Authored-By: Claude Opus 4.7 --- docs/context-menu-parsing-logic.md | 129 +++++++----------- src/main/services/PowerShellBridge.ts | 7 +- src/main/services/RegistryService.ts | 2 +- src/main/services/ShellExtNameResolver.ts | 18 ++- .../services/ShellExtNameResolver.test.ts | 1 + 5 files changed, 69 insertions(+), 88 deletions(-) diff --git a/docs/context-menu-parsing-logic.md b/docs/context-menu-parsing-logic.md index 680a739..d54db42 100644 --- a/docs/context-menu-parsing-logic.md +++ b/docs/context-menu-parsing-logic.md @@ -13,40 +13,15 @@ ContextMaster 的右键菜单解析采用分层架构,从 UI 到底层注册表操作分为 6 个主要层次: -``` -┌─────────────────────────────────────────────────────────┐ -│ Renderer Layer (UI) │ -│ mainPage.ts - 用户交互 │ -└──────────────────────────┬──────────────────────────────┘ - │ IPC -┌──────────────────────────▼──────────────────────────────┐ -│ IPC Handlers (registry.ts) │ -│ Electron IPC 通信层 │ -└──────────────────────────┬──────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────┐ -│ MenuManagerService.ts - 菜单管理服务 │ -│ 业务逻辑、缓存控制、事务管理 │ -└──────────────────────────┬──────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────┐ -│ RegistryService.ts - 注册表服务 │ -│ Classic Shell + Shell Ext 解析、缓存 │ -└──────────────────────────┬──────────────────────────────┘ - │ - ┌──────────────────┴──────────────────┐ - │ │ -┌───────▼────────┐ ┌───────▼────────┐ -│PowerShell │ │ShellExtName │ -│ Bridge.ts │ │ Resolver.ts │ -│ PowerShell │ │ 名称解析器 │ -│ 脚本执行 │ │ │ -└────────────┘ └────────────┘ - │ │ -┌───────▼───────────────────────────────────┐ -│ Win32Shell.ts - Windows API │ -│ SHLoadIndirectString │ -└───────────────────────────────────────┘ +```mermaid +graph TB + A[Renderer Layer UI
mainPage.ts - 用户交互] -->|IPC| B[IPC Handlers registry.ts
Electron IPC 通信层] + B --> C[MenuManagerService.ts
菜单管理服务
业务逻辑、缓存控制、事务管理] + C --> D[RegistryService.ts
注册表服务
Classic Shell + Shell Ext 解析、缓存] + D --> E[PowerShellBridge.ts
PowerShell 脚本执行] + D --> F[ShellExtNameResolver.ts
名称解析器] + E --> G[Win32Shell.ts
Windows API
SHLoadIndirectString] + F --> G ``` --- @@ -55,52 +30,46 @@ ContextMaster 的右键菜单解析采用分层架构,从 UI 到底层注册 ### 1. 用户请求菜单列表的完整流程 -``` -用户点击场景导航 - ↓ -[Renderer: loadScene(scene) - ↓ -检查 Renderer 缓存 (2分钟 TTL) - ↓ -[IPC]: REGISTRY_GET_ITEMS - ↓ -[MenuManagerService]: getMenuItems() - ↓ -检查 MenuManager 缓存 (5分钟 TTL) / in-flight 去重 - ↓ -[RegistryService]: getMenuItems() - ↓ -检查 RegistryCache - ↓ -┌───────────────────────────────────────┐ -│ 并行执行 PowerShell 脚本: │ -│ 1. buildGetItemsScript() │ -│ - Classic Shell 条目 │ -│ 2. buildGetShellExtItemsScript() │ -│ - Shell Ext 条目 │ -└───────────────────────────────────────┘ - ↓ -┌───────────────────────────────────────┐ -│ [PowerShellBridge]: execute() │ -│ - 信号量控制并发 (max 3) │ -│ - 执行 PowerShell.exe/pwsh.exe │ -│ - 解析 JSON 返回 │ -└───────────────────────────────────────┘ - ↓ -┌───────────────────────────────────────┐ -│ [ShellExtNameResolver]: │ -│ - resolveClassicName() │ -│ - resolveExtName() │ -│ (多级回退链解析显示名称) │ -└───────────────────────────────────────┘ - ↓ -[RegistryService]: 清理显示名称 (移除快捷键、括号等) - ↓ -写入各级缓存 - ↓ -返回 MenuItemEntry[] - ↓ -[Renderer]: 渲染列表 +```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 + + User->>R: 点击场景导航 + R->>R: 检查 Renderer 缓存 2分钟 TTL + alt 缓存未命中 + R->>I: REGISTRY_GET_ITEMS + I->>M: getMenuItems() + M->>M: 检查 MenuManager 缓存 5分钟 TTL / in-flight 去重 + alt 缓存未命中 + M->>S: getMenuItems() + S->>S: 检查 RegistryCache + alt 缓存未命中 + par 并行执行 PowerShell 脚本 + S->>P: buildGetItemsScript()
读取 Classic Shell + and + S->>P: buildGetShellExtItemsScript()
读取 Shell Ext + end + P->>P: 信号量控制并发 max 3
执行 PowerShell.exe/pwsh.exe
解析 JSON 返回 + P-->>S: 返回原始数据 + S->>N: resolveClassicName() / resolveExtName()
多级回退链解析显示名称 + N-->>S: 返回解析后的名称 + S->>S: 清理显示名称 移除快捷键、括号等 + S->>S: 写入 RegistryCache + end + S-->>M: MenuItemEntry[] + M->>M: 写入 MenuManager 缓存 + end + M-->>I: MenuItemEntry[] + I-->>R: MenuItemEntry[] + R->>R: 写入 Renderer 缓存 + R->>R: 渲染列表 + end ``` ### 2. 详细步骤说明 diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index e8b5342..f8452aa 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -278,14 +278,18 @@ $result = @($handlers | ForEach-Object { } } } - # DLL FileDescription(.NET FileVersionInfo,天然支持 UI 语言,无需 koffi) + # 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 @@ -310,6 +314,7 @@ $result = @($handlers | ForEach-Object { clsidDefault = $clsidDefault dllPath = $dllPath dllFileDescription = $dllFileDescription + dllProductName = $dllProductName siblingMUIVerb = $siblingMUIVerb registryKey = [string]$regKey } diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 236f6b3..b83c2d1 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -131,7 +131,7 @@ export class RegistryService { 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 || ''}" dllDesc="${raw.dllFileDescription || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); + 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 || ''}" dllDesc="${raw.dllFileDescription || ''}" dllProd="${raw.dllProductName || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); } // 写入缓存 diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index 069e57c..b6935d7 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -66,6 +66,7 @@ export interface PsRawShellExtItem { clsidDefault: string | null; dllPath: string | null; dllFileDescription: string | null; + dllProductName: string | null; siblingMUIVerb: string | null; registryKey: string; } @@ -288,13 +289,18 @@ export class ShellExtNameResolver { } } - // Level 2.5: DLL FileDescription(PS 已通过 .NET FileVersionInfo 采集,天然支持 UI 语言) - if (raw.dllFileDescription && raw.dllFileDescription.length >= 2 && raw.dllFileDescription.length <= 64) { - if (!isGenericName(raw.dllFileDescription)) { - log.debug(`[NameResolver] ${fallback} → Level 2.5 (DLL FileDescription): "${raw.dllFileDescription}"`); - return raw.dllFileDescription; + // 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`); } - log.debug(`[NameResolver] ${fallback} — Level 2.5 DLL "${raw.dllFileDescription}" filtered as generic`); } // Level 3: directName plain 字符串 diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts index fb0c060..82d6a7f 100644 --- a/tests/unit/main/services/ShellExtNameResolver.test.ts +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -40,6 +40,7 @@ function createShellExtItem(overrides: Partial = {}): PsRawSh clsidDefault: null, dllPath: null, dllFileDescription: null, + dllProductName: null, siblingMUIVerb: null, registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\TestExt', ...overrides, From a2a8ff0611c434c0ec495b1b301aa5dff48ed8c9 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:21:58 +0800 Subject: [PATCH 27/31] =?UTF-8?q?feat(shellex):=20=E6=96=B0=E5=A2=9E=20Lev?= =?UTF-8?q?el=201.6=20ProgID=20=E8=A7=A3=E6=9E=90=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLSID → ProgID → HKCR\{ProgID}\(Default) → 应用程序名 优先级: CommandStore(1.7) 之后, Plain text(Phase C) 之前 案例: YunShellExt CLSID → ProgID "BaiduNetdisk.YunShellExt" → HKCR\BaiduNetdisk.YunShellExt\(Default) = "百度网盘" Co-Authored-By: Claude Opus 4.7 --- src/main/services/PowerShellBridge.ts | 10 ++++++++++ src/main/services/RegistryService.ts | 2 +- src/main/services/ShellExtNameResolver.ts | 9 +++++++++ tests/unit/main/services/ShellExtNameResolver.test.ts | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index f8452aa..99f33ce 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -276,6 +276,15 @@ $result = @($handlers | ForEach-Object { $dllRaw = (Get-Item -LiteralPath $inprocPath).GetValue('') if ($dllRaw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllRaw) } } + # 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 } + } + } } } # DLL 版本资源(.NET FileVersionInfo,天然支持 UI 语言,无需 koffi) @@ -315,6 +324,7 @@ $result = @($handlers | ForEach-Object { dllPath = $dllPath dllFileDescription = $dllFileDescription dllProductName = $dllProductName + progIdName = $progIdName siblingMUIVerb = $siblingMUIVerb registryKey = [string]$regKey } diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index b83c2d1..333023f 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -131,7 +131,7 @@ export class RegistryService { 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 || ''}" dllDesc="${raw.dllFileDescription || ''}" dllProd="${raw.dllProductName || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); + 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 || ''}"`); } // 写入缓存 diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index b6935d7..e6be60c 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -67,6 +67,7 @@ export interface PsRawShellExtItem { dllPath: string | null; dllFileDescription: string | null; dllProductName: string | null; + progIdName: string | null; siblingMUIVerb: string | null; registryKey: string; } @@ -246,6 +247,14 @@ export class ShellExtNameResolver { } } + // 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 && diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts index 82d6a7f..b7c20fb 100644 --- a/tests/unit/main/services/ShellExtNameResolver.test.ts +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -41,6 +41,7 @@ function createShellExtItem(overrides: Partial = {}): PsRawSh dllPath: null, dllFileDescription: null, dllProductName: null, + progIdName: null, siblingMUIVerb: null, registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\TestExt', ...overrides, From e79a9248f22f87ab262763d1b1cc408cf47a87d9 Mon Sep 17 00:00:00 2001 From: tanzz Date: Thu, 7 May 2026 05:27:29 +0800 Subject: [PATCH 28/31] =?UTF-8?q?docs:=20=E5=85=A8=E9=9D=A2=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20context-menu-parsing-logic.md=20=E4=B8=8E=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=AE=9E=E7=8E=B0=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Shell Ext 完整解析链 (Phase A/B/C 三阶段 + 所有 Level) - 新增 DLL FileDescription/ProductName 采集说明 (PS FileVersionInfo) - 新增 Level 1.6 ProgID 解析链 - 新增 CommandStore 索引、标准动词翻译表、泛型名过滤 (Group A-D) - Win32Shell 移除已删除的 getFileVersionInfo,仅保留 resolveIndirect - 新增诊断与调试章节 (IPC diagnose + ResolveTrace 日志) - 更新数据结构 (PsRawClassicItem / PsRawShellExtItem 完整字段) - 更新代表案例表、性能优化、错误处理 Co-Authored-By: Claude Opus 4.7 --- docs/context-menu-parsing-logic.md | 514 ++++++++++++++++------------- 1 file changed, 292 insertions(+), 222 deletions(-) diff --git a/docs/context-menu-parsing-logic.md b/docs/context-menu-parsing-logic.md index d54db42..4cd3ebb 100644 --- a/docs/context-menu-parsing-logic.md +++ b/docs/context-menu-parsing-logic.md @@ -4,8 +4,9 @@ 1. [架构概览](#架构概览) 2. [完整数据流](#完整数据流) 3. [核心模块详解](#核心模块详解) -4. [缓存机制](#缓存机制) -5. [启用/禁用逻辑](#启用禁用逻辑) +4. [Shell Ext 名称解析链](#shell-ext-名称解析链) +5. [缓存机制](#缓存机制) +6. [启用/禁用逻辑](#启用禁用逻辑) --- @@ -15,15 +16,16 @@ ContextMaster 的右键菜单解析采用分层架构,从 UI 到底层注册 ```mermaid graph TB - A[Renderer Layer UI
mainPage.ts - 用户交互] -->|IPC| B[IPC Handlers registry.ts
Electron IPC 通信层] + 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 脚本执行] - D --> F[ShellExtNameResolver.ts
名称解析器] - E --> G[Win32Shell.ts
Windows API
SHLoadIndirectString] - F --> G + D --> E[PowerShellBridge.ts
PowerShell 脚本
注册表枚举 + DLL 版本信息采集] + D --> F[ShellExtNameResolver.ts
名称解析器
多级回退链 + 过滤 + 翻译] + F --> G[Win32Shell.ts
koffi FFI
SHLoadIndirectString] ``` +> **关键设计决策**:名称解析逻辑全部在 TypeScript 侧(ShellExtNameResolver),PS 脚本只负责数据采集(注册表原始值 + DLL FileVersionInfo)。 + --- ## 完整数据流 @@ -39,27 +41,29 @@ sequenceDiagram participant S as RegistryService participant P as PowerShellBridge participant N as ShellExtNameResolver + participant W as Win32Shell User->>R: 点击场景导航 - R->>R: 检查 Renderer 缓存 2分钟 TTL + R->>R: 检查 Renderer 缓存 (2min TTL) alt 缓存未命中 R->>I: REGISTRY_GET_ITEMS - I->>M: getMenuItems() - M->>M: 检查 MenuManager 缓存 5分钟 TTL / in-flight 去重 + I->>M: getMenuItems(scene) + M->>M: 检查缓存 (5min TTL) / in-flight 去重 alt 缓存未命中 - M->>S: getMenuItems() - S->>S: 检查 RegistryCache + M->>S: getMenuItems(scene) + S->>S: 检查 RegistryCache (30s TTL) alt 缓存未命中 - par 并行执行 PowerShell 脚本 - S->>P: buildGetItemsScript()
读取 Classic Shell + par 并行执行 PowerShell + S->>P: buildGetItemsScript() → Classic Shell and - S->>P: buildGetShellExtItemsScript()
读取 Shell Ext + S->>P: buildGetShellExtItemsScript() → Shell Ext end - P->>P: 信号量控制并发 max 3
执行 PowerShell.exe/pwsh.exe
解析 JSON 返回 - P-->>S: 返回原始数据 - S->>N: resolveClassicName() / resolveExtName()
多级回退链解析显示名称 - N-->>S: 返回解析后的名称 - S->>S: 清理显示名称 移除快捷键、括号等 + 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[] @@ -67,188 +71,215 @@ sequenceDiagram end M-->>I: MenuItemEntry[] I-->>R: MenuItemEntry[] - R->>R: 写入 Renderer 缓存 R->>R: 渲染列表 end ``` -### 2. 详细步骤说明 - -#### 步骤 1: Renderer 发起请求 (`mainPage.ts`) +### 2. 启动初始化流程 -```typescript -// 用户点击导航 → loadScene(scene) -// 1. 检查 Renderer 本地缓存 (2分钟 TTL) -// 2. 缓存命中 → 直接显示 + 后台刷新 (剩余 <30s 时) -// 3. 缓存未命中 → 调用 window.api.getMenuItems(scene) ``` - -#### 步骤 2: IPC 通信 (`registry.ts`) - -```typescript -// IPC 通道: REGISTRY_GET_ITEMS -// 包装: wrapHandler() 统一错误处理 -// 调用: menuManager.getMenuItems(scene, false, 'high') -``` - -#### 步骤 3: MenuManagerService (`MenuManagerService.ts`) - -```typescript -// 1. 检查自身缓存 (5分钟 TTL) -// 2. 检查 in-flight 请求 (避免重复请求) -// 3. 调用 registry.getMenuItems() -// 4. 写入缓存 -``` - -#### 步骤 4: RegistryService (`RegistryService.ts`) - -```typescript -// 1. 检查 RegistryCache -// 2. 构建 PowerShell 脚本 -// 3. 并行执行两个脚本: -// - Classic Shell: HKCR\\shell -// - Shell Ext: HKCR\\shellex\ContextMenuHandlers -// 4. 解析返回的原始数据 -// 5. 通过 ShellExtNameResolver 解析显示名称 -// 6. 清理显示名称 (移除 &(X)、&X、括号等 -// 7. 写入 RegistryCache -``` - -#### 步骤 5: PowerShell 脚本执行 (`PowerShellBridge.ts`) - -```typescript -// 信号量: 最多 3 个并发 PowerShell 进程 -// 优先级队列: high 优先级插队 -// 超时: 30秒 -// 提权: executeElevated() 用于写入操作 +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 - 注册表服务 +### 1. RegistryService — 注册表服务 **文件**: `src/main/services/RegistryService.ts` -**职责**: -- 协调 Classic Shell 和 Shell Ext 条目读取 -- 名称解析协调 -- 缓存管理 -- 启用/禁用操作 -- 事务回滚 +**职责**: 协调 Classic Shell 和 Shell Ext 条目读取、调用 ShellExtNameResolver 解析名称、缓存管理、启用/禁用操作、事务回滚。 **注册表路径映射**: -```typescript -const SCENE_REG_ROOTS = { - Desktop: 'HKCR\\DesktopBackground\\Shell', - File: 'HKCR\\*\\shell', - Folder: 'HKCR\\Directory\\shell', - Drive: 'HKCR\\Drive\\shell', - DirectoryBackground:'HKCR\\Directory\\Background\\shell', - RecycleBin: 'HKCR\\CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell', -}; - -const SCENE_SHELLEX_PATHS = { - Desktop: 'HKCR\\DesktopBackground\\shellex\\ContextMenuHandlers', - // ... 同上面一样 -}; -``` +| 场景 | 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 桥接 +### 2. PowerShellBridge — PowerShell 桥接 **文件**: `src/main/services/PowerShellBridge.ts` -**职责**: -- 构建 PowerShell 脚本 -- 执行脚本并解析 JSON -- 并发控制 (信号量) -- 提权执行 (UAC) +**职责**: 构建 PowerShell 脚本、执行并解析 JSON、并发控制(信号量 max 3)、提权执行(UAC)。 **关键脚本**: -#### buildGetItemsScript - 获取 Classic Shell 条目 -```powershell -# 读取 HKCR\\shell -# 采集: -# - subKeyName (子键名) -# - MUIVerb (多语言动词) -# - (默认值) -# - LocalizedDisplayName -# - Icon -# - LegacyDisable (是否禁用) -# - command (命令子键默认值) -``` +#### buildGetItemsScript — Classic Shell 条目采集 -#### buildGetShellExtItemsScript - 获取 Shell Ext 条目 -```powershell -# 读取 HKCR\\shellex\ContextMenuHandlers -# 采集: -# - handlerKeyName (处理程序键名 -# - defaultVal (默认值 = CLSID) -# - CLSID\{clsid} 的: -# - LocalizedString -# - MUIVerb -# - (默认值) -# - InprocServer32 (DLL路径) -# - DLL FileDescription (通过 .NET FileVersionInfo) -# - sibling shell key 的 MUIVerb -``` +返回 `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 - 名称解析器 +### 3. ShellExtNameResolver — 名称解析器 **文件**: `src/main/services/ShellExtNameResolver.ts` -**职责**: -- 解析 Classic Shell 显示名称 -- 解析 Shell Ext 显示名称 (多级回退链) -- 标准动词翻译 (open → 打开, edit → 编辑, etc.) -- 通用名称过滤 +**职责**: 解析 Classic Shell 和 Shell Ext 显示名称、标准动词翻译、泛型名称过滤、CommandStore 索引管理。 + +#### Classic Shell 名称解析 (`resolveClassicName`) -#### Classic Shell 名称解析优先级: ``` -1. rawMUIVerb (@间接字符串 → SHLoadIndirectString -2. rawDefault (默认值) -3. rawLocalizedDisplayName (@间接字符串 → SHLoadIndirectString) -4. 标准动词翻译 (subKeyName) -5. subKeyName (兜底) +1. rawMUIVerb → @/ms-resource: → SHLoadIndirectString (间接) 或直接返回 (明文) +2. rawDefault → @/ms-resource: → SHLoadIndirectString 或直接返回 +3. rawLocalizedDisplayName → 同上 +4. 标准动词翻译 → translateStandardVerb(subKeyName) +5. subKeyName → 最终兜底 ``` -#### Shell Ext 名称解析优先级 (多级回退链): +#### Shell Ext 名称解析 (`resolveExtName`) — 见下一节完整解析链 -``` -Level 0: directName (@间接字符串 -Level 1-indirect: CLSID.LocalizedString (@间接) -Level 1.3-indirect: sibling shell key MUIVerb (@间接) -Level 1.5-indirect: CLSID.MUIVerb (@间接) -Level 1.7: CommandStore 索引 (Windows 内置命令) -Level 1-plain: CLSID.LocalizedString (明文) -Level 1.3-plain: sibling shell key MUIVerb (明文) -Level 1.5-plain: CLSID.MUIVerb (明文) -Level 2: CLSID 默认值 -Level 2.5: DLL FileDescription -Level 3: directName (明文) -标准动词翻译 (cleanName) -Fallback: cleanName (键名) -``` +#### 泛型名称过滤 (`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 封装 +### 4. Win32Shell — Windows API 封装 **文件**: `src/main/services/Win32Shell.ts` -**职责**: -- 调用 `SHLoadIndirectString` (shlwapi.dll) -- 解析 @dll,-id 格式的间接字符串 -- 解析 ms-resource: 格式 -- 获取用户 UI 语言 +**职责**: 封装 `SHLoadIndirectString` (shlwapi.dll) 通过 koffi FFI,获取用户 UI 语言。 + +**接口** (`IWin32Shell`): ```typescript -// 示例: -// "@shell32.dll,-51234 → "打开" -// "ms-resource:Windows.System.UserProfile.ProfileTileDisplayName" → "你的账户" +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` | "搜索/打开/编辑" | + --- ## 缓存机制 @@ -258,24 +289,26 @@ Fallback: cleanName (键名) ``` 1. Renderer Cache (mainPage.ts) ├─ TTL: 2 分钟 - ├─ stale-while-revalidate - └─ 剩余 <30s 时后台刷新 + ├─ stale-while-revalidate (剩余 <30s 时后台刷新) + └─ 场景隔离 2. MenuManager Cache (MenuManagerService.ts) ├─ TTL: 5 分钟 - └─ in-flight 去重 + ├─ in-flight 去重 (防止并发重复请求) + └─ 场景隔离 -3. Registry Cache (RegistryService.ts) - └─ 无 TTL,显式失效 +3. Registry Cache (RegistryService.ts via RegistryCache.ts) + ├─ TTL: 30 秒 + ├─ 场景隔离 + └─ 命中/未命中/淘汰统计 ``` ### 缓存失效时机 -``` -- 单个条目切换 → invalidateCache(scene) -- 批量操作 → invalidateAllCache() -- 应用启动 → 无缓存,首次加载 -``` +- 单个条目切换 (enable/disable) → `invalidateCache(scene)` +- 批量操作 → `invalidateAllCache()` +- 备份还原 → `invalidateAllCache()` +- 应用启动 → 无缓存,首次加载 + preloadAllScenes 预热 --- @@ -284,96 +317,133 @@ Fallback: cleanName (键名) ### Classic Shell 条目 ``` -启用: - - 删除 LegacyDisable 字符串值 - - 注册表键保持不变 - -禁用: - - 设置 LegacyDisable 字符串值 (空字符串) - - 注册表键保持不变 +启用: 删除 LegacyDisable 字符串值 +禁用: 设置 LegacyDisable = "" (空字符串) ``` ### Shell Ext 条目 ``` -启用: - - 重命名键: "-Name" → "Name" - -禁用: - - 重命名键: "Name" → "-Name" +启用: 重命名键 "-Name" → "Name" +禁用: 重命名键 "Name" → "-Name" +registryKey 不变 (已归一化为不带 '-' 前缀) ``` ### 事务与回滚 ```typescript -// 批量操作前: createRollbackPoint() -// 记录所有条目原始状态 -// 失败时: rollback() → 恢复所有条目 -// 成功时: commitTransaction() +// 批量操作前: createRollbackPoint(items) +// → 记录所有条目原始 isEnabled 状态 +// 逐条执行 +// 成功: commitTransaction() → 清除回滚数据 +// 失败: rollback() → 逐条恢复原始状态 ``` --- ## 数据结构 -### MenuItemEntry - 菜单条目 +### 关键接口 ```typescript -interface MenuItemEntry { - id: number; // 自增 ID - name: string; // 显示名称 - command: string; // 命令 / CLSID - iconPath: string | null; // 图标路径 - isEnabled: boolean; // 是否启用 - source: string; // 来源 (Shell Ext 处理程序名 - menuScene: MenuScene; // 所属场景 - registryKey: string; // 注册表相对路径 - type: MenuItemType; // System / Custom / ShellExt - dllPath?: string | null; // Shell Ext DLL 路径 +// 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; } -``` -### MenuItemType - 条目类型 +// 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; +} -```typescript enum MenuItemType { System, // 系统内置 (无 command) - Custom, // 自定义 (有 command) - ShellExt, // Shell 扩展 (COM) + Custom, // 自定义 (有 command) + ShellExt, // Shell 扩展 (COM) } ``` --- -## 关键代码位置 +## 诊断与调试 -| 功能 | 文件 | 行号 | -|------|------|------| -| Classic Shell 读取 | RegistryService.ts | 57-145 | -| Shell Ext 读取 | RegistryService.ts | 106-126 | -| 名称解析 (Classic) | ShellExtNameResolver.ts | 153-178 | -| 名称解析 (ShellExt) | ShellExtNameResolver.ts | 181-319 | -| PS 脚本构建 | PowerShellBridge.ts | 168-319 | -| IPC 处理器 | registry.ts | 9-54 | -| UI 渲染 | mainPage.ts | 123-195 | +### 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) -2. **缓存分层**: 三级缓存,TTL 递减 -3. **并行读取**: Classic + ShellExt 并行执行 +1. **并发控制**: PowerShellBridge 信号量 (max 3),支持 high/normal 优先级 +2. **缓存分层**: 三级缓存,TTL 递减 (30s → 5min → 2min) +3. **并行读取**: Classic + ShellExt PS 脚本并行执行 4. **Stale-while-revalidate**: Renderer 缓存命中后台刷新 -5. **预加载**: 启动时后台 preloadAllScenes() -6. **去重**: in-flight 请求避免重复加载 +5. **预加载**: 启动时串行预热所有 6 个场景 +6. **in-flight 去重**: 避免并发重复请求 +7. **间接字符串缓存**: Win32Shell 内对 `resolveIndirect` 结果无限缓存 --- ## 错误处理 -1. **单条失败不影响整体 (per-item try/catch -2. **PowerShell 失败返回空数组 [] -3. **名称解析失败回退到键名 -4. **事务回滚机制 -5. **IPC 统一错误包装 IpcResult +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 }` From 893736d48c4dd1e402f8d60cbaa99acc68f2f13d Mon Sep 17 00:00:00 2001 From: tanzz Date: Fri, 8 May 2026 00:40:01 +0800 Subject: [PATCH 29/31] =?UTF-8?q?fix(ps):=20PS=20=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=20UTF-8=20=E7=BC=96=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AD=E6=96=87=E4=B9=B1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所有 PowerShell 脚本前统一插入: [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 解决 PS 默认 UTF-16 LE 或系统代码页 (GBK) 输出与 Node.js execFile UTF-8 解码不匹配导致中文显示为乱码的问题。 Co-Authored-By: Claude Opus 4.7 --- src/main/services/PowerShellBridge.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 99f33ce..ec9e01d 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -63,9 +63,11 @@ export class PowerShellBridge { 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', script], + ['-NonInteractive', '-NoProfile', '-OutputFormat', 'Text', '-Command', utf8Script], { maxBuffer: 10 * 1024 * 1024, timeout: 30000 } ); @@ -103,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 = & { From 09cc89699317654223706cd609c67a1bd1fe0e6a Mon Sep 17 00:00:00 2001 From: tanzz Date: Fri, 8 May 2026 00:47:01 +0800 Subject: [PATCH 30/31] =?UTF-8?q?fix(shellex):=20Level=201.3=20=E5=8F=8D?= =?UTF-8?q?=E5=90=91=E6=89=AB=E6=8F=8F=20CommandStateHandler/DelegateExecu?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 常规 sibling key 查找要求 cleanName 作为 shell verb 键名存在, 但 CLSID 格式的 cleanName (如 {a2a9545d-...}) 不会作为键名。 新增回退: 反向扫描 $shellPath 下所有 verb,查找其 CommandStateHandler 或 DelegateExecute 值等于当前 CLSID 的键, 读取其 MUIVerb。 案例: {a2a9545d-...} → pintostartscreen verb CommandStateHandler = {a2a9545d-...} MUIVerb = @shell32.dll,-XXXXX → resolveIndirect → 中文名 Co-Authored-By: Claude Opus 4.7 --- src/main/services/PowerShellBridge.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index ec9e01d..36ca3c7 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -312,6 +312,17 @@ $result = @($handlers | ForEach-Object { $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') + if (($csh -eq $actualClsid) -or ($de -eq $actualClsid)) { + $mv = $_.GetValue('MUIVerb') + if ($mv) { $siblingMUIVerb = [string]$mv } + } + } + } } $isEnabled = -not $handlerKeyName.StartsWith('-') $regKey = '${shellexSubPath}\\' + $cleanName From adc937e2fb56ab4d947b7ed870ad29296c464cfa Mon Sep 17 00:00:00 2001 From: tanzz Date: Fri, 8 May 2026 00:52:07 +0800 Subject: [PATCH 31/31] =?UTF-8?q?fix(shellex):=20=E6=96=B0=E5=A2=9E=20CLSI?= =?UTF-8?q?D\Shell=20=E5=AD=90=E9=94=AE=20MUIVerb=20+=20=E5=8F=8D=E5=90=91?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=A2=9E=E5=8A=A0=20ExplorerCommandHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PS 脚本读取 CLSID\Shell\*\MUIVerb(COM 对象自身注册的 verb) - 反向扫描增加 ExplorerCommandHandler 匹配 (部分 verb 使用此值而非 CommandStateHandler 指向 CLSID) Co-Authored-By: Claude Opus 4.7 --- src/main/services/PowerShellBridge.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index 36ca3c7..ed74446 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -279,6 +279,14 @@ $result = @($handlers | ForEach-Object { $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 } + } + } # ProgID → 应用程序名(用于 Level 1.6) $progIdVal = $clsidKey.GetValue('ProgID') if ($progIdVal) { @@ -317,7 +325,8 @@ $result = @($handlers | ForEach-Object { Get-ChildItem -LiteralPath $shellPath -ErrorAction SilentlyContinue | ForEach-Object { $csh = $_.GetValue('CommandStateHandler') $de = $_.GetValue('DelegateExecute') - if (($csh -eq $actualClsid) -or ($de -eq $actualClsid)) { + $ech = $_.GetValue('ExplorerCommandHandler') + if (($csh -eq $actualClsid) -or ($de -eq $actualClsid) -or ($ech -eq $actualClsid)) { $mv = $_.GetValue('MUIVerb') if ($mv) { $siblingMUIVerb = [string]$mv } }