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 43d664b..fc532a8 100644 --- a/src/main/services/MenuManagerService.ts +++ b/src/main/services/MenuManagerService.ts @@ -13,32 +13,46 @@ const CACHE_TTL = 5 * 60 * 1000; export class MenuManagerService { private cache = new Map(); + private inFlight = new Map>(); constructor( private readonly registry: RegistryService, private readonly history: OperationHistoryService ) {} - async getMenuItems(scene: MenuScene, forceRefresh = false): Promise { + async getMenuItems(scene: MenuScene, forceRefresh = false, priority: 'high' | 'normal' = 'normal'): Promise { if (!forceRefresh) { const cached = this.cache.get(scene); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { log.debug(`[MenuManager] Cache hit for scene: ${scene}`); return cached.items; } + const existing = this.inFlight.get(scene); + if (existing) { + log.debug(`[MenuManager] In-flight hit for scene: ${scene}`); + return existing; + } } log.debug(`[MenuManager] Loading items for scene: ${scene} (forceRefresh: ${forceRefresh})`); const start = Date.now(); - const items = await this.registry.getMenuItems(scene); - const elapsed = Date.now() - start; - - if (elapsed > 100) { - log.info(`[MenuManager] Loaded ${items.length} items for ${scene} in ${elapsed}ms`); - } + const promise = this.registry.getMenuItems(scene, priority) + .then((items) => { + const elapsed = Date.now() - start; + if (elapsed > 100) { + log.info(`[MenuManager] Loaded ${items.length} items for ${scene} in ${elapsed}ms`); + } + this.cache.set(scene, { items, timestamp: Date.now() }); + this.inFlight.delete(scene); + return items; + }) + .catch((e) => { + this.inFlight.delete(scene); + throw e; + }); - this.cache.set(scene, { items, timestamp: Date.now() }); - return items; + this.inFlight.set(scene, promise); + return promise; } invalidateCache(scene?: MenuScene): void { @@ -51,11 +65,49 @@ export class MenuManagerService { } } + /** + * 获取所有场景的菜单条目(并行加载) + */ + async getAllMenuItems(): Promise> { + const scenes = Object.values(MenuScene) as MenuScene[]; + const results = await Promise.all( + scenes.map(async (scene) => { + try { + const items = await this.registry.getMenuItems(scene); + return { scene, items, success: true }; + } catch (e) { + log.error(`Failed to load scene ${scene}:`, e); + return { scene, items: [] as MenuItemEntry[], success: false }; + } + }) + ); + + const allItems: Record = { + [MenuScene.Desktop]: [], + [MenuScene.File]: [], + [MenuScene.Folder]: [], + [MenuScene.Drive]: [], + [MenuScene.DirectoryBackground]: [], + [MenuScene.RecycleBin]: [], + }; + + for (const result of results) { + allItems[result.scene] = result.items; + } + + return allItems; + } + async enableItem(item: MenuItemEntry): Promise<{ newRegistryKey?: string }> { if (item.isEnabled) return {}; const result = await this.registry.setItemEnabled(item.registryKey, true); if (result.newRegistryKey) item.registryKey = result.newRegistryKey; item.isEnabled = true; + + // 操作成功后清除对应场景的缓存 + this.registry.invalidateCache(item.menuScene); + log.debug(`Cache invalidated for scene ${item.menuScene} after enabling ${item.name}`); + this.history.recordOperation( OperationType.Enable, item.name, @@ -72,6 +124,11 @@ export class MenuManagerService { const result = await this.registry.setItemEnabled(item.registryKey, false); if (result.newRegistryKey) item.registryKey = result.newRegistryKey; item.isEnabled = false; + + // 操作成功后清除对应场景的缓存 + this.registry.invalidateCache(item.menuScene); + log.debug(`Cache invalidated for scene ${item.menuScene} after disabling ${item.name}`); + this.history.recordOperation( OperationType.Disable, item.name, @@ -95,10 +152,14 @@ export class MenuManagerService { const targets = items.filter((i) => !i.isEnabled); if (!targets.length) return; + // 收集需要清除缓存的场景 + const affectedScenes = new Set(); + this.registry.createRollbackPoint(targets); try { for (const item of targets) { await this.enableItem(item); + affectedScenes.add(item.menuScene); } this.registry.commitTransaction(); this.cache.clear(); @@ -112,10 +173,14 @@ export class MenuManagerService { const targets = items.filter((i) => i.isEnabled); if (!targets.length) return; + // 收集需要清除缓存的场景 + const affectedScenes = new Set(); + this.registry.createRollbackPoint(targets); try { for (const item of targets) { await this.disableItem(item); + affectedScenes.add(item.menuScene); } this.registry.commitTransaction(); this.cache.clear(); @@ -124,4 +189,34 @@ export class MenuManagerService { throw new Error(`批量禁用失败,已回滚: ${(e as Error).message}`); } } + + /** + * 后台预热所有场景(fire-and-forget,依赖 PowerShellBridge 信号量控制并发) + * 结果写入内存缓存,供后续 IPC 请求直接命中 + */ + async preloadAllScenes(): Promise { + const scenes = Object.values(MenuScene) as MenuScene[]; + await Promise.all( + scenes.map((scene) => + this.getMenuItems(scene).catch((e) => + log.warn(`[MenuManager] Preload failed for ${scene}:`, e) + ) + ) + ); + log.info('[MenuManager] All scenes preloaded'); + } + + /** + * 获取缓存统计信息 + */ + getCacheStats(): ReturnType { + return this.registry.getCacheStats(); + } + + /** + * 打印缓存统计日志 + */ + logCacheStats(): void { + this.registry.logCacheStats(); + } } diff --git a/src/main/services/OperationHistoryService.ts b/src/main/services/OperationHistoryService.ts index f5e93ce..814eb90 100644 --- a/src/main/services/OperationHistoryService.ts +++ b/src/main/services/OperationHistoryService.ts @@ -75,15 +75,11 @@ export class OperationHistoryService { } function determineSceneFromRegistryKey(registryKey: string): MenuScene { + if (registryKey.includes('Directory\\Background')) return MenuScene.DirectoryBackground; if (registryKey.includes('DesktopBackground')) return MenuScene.Desktop; - if (registryKey.includes('*\\')) return MenuScene.File; - if ( - registryKey.includes('Directory\\shell') && - !registryKey.includes('Directory\\Background') - ) - return MenuScene.Folder; + if (registryKey.includes('CLSID\\{645FF040')) return MenuScene.RecycleBin; if (registryKey.includes('Drive\\shell')) return MenuScene.Drive; - if (registryKey.includes('Directory\\Background')) return MenuScene.DirectoryBackground; - if (registryKey.includes('CLSID')) return MenuScene.RecycleBin; - return MenuScene.File; + if (registryKey.includes('Directory\\shell')) return MenuScene.Folder; + if (registryKey.includes('*\\')) return MenuScene.File; + throw new Error(`无法从注册表路径确定场景: ${registryKey}`); } diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index c017406..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,13 +240,26 @@ ${script} 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 = $key.GetValue('') + $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) @@ -188,154 +320,216 @@ Write-Output '{"ok":true}' /** * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 - * 使用四级级联策略解析本地化名称: - * 1. LocalizedString/FriendlyTypeName → SHLoadIndirectString(解析 @DLL,-ID 格式) - * 2. InprocServer32 DLL 字符串表 → 通用字符串质量筛选(LoadLibraryEx + LoadString) - * 3. CLSID 默认值 - * 4. 处理程序键名(最终兜底) + * 使用三级级联策略解析本地化名称: + * 1. LocalizedString → SHLoadIndirectString(@ 格式)或直接使用 + * 2. CLSID 默认值(可靠、ASCII-safe) + * 3. 处理程序键名(最终兜底) * CmHelper.dll 编译后缓存至 %LOCALAPPDATA%\ContextMaster\,避免重复编译开销 */ buildGetShellExtItemsScript(shellexSubPath: string): string { return ` $ErrorActionPreference = 'SilentlyContinue' New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null -$cmDir = Join-Path $env:LOCALAPPDATA 'ContextMaster' -$cmDll = Join-Path $cmDir 'CmHelper.dll' -$helperLoaded = $false -if (Test-Path $cmDll) { - try { Add-Type -Path $cmDll -ErrorAction Stop; $helperLoaded = $true } catch {} +${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 } -if (-not $helperLoaded) { - $src = @' -using System; -using System.Runtime.InteropServices; -using System.Text; -using System.Collections.Generic; -public class CmHelper { - const uint LOAD_AS_DATA = 2u; - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - static extern int SHLoadIndirectString(string s, StringBuilder buf, int cap, IntPtr r); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - static extern IntPtr LoadLibraryEx(string p, IntPtr h, uint f); - [DllImport("kernel32.dll")] - static extern bool FreeLibrary(IntPtr h); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - static extern int LoadString(IntPtr h, uint id, StringBuilder buf, int cap); - public static string ResolveIndirect(string s) { - if (string.IsNullOrEmpty(s) || !s.StartsWith("@")) return null; - var sb = new StringBuilder(512); - return SHLoadIndirectString(s, sb, 512, IntPtr.Zero) == 0 ? sb.ToString() : null; - } - public static string[] ReadDllStrings(string dll, uint from, uint to) { - var list = new List(); - var hMod = LoadLibraryEx(dll, IntPtr.Zero, LOAD_AS_DATA); - if (hMod == IntPtr.Zero) return list.ToArray(); - try { - for (uint i = from; i <= to; i++) { - var sb = new StringBuilder(512); - if (LoadString(hMod, i, sb, 512) > 0) list.Add(sb.ToString()); - } - } finally { FreeLibrary(hMod); } - return list.ToArray(); - } +# 判断 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 } -'@ - 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 {} +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 {} } -} -function Resolve-ExtName($clsid, $fallback) { if ($clsid -match '^\\{[0-9A-Fa-f-]+\\}$') { $clsidPath = 'HKCR:\\CLSID\\' + $clsid if (Test-Path -LiteralPath $clsidPath) { $clsidKey = Get-Item -LiteralPath $clsidPath - foreach ($valName in @('LocalizedString', 'FriendlyTypeName')) { - $raw = $clsidKey.GetValue($valName) - if ($raw) { - if ($raw.StartsWith('@')) { - try { - $resolved = [CmHelper]::ResolveIndirect($raw) - if ($resolved -and $resolved.Length -ge 2) { return $resolved } - } catch {} - } elseif ($raw.Length -ge 2) { - return $raw - } + # 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 } } } - $inprocPath = Join-Path $clsidPath 'InprocServer32' - if (Test-Path -LiteralPath $inprocPath) { - $dllPath = (Get-Item -LiteralPath $inprocPath).GetValue('') - # Fix 1: 展开 %SystemRoot% 等环境变量,否则 Test-Path 永远返回 $false - if ($dllPath) { - $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllPath) + # 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 } + } + } } - if ($dllPath -and (Test-Path -LiteralPath $dllPath)) { - # Level 2: 通用字符串质量筛选(过滤后取第一条,无硬编码词表,无长度限制) + } + # Level 1.5: MUIVerb(部分扩展如 gvim 通过此键注册显示名) + $muiVerb = $clsidKey.GetValue('MUIVerb') + if ($muiVerb) { + if ($muiVerb.StartsWith('@') -or $muiVerb.StartsWith('ms-resource:')) { 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 } + $resolved = [CmHelper]::ResolveIndirect($muiVerb) + if ($resolved -and $resolved.Length -ge 2) { return $resolved } } catch {} - # Level 2.5: DLL VersionInfo(适用于英文/日文等非中文软件) + } 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 { - $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 + $vs = [CmHelper]::GetLocalizedVerStrings($dllExp) + $candidates = if ($vs) { $vs } else { + $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllExp) + @($vi.FileDescription, $vi.ProductName) } - if ($desc -and $desc.Length -le 80 -and $desc -notmatch '^\\{' -and $desc -notmatch '[\\\\/:*?<>|]') { - return $desc + foreach ($cand in $candidates) { + if ($cand -and $cand.Length -ge 2 -and $cand.Length -le 64) { + if (-not (Test-IsGenericName $cand)) { return $cand } + } } } catch {} } } - $def = $clsidKey.GetValue('') - if ($def) { return [string]$def } } } + # 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 路径) +$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 - $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 + $regKey = '${shellexSubPath}\\' + $cleanName + $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) } + } + } [PSCustomObject]@{ name = [string]$displayName - command = [string]$clsid + command = [string]$actualClsid iconPath = $null isEnabled = [bool]$isEnabled source = [string]$handlerKeyName registryKey = [string]$regKey subKeyName = [string]$handlerKeyName itemType = 'ShellExt' + dllPath = $dllPath } }) $result | ConvertTo-Json -Compress -Depth 3 @@ -366,7 +560,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..68a4b09 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 完全一致 @@ -35,52 +36,70 @@ interface PsMenuItemRaw { registryKey: string; subKeyName: string; itemType?: string; // 'ShellExt' for shell extensions + dllPath?: string | null; } 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 { + async getMenuItems(scene: MenuScene, priority: 'high' | 'normal' = 'normal'): Promise { + // 尝试从缓存读取 + const cached = this.cache.get(scene); + if (cached) { + log.debug(`RegistryService: Returning cached data for ${scene} (${cached.length} items)`); + return cached; + } + const basePath = SCENE_REGISTRY_PATHS[scene]; const shellexPath = SCENE_SHELLEX_PATHS[scene]; + try { - // 读取 Classic Shell 命令 + // 并行读取 Classic Shell 命令 + Shell 扩展(COM ContextMenuHandlers) const script = this.ps.buildGetItemsScript(basePath); - const raw = await this.ps.execute(script); + const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); + const [raw, shellexRaw] = await Promise.all([ + this.ps.execute(script, priority), + this.ps.execute(shellexScript, priority).catch((e) => { + log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); + return [] as PsMenuItemRaw[]; + }), + ]); const items = Array.isArray(raw) ? raw : (raw ? [raw] : []); + const shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; - // 读取 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); - } - - return [...items, ...shellexItems].map((r) => ({ + const result = [...items, ...shellexItems].map((r) => ({ id: this.nextId++, - name: 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), + dllPath: r.dllPath ?? null, })); + + // 写入缓存 + 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 +109,7 @@ export class RegistryService { /** * 启用或禁用单个菜单条目 * ShellExt 通过重命名键(±前缀)实现;Classic Shell 通过 LegacyDisable 值实现 - * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 + * ShellExt 通过重命名键(±前缀)实现,registryKey 已归一化,身份不变 */ async setItemEnabled(registryKey: string, enabled: boolean): Promise<{ newRegistryKey?: string }> { try { @@ -100,7 +119,7 @@ export class RegistryService { return {}; } else { const script = this.ps.buildSetEnabledScript(registryKey, enabled); - await this.ps.executeElevated<{ ok: boolean }>(script); + await this.ps.executeElevated<{ ok: boolean }>(script); return {}; } } catch (e) { @@ -130,14 +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')}`); + } } /** @@ -155,6 +182,34 @@ export class RegistryService { return `${HKCR_PREFIX}\\${SCENE_REGISTRY_PATHS[scene]}`; } + /** + * 清除指定场景的缓存 + */ + invalidateCache(scene: MenuScene): void { + this.cache.invalidate(scene); + } + + /** + * 清除所有缓存 + */ + invalidateAllCache(): void { + this.cache.invalidateAll(); + } + + /** + * 获取缓存统计信息 + */ + getCacheStats(): ReturnType { + return this.cache.getStats(); + } + + /** + * 打印缓存统计日志 + */ + logCacheStats(): void { + this.cache.logStats(); + } + private async setItemEnabledInternal(registryKey: string, enabled: boolean): Promise { if (this.isShellExtKey(registryKey)) { const script = this.ps.buildShellExtToggleScript(registryKey, enabled); @@ -170,12 +225,24 @@ export class RegistryService { return registryKey.includes('shellex') && registryKey.includes('ContextMenuHandlers'); } -private inferSource(subKeyName: string): string { + private inferSource(subKeyName: string): string { 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/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/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 5f03b48..b25f68b 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -2,6 +2,7 @@ import '../api/bridge'; import { MenuScene, MenuItemType } from '../../shared/enums'; import type { MenuItemEntry, ToggleItemParams } from '../../shared/types'; import { t, registerRefreshCallback } from '../i18n'; +import { escapeHtml } from '../utils/html'; export const SCENE_REG_ROOTS: Record = { [MenuScene.Desktop]: 'HKEY_CLASSES_ROOT\\DesktopBackground\\Shell', @@ -29,6 +30,10 @@ let selectedItemId: number | null = null; let filterMode: 'all' | 'enabled' | 'disabled' = 'all'; let loadingScene = false; let currentScene: MenuScene = MenuScene.Desktop; +let pendingScene: MenuScene | null = null; + +const RENDERER_CACHE_TTL = 2 * 60 * 1000; // 2 分钟 +const rendererCache = new Map(); export function refreshCurrentContent(): void { renderItems(); @@ -43,13 +48,45 @@ export function refreshCurrentContent(): void { registerRefreshCallback(refreshCurrentContent); -export async function loadScene(scene: MenuScene): Promise { - if (loadingScene) return; +export async function loadScene(scene: MenuScene, forceRefresh = false): Promise { + if (loadingScene) { + pendingScene = scene; + // 立即更新 header 和导航高亮,表明请求已被接受 + const titleEl = document.getElementById('sceneTitle'); + if (titleEl) titleEl.innerHTML = `${getSceneName(scene)} `; + return; + } loadingScene = true; currentScene = scene; + pendingScene = null; + + // 检查 Renderer 缓存(stale-while-revalidate) + if (!forceRefresh) { + const cached = rendererCache.get(scene); + if (cached && Date.now() - cached.timestamp < RENDERER_CACHE_TTL) { + console.debug(`[Renderer] cache hit: ${scene}`); + currentItems = cached.items; + selectedItemId = null; + resetDetailPanel(); + updateSceneHeader(scene); + renderItems(); + updateStatusBar(scene); + loadingScene = false; + // TTL 剩余不足 30s 时后台静默刷新,避免下次切换出现加载状态 + if (Date.now() - cached.timestamp > RENDERER_CACHE_TTL - 30_000) { + void silentRefreshScene(scene); + } + if (pendingScene !== null) { + const next = pendingScene; + pendingScene = null; + await loadScene(next); + } + return; + } + } const listEl = document.getElementById('itemList'); - if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; + if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; selectedItemId = null; resetDetailPanel(); @@ -59,13 +96,27 @@ export async function loadScene(scene: MenuScene): Promise { if (!result.success) { showError(`${t('main.loadFailed')}: ${result.error}`); - return; + } else { + currentItems = result.data; + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + updateSceneHeader(scene); + renderItems(); + updateStatusBar(scene); } - currentItems = result.data; - updateSceneHeader(scene); - renderItems(); - updateStatusBar(scene); + // 若加载期间有新的场景请求,执行最新的那个 + if (pendingScene !== null) { + const next = pendingScene; + pendingScene = null; + await loadScene(next); + } +} + +async function silentRefreshScene(scene: MenuScene): Promise { + const result = await window.api.getMenuItems(scene); + if (result.success) { + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + } } // ── 渲染条目列表 ── @@ -173,6 +224,8 @@ export async function toggleItem(id: number): Promise { if (result.data.newRegistryKey) { item.registryKey = result.data.newRegistryKey; } + // toggle 后使该场景的 renderer 缓存失效,确保下次切回时拿到最新状态 + rendererCache.delete(item.menuScene); renderItems(); const action = item.isEnabled ? t('history.operation.enable') : t('history.operation.disable'); (window as Window & { showUndo?: (msg: string, itemId: number) => void; invalidateAllScenesCache?: () => void }) @@ -201,9 +254,8 @@ export function showDetail(id: number): void { } return `${SCENE_REG_ROOTS[item.menuScene]}\\${item.registryKey.split('\\').pop()}`; })(); - const regCmdPath = isShellExt - ? `(COM DLL,CLSID: ${item.command})` - : `${regItemPath}\\command`; + const regCmdPath = `${regItemPath}\\command`; + // 在 HTML onclick 属性里,反斜杠会被 JS 当转义前缀消耗,必须双写 const regItemPathAttr = regItemPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); const disabledNoteContent = isShellExt @@ -267,10 +319,17 @@ export function showDetail(id: number): void { ${legacyNote} -
-
${isShellExt ? t('item.comObject') : t('item.commandSubkey')}
-
${escapeHtml(regCmdPath)}
+ ${isShellExt ? `
+
COM 标识符
+
${escapeHtml(item.command)}
+ ${item.dllPath ? `
+
提供程序 DLL
+
${escapeHtml(item.dllPath)}
+
` : ''}` : `
+
命令子键路径
+
${escapeHtml(regCmdPath)}
+
`}
${t('item.openInRegedit')}
@@ -367,14 +426,6 @@ function showOperationError(msg: string): void { }, 3000); } -function escapeHtml(s: string): string { - return s.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - // ── 从详情面板触发切换 ── export async function toggleFromDetail(): Promise { if (selectedItemId == null) return; @@ -422,22 +473,43 @@ export function restoreSceneTitle(scene: MenuScene): void { resetDetailPanel(); } -// ── 预加载其余场景的 badge 数量 ── +// ── 预加载其余场景的 badge 数量(串行,每个场景完成后立即更新,渐进显示)── export async function preloadBadgeCounts(skipScene: MenuScene): Promise { const allScenes = Object.values(MenuScene) as MenuScene[]; - const scenesToLoad = allScenes.filter((s) => s !== skipScene); - - await Promise.all( - scenesToLoad.map(async (scene) => { - const result = await window.api.getMenuItems(scene); - const badgeEl = document.getElementById(`badge-${scene}`); - if (badgeEl) { - badgeEl.textContent = result.success ? String(result.data.length) : '?'; - } - }) - ); + const targetScenes = allScenes.filter((scene) => scene !== skipScene); + + for (const scene of targetScenes) { + const result = await window.api.getMenuItems(scene).catch(() => null); + const badgeEl = document.getElementById(`badge-${scene}`); + if (!badgeEl) continue; + + if (result && result.success && 'data' in result) { + badgeEl.textContent = String(result.data.length); + rendererCache.set(scene, { items: result.data, timestamp: Date.now() }); + } else { + badgeEl.textContent = '?'; + } + } } +export function onNavigateAway(): void { + pendingScene = null; // 取消挂起的场景切换请求,避免导航离开后触发残留副作用 +} + +// ── 注入 loading spinner 样式 ── +(function injectSpinnerStyles() { + if (document.getElementById('_cmSpinnerStyles')) return; + const style = document.createElement('style'); + style.id = '_cmSpinnerStyles'; + style.textContent = ` +.loading-state { display:flex; align-items:center; justify-content:center; height:200px; gap:10px; color:var(--text3); } +.loading-spinner { width:18px; height:18px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:cmSpin 0.7s linear infinite; flex-shrink:0; } +@keyframes cmSpin { to { transform:rotate(360deg); } } +.loading-badge { font-size:11px; color:var(--text3); font-weight:normal; } + `.trim(); + document.head.appendChild(style); +})(); + // 挂载到 window 供 HTML inline onclick 调用 const mainPageApi = { selectItem, toggleItem, setFilter, flashCopyBtn, toggleFromDetail, deleteSelected }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/renderer/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/src/shared/types.ts b/src/shared/types.ts index 6320b8f..431b13e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -16,6 +16,7 @@ export interface MenuItemEntry { menuScene: MenuScene; registryKey: string; type: MenuItemType; + dllPath?: string | null; // 仅 ShellExt 类型有值,指向 InprocServer32 DLL } // 操作记录 diff --git a/tests/unit/main/ipc/registry.test.ts b/tests/unit/main/ipc/registry.test.ts index 6c34c2e..a4f1fbd 100644 --- a/tests/unit/main/ipc/registry.test.ts +++ b/tests/unit/main/ipc/registry.test.ts @@ -65,7 +65,7 @@ describe('IPC Registry Handlers', () => { const result = await handler({}, MenuScene.Desktop); - expect(mockMenuManager.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop); + expect(mockMenuManager.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop, false, 'high'); expect(result).toEqual(mockItems); }); }); diff --git a/tests/unit/main/services/BackupService.test.ts b/tests/unit/main/services/BackupService.test.ts index 6ced4fa..70990ce 100644 --- a/tests/unit/main/services/BackupService.test.ts +++ b/tests/unit/main/services/BackupService.test.ts @@ -106,11 +106,21 @@ describe('BackupService', () => { }); describe('deleteBackup', () => { - it('should call repo delete with correct id', () => { - service.deleteBackup(123); + it('should call repo delete with correct id', async () => { + mockRepo.findById.mockReturnValue({ + id: 123, name: 'Test', creationTime: '', type: BackupType.Manual, menuItemsJson: '[]', sha256Checksum: 'abc', + }); + + await service.deleteBackup(123); expect(mockRepo.delete).toHaveBeenCalledWith(123); }); + + it('should throw when backup not found', async () => { + mockRepo.findById.mockReturnValue(null); + + await expect(service.deleteBackup(999)).rejects.toThrow('备份快照不存在: id=999'); + }); }); describe('getAllBackups', () => { diff --git a/tests/unit/main/services/MenuManagerService.test.ts b/tests/unit/main/services/MenuManagerService.test.ts index 3b33193..d821f33 100644 --- a/tests/unit/main/services/MenuManagerService.test.ts +++ b/tests/unit/main/services/MenuManagerService.test.ts @@ -10,6 +10,7 @@ vi.mock('@/main/services/RegistryService'); vi.mock('@/main/services/OperationHistoryService'); vi.mock('@/main/utils/logger', () => ({ default: { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -29,6 +30,7 @@ describe('MenuManagerService', () => { createRollbackPoint: vi.fn(), commitTransaction: vi.fn(), rollback: vi.fn(), + invalidateCache: vi.fn(), } as MockedObject; mockHistory = { @@ -57,7 +59,7 @@ describe('MenuManagerService', () => { const result = await service.getMenuItems(MenuScene.Desktop); - expect(mockRegistry.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop); + expect(mockRegistry.getMenuItems).toHaveBeenCalledWith(MenuScene.Desktop, 'normal'); expect(result).toEqual(mockItems); }); }); diff --git a/tests/unit/main/services/OperationHistoryService.test.ts b/tests/unit/main/services/OperationHistoryService.test.ts new file mode 100644 index 0000000..2955fc9 --- /dev/null +++ b/tests/unit/main/services/OperationHistoryService.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, MockedObject } from 'vitest'; +import { OperationHistoryService } from '@/main/services/OperationHistoryService'; +import { OperationRecordRepo } from '@/main/data/repositories/OperationRecordRepo'; +import { MenuManagerService } from '@/main/services/MenuManagerService'; +import { MenuScene, MenuItemType, OperationType } from '@/shared/enums'; +import { OperationRecord } from '@/shared/types'; + +vi.mock('@/main/utils/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const makeRecord = (overrides: Partial = {}): OperationRecord => ({ + id: 1, + timestamp: '2026-01-01T00:00:00Z', + operationType: OperationType.Enable, + targetEntryName: 'TestItem', + registryPath: 'DesktopBackground\\Shell\\TestItem', + oldValue: null, + newValue: null, + ...overrides, +}); + +const makeItem = (scene: MenuScene, registryKey: string) => ({ + id: -1, + name: 'TestItem', + command: '', + iconPath: null, + isEnabled: true, + source: '', + menuScene: scene, + registryKey, + type: MenuItemType.System, +}); + +describe('OperationHistoryService', () => { + let service: OperationHistoryService; + let mockRepo: MockedObject; + let mockMenuManager: MockedObject; + + beforeEach(() => { + mockRepo = { + insert: vi.fn(), + findAll: vi.fn().mockReturnValue([]), + findById: vi.fn(), + deleteAll: vi.fn(), + } as unknown as MockedObject; + + mockMenuManager = { + enableItem: vi.fn(), + disableItem: vi.fn(), + invalidateCache: vi.fn(), + } as unknown as MockedObject; + + service = new OperationHistoryService(mockRepo); + }); + + // ── determineSceneFromRegistryKey(通过 undoOperation 间接测试)── + + describe('undoOperation — 场景路径解析', () => { + it('Directory\\Background\\shell → DirectoryBackground(不误判为 Folder)', async () => { + const record = makeRecord({ + operationType: OperationType.Enable, + registryPath: 'Directory\\Background\\shell\\TestItem', + }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.DirectoryBackground); + }); + + it('DesktopBackground → Desktop', async () => { + const record = makeRecord({ registryPath: 'DesktopBackground\\Shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Desktop); + }); + + it('CLSID\\{645FF040 → RecycleBin', async () => { + const record = makeRecord({ + registryPath: 'CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell\\TestItem', + }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.RecycleBin); + }); + + it('Drive\\shell → Drive', async () => { + const record = makeRecord({ registryPath: 'Drive\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Drive); + }); + + it('Directory\\shell → Folder', async () => { + const record = makeRecord({ registryPath: 'Directory\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.Folder); + }); + + it('*\\shell → File', async () => { + const record = makeRecord({ registryPath: '*\\shell\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.invalidateCache).toHaveBeenCalledWith(MenuScene.File); + }); + + it('未知路径抛出错误', async () => { + const record = makeRecord({ registryPath: 'UNKNOWN\\path\\TestItem' }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + + await expect( + service.undoOperation(1, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('无法从注册表路径确定场景'); + }); + }); + + // ── undoOperation 启用/禁用反转逻辑 ── + + describe('undoOperation — 启用/禁用反转', () => { + it('Enable 操作撤销 → 调用 disableItem', async () => { + const record = makeRecord({ operationType: OperationType.Enable }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.disableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.disableItem).toHaveBeenCalled(); + expect(mockMenuManager.enableItem).not.toHaveBeenCalled(); + }); + + it('Disable 操作撤销 → 调用 enableItem', async () => { + const record = makeRecord({ operationType: OperationType.Disable }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + vi.mocked(mockMenuManager.enableItem).mockResolvedValue(undefined); + + await service.undoOperation(1, mockMenuManager as unknown as MenuManagerService); + + expect(mockMenuManager.enableItem).toHaveBeenCalled(); + expect(mockMenuManager.disableItem).not.toHaveBeenCalled(); + }); + + it('撤销不存在的记录 → 抛出异常', async () => { + vi.mocked(mockRepo.findById).mockReturnValue(null); + + await expect( + service.undoOperation(999, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('找不到要撤销的操作记录'); + }); + + it('不支持 Backup 类型的撤销 → 抛出异常', async () => { + const record = makeRecord({ operationType: OperationType.Backup }); + vi.mocked(mockRepo.findById).mockReturnValue(record); + + await expect( + service.undoOperation(1, mockMenuManager as unknown as MenuManagerService) + ).rejects.toThrow('不支持该类型操作的撤销'); + }); + }); +}); diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 18ce5e6..23dace4 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -41,6 +41,663 @@ 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('应通过 CmHelper 调用 SHLoadIndirectString(复用缓存 DLL,不再内联编译)', () => { + 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); + }); + + 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 作为第三优先级', () => { + 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', () => { + 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,不遍历所有字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('FileVersionInfo'); + expect(script).toContain('ProductName'); + }); + + it('应将 CLSID Default 值作为 Level 2 兜底', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('Level 2: CLSID 默认值'); + expect(script).not.toContain('Level 3: CLSID'); + }); + + it('Format-DisplayName 不应包含热键清理正则(热键清理已移至 TS 层)', () => { + 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'); + }); + + it('CmHelper 源码中不应包含 ReadDllStrings 方法', () => { + 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'); + }); + + it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + expect(script).toContain('InprocServer32'); + expect(script).toContain('ExpandEnvironmentVariables'); + expect(script).toContain('dllPath'); + }); + + it('不应包含硬编码 friendlyNames 映射表(已移除,让 SHLoadIndirectString 自动本地化)', () => { + const script = bridge.buildGetShellExtItemsScript( + 'DesktopBackground\\shellex\\ContextMenuHandlers' + ); + + 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: 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:'\)/); + }); + + 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'); + }); + + 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', () => { + 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'); + }); + + 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', () => { diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 5e893fd..0a40e56 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -56,11 +56,203 @@ 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 = [{ + 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('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 = [