diff --git a/.claude/settings.json b/.claude/settings.json index cccb922..b7d993b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,4 +13,4 @@ "Write(docs/**)" ] } -} +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b690543..8991ec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,97 +1,164 @@ -# ContextMaster — Windows 右键菜单管理工具 - -## 技术栈 -- 框架:Electron 33.x + electron-forge + Vite -- 语言:TypeScript(严格模式) -- 数据库:better-sqlite3(同步 API,WAL 模式) -- 注册表访问:PowerShell 桥接(读写 HKCR) -- IPC:contextBridge + 类型化 IPC 频道 - -## 架构分层 - -``` -src/ -├── shared/ → 类型/枚举/IPC 频道常量(主+渲染进程共用) -├── main/ -│ ├── services/ → PowerShellBridge → RegistryService → MenuManagerService -│ │ OperationHistoryService → BackupService -│ ├── data/ → Database.ts(better-sqlite3)+ Repository 层 -│ └── ipc/ → 四个 handler 文件(registry/history/backup/system) -├── preload/ → contextBridge 暴露 window.api -└── renderer/ → index.html + main.ts + 四个 page 模块 -``` - -## 关键约束 -- 所有注册表写操作前必须创建回滚点 -- 禁用条目通过写入 LegacyDisable 字符串值实现 -- 启用条目通过删除 LegacyDisable 值实现 -- 需要管理员权限(UAC manifest 已在 assets/app.manifest 配置) -- `wrapHandler` 使用 `any` 签名,IPC 边界类型由 preload 层保证 - -## 注册表路径 -- 桌面右键:HKCR\DesktopBackground\Shell -- 文件右键:HKCR\*\shell -- 文件夹右键:HKCR\Directory\shell -- 驱动器右键:HKCR\Drive\shell -- 目录背景:HKCR\Directory\Background\shell -- 回收站:HKCR\CLSID\{645FF040-5081-101B-9F08-00AA002F954E}\shell - -## 代码规范 -- 使用 async/await 处理所有 IO 操作 -- 错误处理使用 Result 模式 -- renderer page 模块通过 `(window as any)._mainPage` 等挂载供 HTML inline onclick 调用 - -## 运行方式 -```bash -npm start # 开发模式(需管理员身份运行) -npm run make # 打包安装包 -``` - -> 注意:better-sqlite3 为 native 模块,切换 Node/Electron 版本后需 `npx electron-rebuild` - -## 设计 Token - -### 颜色 -``` ---accent: #0067C0 /* 主按钮、选中态、激活指示器 */ ---accent-hover: #005A9E ---accent-bg: #EFF6FC /* 选中行背景 */ ---success: #0F7B0F /* 已启用标签 */ ---success-bg: #DFF6DD ---danger: #C42B1C /* 已禁用标签、删除操作 */ ---danger-bg: #FDE7E9 ---warning: #9D5D00 ---warning-bg: #FFF4CE ---bg: #F3F3F3 /* 应用主背景 */ ---surface: #FFFFFF /* 卡片、面板 */ ---surface2: #F9F9F9 ---border: #E0E0E0 ---text: #1A1A1A ---text2: #616161 ---text3: #8A8A8A ---radius: 8px ---radius-sm: 4px ---nav-w: 220px -``` - -### 字体 -- 主字体:Segoe UI Variable -- 代码/路径:Consolas -- 字号:正文 13px / 辅助 11px / 标题 18px / 大标题 24px - -## 布局结构 -- TitleBar:H=40px,含应用图标+标题+窗口控制按钮 -- NavigationView(左侧):W=220px,分两组(场景 / 管理) -- Main Content:含 Toolbar(H≈52px) + ContentArea + DetailPanel(W=240px) -- StatusBar:H=26px,accent 色背景 - -## 页面清单 -1. MainPage — 菜单管理(核心页,含场景切换) -2. HistoryPage — 操作记录 -3. BackupPage — 备份管理 -4. SettingsPage — 设置 - -## 参考文档 -- UI 原型:docs/prototype/ContextMaster-UI原型.html -- 完整方案设计:docs/ContextMaster-方案设计文档.md -- 设计规范:docs/ContextMaster-Figma设计规范.md +# ContextMaster — Windows 右键菜单管理工具 + +## 技术栈 +- 框架:Electron 33.x + electron-forge + Vite +- 语言:TypeScript(严格模式) +- 包管理器:pnpm +- 数据库:better-sqlite3(同步 API,WAL 模式) +- 注册表访问:PowerShell 桥接(读写 HKCR) +- Win32 FFI:koffi(调用 `shlwapi.dll` / `kernel32.dll`) +- IPC:contextBridge + 类型化 IPC 频道 + +## 常用命令 + +```bash +pnpm install # 安装依赖 +pnpm start # 开发模式(需管理员身份运行) +pnpm run make # 打包 Squirrel 安装包 → out/ +pnpm run lint # ESLint 检查 +pnpm run test:unit # 运行所有单元测试 +pnpm run test:unit -- -t "test name" # 运行单个测试 +pnpm run test:unit:watch # watch 模式 +pnpm run test:unit:ui # Vitest UI +pnpm run test:coverage # 单元测试 + 覆盖率 +pnpm run test:e2e # Playwright E2E 测试 +pnpm run test:e2e:ui # Playwright UI 模式 +``` + +> better-sqlite3 为 native 模块,切换 Node/Electron 版本后需 `npx electron-rebuild` + +## 架构分层 + +``` +src/ +├── shared/ → 类型/枚举/IPC 频道常量(主+渲染进程共用) +├── main/ +│ ├── services/ → PowerShellBridge → RegistryService → MenuManagerService +│ │ ShellExtNameResolver(名称解析) +│ │ Win32Shell(koffi FFI:SHLoadIndirectString) +│ │ SystemInfoService(OS/菜单风格检测) +│ │ OperationHistoryService → BackupService +│ ├── data/ → Database.ts(better-sqlite3)+ Repository 层 +│ └── ipc/ → 四个 handler 文件(registry/history/backup/system) +├── preload/ → contextBridge 暴露 window.api +└── renderer/ → index.html + main.ts + 四个 page 模块 +``` + +## 服务组合根 + +`src/main/index.ts:initServices()` 中手动 DI 构建服务链: + +``` +Win32Shell(koffi) + └─→ ShellExtNameResolver(名称解析,四级级联策略) + ↓ +Database + ├── OperationRecordRepo ──→ OperationHistoryService ──┐ + └── BackupSnapshotRepo ──→ BackupService │ + ↓ │ +PowerShellBridge → SystemInfoService │ +PowerShellBridge → RegistryService → MenuManagerService ←┘ +``` + +启动顺序:`initLogger()` → `initServices()` → `createWindow()` + +## 关键约束 + +- 所有注册表写操作前必须创建回滚点(`RegistryService.createRollbackPoint`) +- **Classic Shell** 条目:通过写入 `LegacyDisable` 字符串值禁用,删除该值启用 +- **Shell 扩展(COM)** 条目:通过重命名键名(加/去 `-` 前缀)实现启用/禁用 +- Shell 扩展条目类型通过 `registryKey` 是否包含 `shellex\ContextMenuHandlers` 来判定 +- PowerShell 执行有两种模式: + - `execute()` — 直接执行(管理员身份下使用) + - `executeElevated()` — 通过临时 `.ps1` 文件 + `Start-Process -Verb RunAs` 提权,结果写临时 JSON 文件 +- `wrapHandler` 统一捕获异常返回 `IpcResult`,IPC 边界类型由 preload 层保证 +- renderer page 模块通过 `(window as any)._mainPage` 等挂载函数供 HTML inline onclick 调用 + +## Shell 扩展名称解析(本分支重构) + +名称解析已从 `PowerShellBridge` 拆出为独立服务: + +- **`Win32Shell`**:koffi FFI 封装,调用 `shlwapi.dll!SHLoadIndirectString` 解析 `@DLL,-ID` 格式字符串,并通过 `kernel32.dll!GetUserDefaultUILanguage` 检测 UI 语言 +- **`ShellExtNameResolver`**:四级级联策略解析 Shell 扩展显示名: + 1. `directName` 间接格式 → `resolveIndirect` + 2. `LocalizedString` / `FriendlyTypeName` → `resolveIndirect`(Phase A) + 3. CLSID 默认值(Phase B) + 4. 处理程序键名(兜底) +- **`SystemInfoService`**:检测 OS 版本(Win10/Win11,基于 build number ≥ 22000)及当前菜单风格(`classic` / `win11-new`) +- 标准谓词(open/edit/print/runas 等)通过内置翻译表按 UI 语言转换,无需注册表查询 + +## 注册表路径 + +| 场景 | Classic Shell | ShellEx ContextMenuHandlers | +|------|--------------|---------------------------| +| 桌面 | `HKCR\DesktopBackground\Shell` | `DesktopBackground\shellex\ContextMenuHandlers` | +| 文件 | `HKCR\*\shell` | `*\shellex\ContextMenuHandlers` | +| 文件夹 | `HKCR\Directory\shell` | `Directory\shellex\ContextMenuHandlers` | +| 驱动器 | `HKCR\Drive\shell` | `Drive\shellex\ContextMenuHandlers` | +| 目录背景 | `HKCR\Directory\Background\shell` | `Directory\Background\shellex\ContextMenuHandlers` | +| 回收站 | `HKCR\CLSID\{645FF040-5081-101B-9F08-00AA002F954E}\shell` | 同上 + `\shellex\ContextMenuHandlers` | + +## IPC 模式 + +``` +renderer → window.api.getMenuItems(scene) [preload/index.ts] + ↓ ipcRenderer.invoke +main → ipcMain.handle('registry:getItems', ...) [main/ipc/registry.ts] + ↓ wrapHandler + menuManager.getMenuItems(scene) [main/services/MenuManagerService.ts] + ↓ +返回 IpcResult +``` + +## 单元测试 + +- 测试目录:`tests/unit/` — 镜像 `src/` 结构 +- 运行器:Vitest(`globals: true`, `environment: 'node'`) +- 全局 mock:`tests/unit/setup.ts` 预 mock 了 `electron`, `electron-log`, `better-sqlite3` +- 路径别名:`@` → `src/`, `@main` → `src/main/`, `@shared` → `src/shared/` 等 + +## 设计 Token + +``` +--accent: #0067C0 /* 主按钮、选中态、激活指示器 */ +--accent-hover: #005A9E +--accent-bg: #EFF6FC /* 选中行背景 */ +--success: #0F7B0F /* 已启用标签 */ +--success-bg: #DFF6DD +--danger: #C42B1C /* 已禁用标签、删除操作 */ +--danger-bg: #FDE7E9 +--warning: #9D5D00 +--warning-bg: #FFF4CE +--bg: #F3F3F3 /* 应用主背景 */ +--surface: #FFFFFF /* 卡片、面板 */ +--surface2: #F9F9F9 +--border: #E0E0E0 +--text: #1A1A1A +--text2: #616161 +--text3: #8A8A8A +--radius: 8px +--radius-sm: 4px +--nav-w: 220px +``` + +字体:Segoe UI Variable(正文 13px / 辅助 11px / 标题 18px / 大标题 24px),代码/路径:Consolas + +## 布局结构 + +- TitleBar:H=40px,含应用图标+标题+窗口控制按钮 +- NavigationView(左侧):W=220px,分两组(场景 / 管理) +- Main Content:含 Toolbar(H≈52px) + ContentArea + DetailPanel(W=240px) +- StatusBar:H=26px,accent 色背景 + +## 页面清单 + +1. MainPage — 菜单管理(核心页,含场景切换) +2. HistoryPage — 操作记录 +3. BackupPage — 备份管理 +4. SettingsPage — 设置 + +## 参考文档 + +- UI 原型:`docs/prototype/ContextMaster-UI原型.html` +- 完整方案设计:`docs/ContextMaster-方案设计文档.md` +- 设计规范:`docs/ContextMaster-Figma设计规范.md` diff --git a/docs/shell-extension-name-resolution.md b/docs/shell-extension-name-resolution.md index 5a8ad82..89abd14 100644 --- a/docs/shell-extension-name-resolution.md +++ b/docs/shell-extension-name-resolution.md @@ -267,13 +267,20 @@ Level 1.7: cmdStoreVerbs[CLSID] = "固定到任务栏" ✓ --- -## CmHelper 编译与缓存 +## koffi FFI 与 SHLoadIndirectString -CmHelper 是一个 C# 类,在脚本运行时动态编译(或从缓存加载)。缓存路径:`%LOCALAPPDATA%\ContextMaster\CmHelper.dll`。 +`SHLoadIndirectString` 通过 TypeScript `koffi` 库直接调用 `shlwapi.dll`, +封装在 `src/main/services/Win32Shell.ts`。 -**版本校验**:加载 DLL 后立即检查 `[CmHelper]::Ver == "2026.3"`,不匹配时重新编译。这确保代码变更后自动更新缓存。 +DLL 版本资源通过 PowerShell `[System.Diagnostics.FileVersionInfo]::GetVersionInfo` +采集(天然支持当前 UI 语言),不依赖 C# 运行时编译。 -**编译失败时的降级行为**: -- `ResolveIndirect` 不可用 → Level 0、Level 1(间接格式)、Level 1.3(间接格式)、Level 1.5(间接格式)失败 -- `GetLocalizedVerStrings` 不可用 → Level 2.5 降级为 `FileVersionInfo`(仅当前 locale) +**优势**: +- 不依赖 .NET SDK 或运行时编译(C# `CmHelper` 已移除) +- 所有名称解析逻辑在 TypeScript 侧(`ShellExtNameResolver`),便于测试和调试 +- PS 脚本只负责注册表数据采集 + DLL FileVersionInfo 读取 + +**koffi 不可用时的降级行为**: +- `resolveIndirect` 失败 → Level 0、Level 1/1.3/1.5(间接格式)、Level 1.7(间接格式)全部跳过 +- DLL FileVersionInfo 仍通过 PS 脚本采集(Level 2.5),不受影响 - 其余 Level(plain 字符串路径)不受影响 diff --git a/script/task.ps1 b/script/task.ps1 index 5f80162..b112135 100644 --- a/script/task.ps1 +++ b/script/task.ps1 @@ -1,13 +1,13 @@ -param( - [string]$Task -) - -$path = ".ai/worktrees/$Task" -$branch = "task/$Task" - -git worktree add $path -b $branch - -Write-Host "Worktree created:" -Write-Host $path - +param( + [string]$Task +) + +$path = ".ai/worktrees/$Task" +$branch = "task/$Task" + +git worktree add $path -b $branch + +Write-Host "Worktree created:" +Write-Host $path + wt -w 0 new-tab -d $path diff --git a/src/main/index.ts b/src/main/index.ts index e4256af..8034ad6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { BackupSnapshotRepo } from './data/repositories/BackupSnapshotRepo'; import { OperationHistoryService } from './services/OperationHistoryService'; import { MenuManagerService } from './services/MenuManagerService'; import { BackupService } from './services/BackupService'; +import { SystemInfoService } from './services/SystemInfoService'; import { registerRegistryHandlers } from './ipc/registry'; import { registerHistoryHandlers } from './ipc/history'; import { registerBackupHandlers } from './ipc/backup'; @@ -62,7 +63,7 @@ function initServices(): MenuManagerService { const db = getDatabase(); const ps = new PowerShellBridge(); const win32Shell = new Win32Shell(); - const resolver = new ShellExtNameResolver(win32Shell, win32Shell.uiLanguage); + const resolver = new ShellExtNameResolver(win32Shell, win32Shell.primaryLang); const cmdStoreIndex = new CommandStoreIndex(); const registry = new RegistryService(ps, resolver, cmdStoreIndex); const opRepo = new OperationRecordRepo(db); @@ -70,6 +71,7 @@ function initServices(): MenuManagerService { const history = new OperationHistoryService(opRepo); const menuManager = new MenuManagerService(registry, history); const backup = new BackupService(bkRepo, menuManager, history); + const systemInfo = new SystemInfoService(ps); // 异步构建 CommandStore 索引(不阻塞启动) ps.execute>(ps.buildCommandStoreScript()) @@ -82,7 +84,7 @@ function initServices(): MenuManagerService { registerRegistryHandlers(menuManager); registerHistoryHandlers(history, menuManager); registerBackupHandlers(backup); - registerSystemHandlers(win32Shell, () => cmdStoreIndex.size); + registerSystemHandlers(win32Shell, () => cmdStoreIndex.size, systemInfo); return menuManager; } @@ -91,15 +93,11 @@ app.whenReady().then(() => { 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'); - })(); + // 仅预热首屏 Desktop(high 优先级);其余场景按用户点击时延迟加载, + // 避免启动期饱和 PS 槽导致用户首次切换场景被阻塞排队 + void menuManager.getMenuItems(MenuScene.Desktop, false, 'high') + .then(() => log.info('[Preload] Desktop preloaded')) + .catch(e => log.warn('[Preload] Desktop failed:', e)); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 58de76b..975040a 100644 --- a/src/main/ipc/system.ts +++ b/src/main/ipc/system.ts @@ -6,12 +6,14 @@ import { isAdmin, restartAsAdmin } from '../utils/AdminHelper'; import { wrapHandler } from '../utils/ipcWrapper'; import log, { getLogDir } from '../utils/logger'; import type { IWin32Shell } from '../services/Win32Shell'; +import type { SystemInfoService } from '../services/SystemInfoService'; const execFileAsync = promisify(execFile); export function registerSystemHandlers( win32Shell?: IWin32Shell, getCmdStoreSize?: () => number, + systemInfo?: SystemInfoService, ): void { ipcMain.handle( IPC.SYS_IS_ADMIN, @@ -186,4 +188,23 @@ export function registerSystemHandlers( return BrowserWindow.getFocusedWindow()?.isMaximized() ?? false; }) ); + + ipcMain.handle( + IPC.SYS_MENU_STYLE, + wrapHandler(async () => { + if (!systemInfo) throw new Error('SystemInfoService not injected'); + return systemInfo.getMenuStyle(); + }) + ); + + ipcMain.handle( + IPC.SYS_SET_MENU_STYLE, + wrapHandler(async (_event: unknown, target: 'classic' | 'win11-new') => { + if (!systemInfo) throw new Error('SystemInfoService not injected'); + log.info(`[System] Setting menu style to: ${target}`); + await systemInfo.setMenuStyle(target); + await systemInfo.restartExplorer(); + return true; + }) + ); } diff --git a/src/main/services/MenuManagerService.ts b/src/main/services/MenuManagerService.ts index fc532a8..0b88da5 100644 --- a/src/main/services/MenuManagerService.ts +++ b/src/main/services/MenuManagerService.ts @@ -2,17 +2,11 @@ import { MenuScene, OperationType } from '../../shared/enums'; import { MenuItemEntry } from '../../shared/types'; import { RegistryService } from './RegistryService'; import { OperationHistoryService } from './OperationHistoryService'; +import { RegistryCache } from '../utils/RegistryCache'; import log from '../utils/logger'; -interface CacheEntry { - items: MenuItemEntry[]; - timestamp: number; -} - -const CACHE_TTL = 5 * 60 * 1000; - export class MenuManagerService { - private cache = new Map(); + private cache = new RegistryCache(2 * 60 * 1000); private inFlight = new Map>(); constructor( @@ -23,9 +17,9 @@ export class MenuManagerService { 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) { + if (cached) { log.debug(`[MenuManager] Cache hit for scene: ${scene}`); - return cached.items; + return cached; } const existing = this.inFlight.get(scene); if (existing) { @@ -42,7 +36,7 @@ export class MenuManagerService { if (elapsed > 100) { log.info(`[MenuManager] Loaded ${items.length} items for ${scene} in ${elapsed}ms`); } - this.cache.set(scene, { items, timestamp: Date.now() }); + this.cache.set(scene, items); this.inFlight.delete(scene); return items; }) @@ -57,11 +51,9 @@ export class MenuManagerService { invalidateCache(scene?: MenuScene): void { if (scene) { - this.cache.delete(scene); - log.debug(`[MenuManager] Cache invalidated for scene: ${scene}`); + this.cache.invalidate(scene); } else { - this.cache.clear(); - log.debug('[MenuManager] All cache invalidated'); + this.cache.invalidateAll(); } } @@ -104,9 +96,7 @@ export class MenuManagerService { 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.cache.invalidate(item.menuScene); this.history.recordOperation( OperationType.Enable, @@ -125,9 +115,7 @@ export class MenuManagerService { 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.cache.invalidate(item.menuScene); this.history.recordOperation( OperationType.Disable, @@ -152,17 +140,13 @@ 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(); + this.cache.invalidateAll(); } catch (e) { await this.registry.rollback(); throw new Error(`批量启用失败,已回滚: ${(e as Error).message}`); @@ -173,17 +157,13 @@ 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(); + this.cache.invalidateAll(); } catch (e) { await this.registry.rollback(); throw new Error(`批量禁用失败,已回滚: ${(e as Error).message}`); @@ -209,14 +189,11 @@ export class MenuManagerService { /** * 获取缓存统计信息 */ - getCacheStats(): ReturnType { - return this.registry.getCacheStats(); + getCacheStats() { + return this.cache.getStats(); } - /** - * 打印缓存统计日志 - */ logCacheStats(): void { - this.registry.logCacheStats(); + this.cache.logStats(); } } diff --git a/src/main/services/PowerShellBridge.ts b/src/main/services/PowerShellBridge.ts index ed74446..534c028 100644 --- a/src/main/services/PowerShellBridge.ts +++ b/src/main/services/PowerShellBridge.ts @@ -14,6 +14,7 @@ const PS_EXE = fs.existsSync(PWSH7_PATH) ? PWSH7_PATH : 'powershell.exe'; export class PowerShellBridge { private pending = 0; + // maxConcurrent=3:实测 5 反而拖慢,因多个 PS 进程同时做注册表 IO 互相争抢 private maxConcurrent = 3; private readonly waitQueue: Array<() => void> = []; @@ -68,7 +69,9 @@ export class PowerShellBridge { const { stdout, stderr } = await execFileAsync( PS_EXE, ['-NonInteractive', '-NoProfile', '-OutputFormat', 'Text', '-Command', utf8Script], - { maxBuffer: 10 * 1024 * 1024, timeout: 30000 } + // 90 秒上限,应对 Win11 24H2 部分系统注册表 IO 慢的情况;之前 30 秒会导致 File/Folder 等 + // 大量 ShellExt 的场景超时被 SIGTERM 杀死,整个场景返回空 + { maxBuffer: 10 * 1024 * 1024, timeout: 90000 } ); if (stderr) { @@ -164,40 +167,60 @@ ${script} } /** - * 构建扫描 Classic Shell 注册表路径的脚本 - * 仅返回原始注册表值(MUIVerb/Default/LocalizedDisplayName), - * 不执行间接字符串解析。解析逻辑由 TypeScript 侧 ShellExtNameResolver 完成。 + * 构建扫描 Classic Shell 注册表路径的脚本(支持多路径,单次 PS 进程) + * 使用 .NET Registry API 直接访问,比 PS Registry provider 快 10-100x, + * 对 Win11 24H2 等注册表 IO 慢的系统尤其关键。 */ - buildGetItemsScript(hkcrSubPath: string): string { + buildGetItemsScript(hkcrSubPaths: string[]): string { + const pathsLiteral = hkcrSubPaths.map(p => `'${p.replace(/'/g, "''")}'`).join(','); return ` $ErrorActionPreference = 'SilentlyContinue' -New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null -$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 - $iconPath = $key.GetValue('Icon') - $isEnabled = ($key.GetValue('LegacyDisable') -eq $null) - $commandSubKey = Join-Path $key.PSPath 'command' - $command = '' - if (Test-Path -LiteralPath $commandSubKey) { - $command = (Get-Item -LiteralPath $commandSubKey).GetValue('') - if (-not $command) { $command = '' } - } - $regKey = '${hkcrSubPath}\\' + $keyName - [PSCustomObject]@{ - subKeyName = [string]$keyName - rawMUIVerb = if ($key.GetValue('MUIVerb')) { [string]$key.GetValue('MUIVerb') } else { $null } - rawDefault = if ($key.GetValue('')) { [string]$key.GetValue('') } else { $null } - rawLocalizedDisplayName = if ($key.GetValue('LocalizedDisplayName')) { [string]$key.GetValue('LocalizedDisplayName') } else { $null } - rawIcon = if ($iconPath) { [string]$iconPath } else { $null } - isEnabled = [bool]$isEnabled - command = [string]$command - registryKey = [string]$regKey +$scanPaths = @(${pathsLiteral}) +$root = [Microsoft.Win32.Registry]::ClassesRoot +$result = New-Object System.Collections.ArrayList +foreach ($subPath in $scanPaths) { + $base = $root.OpenSubKey($subPath, $false) + if (-not $base) { continue } + try { + foreach ($name in $base.GetSubKeyNames()) { + $sub = $base.OpenSubKey($name, $false) + if (-not $sub) { continue } + try { + $valueNames = $sub.GetValueNames() + $iconPath = $sub.GetValue('Icon') + $isEnabled = ($sub.GetValue('LegacyDisable', $null) -eq $null) + $command = '' + $cmdSub = $sub.OpenSubKey('command', $false) + if ($cmdSub) { + try { $command = [string]$cmdSub.GetValue('') } finally { $cmdSub.Close() } + if (-not $command) { $command = '' } + } + $hasSub = ($sub.GetValue('SubCommands', $null) -ne $null) -or ($sub.GetValue('ExtendedSubCommandsKey', $null) -ne $null) + $hasSupp = ($sub.GetValue('SuppressionPolicy', $null) -ne $null) -or ($sub.GetValue('SuppressionPolicyEx', $null) -ne $null) + $obj = [PSCustomObject]@{ + subKeyName = [string]$name + rawMUIVerb = if ($sub.GetValue('MUIVerb')) { [string]$sub.GetValue('MUIVerb') } else { $null } + rawDefault = if ($sub.GetValue('')) { [string]$sub.GetValue('') } else { $null } + rawLocalizedDisplayName = if ($sub.GetValue('LocalizedDisplayName')) { [string]$sub.GetValue('LocalizedDisplayName') } else { $null } + rawIcon = if ($iconPath) { [string]$iconPath } else { $null } + isEnabled = [bool]$isEnabled + command = [string]$command + registryKey = [string]($subPath + '\\' + $name) + hasExtended = [bool]($valueNames -contains 'Extended') + hasSubCommands = [bool]$hasSub + hasSuppression = [bool]$hasSupp + hasProgrammaticAccessOnly = [bool]($valueNames -contains 'ProgrammaticAccessOnly') + hasHasLUAShield = [bool]($valueNames -contains 'HasLUAShield') + } + [void]$result.Add($obj) + } finally { + $sub.Close() + } + } + } finally { + $base.Close() } -}) +} $result | ConvertTo-Json -Compress -Depth 3 `.trim(); } @@ -237,121 +260,108 @@ Write-Output '{"ok":true}' } /** - * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本 - * 仅读取原始注册表数据(键名、CLSID、LocalizedString、MUIVerb、DLL 路径等), - * 不执行名称解析。解析逻辑由 TypeScript 侧 ShellExtNameResolver 完成。 + * 构建枚举 shellex\ContextMenuHandlers 下所有 Shell 扩展的脚本(支持多路径,单次 PS 进程) + * 使用 .NET Registry API 直接访问,比 PS Registry provider 快 10-100x。 + * 仅读取原始注册表数据;名称解析由 TypeScript 侧 ShellExtNameResolver 完成。 */ - buildGetShellExtItemsScript(shellexSubPath: string): string { + buildGetShellExtItemsScript(shellexSubPaths: string[]): string { + const pathsLiteral = shellexSubPaths.map(p => `'${p.replace(/'/g, "''")}'`).join(','); return ` $ErrorActionPreference = 'SilentlyContinue' -New-PSDrive -Name HKCR -PSProvider Registry -Root HKEY_CLASSES_ROOT -ErrorAction SilentlyContinue | Out-Null -$shellexPath = 'HKCR:\\${shellexSubPath}' -if (-not (Test-Path -LiteralPath $shellexPath)) { Write-Output '[]'; exit } -# 推导 sibling shell 路径 -$shellPath = $null -if ($shellexPath -match '\\\\shellex\\\\ContextMenuHandlers$') { - $shellPath = $shellexPath -replace '\\\\shellex\\\\ContextMenuHandlers$', '\\shell' -} -$handlers = Get-ChildItem -LiteralPath $shellexPath | Where-Object { $_.PSIsContainer } -$result = @($handlers | ForEach-Object { - $handlerKeyName = $_.PSChildName - $defaultVal = $_.GetValue('') - $cleanName = $handlerKeyName -replace '^-+', '' - $actualClsid = $cleanName - if ($cleanName -notmatch '^\\{[0-9A-Fa-f-]+\\}$' -and - $defaultVal -match '^\\{[0-9A-Fa-f-]+\\}$') { - $actualClsid = $defaultVal +$scanPaths = @(${pathsLiteral}) +$root = [Microsoft.Win32.Registry]::ClassesRoot +$result = New-Object System.Collections.ArrayList +foreach ($subPath in $scanPaths) { + $shellexKey = $root.OpenSubKey($subPath, $false) + if (-not $shellexKey) { continue } + $shellPath = $null + if ($subPath -match '\\\\shellex\\\\ContextMenuHandlers$') { + $shellPath = $subPath -replace '\\\\shellex\\\\ContextMenuHandlers$', '\\shell' } - # 读取 CLSID 子键原始值 - $clsidLocalizedString = $null - $clsidMUIVerb = $null - $clsidDefault = $null - $dllPath = $null - if ($actualClsid -match '^\\{[0-9A-Fa-f-]+\\}$') { - $clsidPath = 'HKCR:\\CLSID\\' + $actualClsid - if (Test-Path -LiteralPath $clsidPath) { - $clsidKey = Get-Item -LiteralPath $clsidPath - if ($clsidKey.GetValue('LocalizedString')) { $clsidLocalizedString = [string]$clsidKey.GetValue('LocalizedString') } - if ($clsidKey.GetValue('MUIVerb')) { $clsidMUIVerb = [string]$clsidKey.GetValue('MUIVerb') } - if ($clsidKey.GetValue('')) { $clsidDefault = [string]$clsidKey.GetValue('') } - $inprocPath = $clsidPath + '\\InprocServer32' - if (Test-Path -LiteralPath $inprocPath) { - $dllRaw = (Get-Item -LiteralPath $inprocPath).GetValue('') - if ($dllRaw) { $dllPath = [System.Environment]::ExpandEnvironmentVariables($dllRaw) } - } - # CLSID\Shell 子键的 MUIVerb(COM 对象自身注册的 verb) - $clsidShellPath = $clsidPath + '\\Shell' - if (Test-Path -LiteralPath $clsidShellPath) { - Get-ChildItem -LiteralPath $clsidShellPath -ErrorAction SilentlyContinue | ForEach-Object { - $shellMv = $_.GetValue('MUIVerb') - if ($shellMv -and -not $clsidMUIVerb) { $clsidMUIVerb = [string]$shellMv } + try { + foreach ($handlerKeyName in $shellexKey.GetSubKeyNames()) { + $h = $shellexKey.OpenSubKey($handlerKeyName, $false) + if (-not $h) { continue } + try { + $defaultVal = [string]$h.GetValue('') + $cleanName = $handlerKeyName -replace '^-+', '' + $actualClsid = $cleanName + if ($cleanName -notmatch '^\\{[0-9A-Fa-f-]+\\}$' -and + $defaultVal -match '^\\{[0-9A-Fa-f-]+\\}$') { + $actualClsid = $defaultVal } - } - # ProgID → 应用程序名(用于 Level 1.6) - $progIdVal = $clsidKey.GetValue('ProgID') - if ($progIdVal) { - $progIdPath = 'HKCR:\' + $progIdVal - if (Test-Path -LiteralPath $progIdPath) { - $progIdDef = (Get-Item -LiteralPath $progIdPath).GetValue('') - if ($progIdDef -and $progIdDef.Length -ge 2) { $progIdName = [string]$progIdDef } + $clsidLocalizedString = $null + $clsidMUIVerb = $null + $clsidDefault = $null + $clsidIcon = $null + $dllPath = $null + if ($actualClsid -match '^\\{[0-9A-Fa-f-]+\\}$') { + $clsidKey = $root.OpenSubKey('CLSID\\' + $actualClsid, $false) + if ($clsidKey) { + try { + $v = $clsidKey.GetValue('LocalizedString'); if ($v) { $clsidLocalizedString = [string]$v } + $v = $clsidKey.GetValue('MUIVerb'); if ($v) { $clsidMUIVerb = [string]$v } + $v = $clsidKey.GetValue(''); if ($v) { $clsidDefault = [string]$v } + $ip = $clsidKey.OpenSubKey('InprocServer32', $false) + if ($ip) { + try { + $dllRaw = $ip.GetValue('') + if ($dllRaw) { $dllPath = [Environment]::ExpandEnvironmentVariables([string]$dllRaw) } + } finally { $ip.Close() } + } + if (-not $clsidMUIVerb) { + $shSame = $clsidKey.OpenSubKey('Shell\\' + $cleanName, $false) + if ($shSame) { + try { + $v = $shSame.GetValue('MUIVerb'); if ($v) { $clsidMUIVerb = [string]$v } + } finally { $shSame.Close() } + } + } + $ic = $clsidKey.OpenSubKey('DefaultIcon', $false) + if ($ic) { + try { + $v = $ic.GetValue(''); if ($v) { $clsidIcon = [string]$v } + } finally { $ic.Close() } + } + } finally { $clsidKey.Close() } + } } - } - } - } - # DLL 版本资源(.NET FileVersionInfo,天然支持 UI 语言,无需 koffi) - $dllFileDescription = $null - $dllProductName = $null - if ($dllPath -and (Test-Path -LiteralPath $dllPath -PathType Leaf)) { - try { - $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dllPath) - if ($vi.FileDescription -and $vi.FileDescription.Length -ge 2) { - $dllFileDescription = [string]$vi.FileDescription - } - if ($vi.ProductName -and $vi.ProductName.Length -ge 2) { - $dllProductName = [string]$vi.ProductName - } - } catch {} - } - # sibling shell key MUIVerb - $siblingMUIVerb = $null - if ($shellPath) { - $siblingVerbPath = Join-Path $shellPath $cleanName - if (Test-Path -LiteralPath $siblingVerbPath) { - $smv = (Get-Item -LiteralPath $siblingVerbPath).GetValue('MUIVerb') - if ($smv) { $siblingMUIVerb = [string]$smv } - } - # 回退:反向扫描 shell verbs,查找 CommandStateHandler/DelegateExecute = $actualClsid - if (-not $siblingMUIVerb -and $actualClsid) { - Get-ChildItem -LiteralPath $shellPath -ErrorAction SilentlyContinue | ForEach-Object { - $csh = $_.GetValue('CommandStateHandler') - $de = $_.GetValue('DelegateExecute') - $ech = $_.GetValue('ExplorerCommandHandler') - if (($csh -eq $actualClsid) -or ($de -eq $actualClsid) -or ($ech -eq $actualClsid)) { - $mv = $_.GetValue('MUIVerb') - if ($mv) { $siblingMUIVerb = [string]$mv } + $siblingMUIVerb = $null + if ($shellPath) { + $sib = $root.OpenSubKey($shellPath + '\\' + $cleanName, $false) + if ($sib) { + try { + $v = $sib.GetValue('MUIVerb'); if ($v) { $siblingMUIVerb = [string]$v } + } finally { $sib.Close() } + } + } + $isEnabled = -not $handlerKeyName.StartsWith('-') + $obj = [PSCustomObject]@{ + handlerKeyName = [string]$handlerKeyName + cleanName = [string]$cleanName + defaultVal = $defaultVal + isEnabled = [bool]$isEnabled + actualClsid = [string]$actualClsid + clsidLocalizedString = $clsidLocalizedString + clsidMUIVerb = $clsidMUIVerb + clsidDefault = $clsidDefault + clsidIcon = $clsidIcon + dllPath = $dllPath + dllFileDescription = $null + dllProductName = $null + progIdName = $null + siblingMUIVerb = $siblingMUIVerb + registryKey = [string]($subPath + '\\' + $cleanName) } + [void]$result.Add($obj) + } finally { + $h.Close() } } + } finally { + $shellexKey.Close() } - $isEnabled = -not $handlerKeyName.StartsWith('-') - $regKey = '${shellexSubPath}\\' + $cleanName - [PSCustomObject]@{ - handlerKeyName = [string]$handlerKeyName - cleanName = [string]$cleanName - defaultVal = [string]$defaultVal - isEnabled = [bool]$isEnabled - actualClsid = [string]$actualClsid - clsidLocalizedString = $clsidLocalizedString - clsidMUIVerb = $clsidMUIVerb - clsidDefault = $clsidDefault - dllPath = $dllPath - dllFileDescription = $dllFileDescription - dllProductName = $dllProductName - progIdName = $progIdName - siblingMUIVerb = $siblingMUIVerb - registryKey = [string]$regKey - } -}) +} $result | ConvertTo-Json -Compress -Depth 3 `.trim(); } @@ -363,21 +373,27 @@ $result | ConvertTo-Json -Compress -Depth 3 buildCommandStoreScript(): string { return ` $ErrorActionPreference = 'SilentlyContinue' -$cmdStorePath = 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell' -$result = @() -if (Test-Path -LiteralPath $cmdStorePath) { - Get-ChildItem -LiteralPath $cmdStorePath | ForEach-Object { - $handler = $_.GetValue('ExplorerCommandHandler') - if ($handler -and $handler -match '^\\{[0-9A-Fa-f-]+\\}$') { - $mv = $_.GetValue('MUIVerb') - if ($mv) { - [PSCustomObject]@{ - clsid = [string]$handler - muiverb = [string]$mv +$result = New-Object System.Collections.ArrayList +$cmdStore = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell', $false) +if ($cmdStore) { + try { + foreach ($name in $cmdStore.GetSubKeyNames()) { + $sub = $cmdStore.OpenSubKey($name, $false) + if (-not $sub) { continue } + try { + $handler = $sub.GetValue('ExplorerCommandHandler') + if ($handler -and $handler -match '^\\{[0-9A-Fa-f-]+\\}$') { + $mv = $sub.GetValue('MUIVerb') + if ($mv) { + [void]$result.Add([PSCustomObject]@{ + clsid = [string]$handler + muiverb = [string]$mv + }) + } } - } + } finally { $sub.Close() } } - } | ForEach-Object { $result += $_ } + } finally { $cmdStore.Close() } } $result | ConvertTo-Json -Compress -Depth 2 `.trim(); diff --git a/src/main/services/RegistryService.ts b/src/main/services/RegistryService.ts index 333023f..f8a7b0a 100644 --- a/src/main/services/RegistryService.ts +++ b/src/main/services/RegistryService.ts @@ -1,28 +1,25 @@ -import { MenuScene, MenuItemType } from '../../shared/enums'; +import { MenuScene, MenuItemType, ItemProtectionLevel } from '../../shared/enums'; import { MenuItemEntry } from '../../shared/types'; import { PowerShellBridge } from './PowerShellBridge'; -import { RegistryCache } from '../utils/RegistryCache'; import { ShellExtNameResolver, CommandStoreIndex, PsRawClassicItem, PsRawShellExtItem } from './ShellExtNameResolver'; import log from '../utils/logger'; -// 与 C# RegistryService._sceneRegistryPaths 完全一致 -const SCENE_REGISTRY_PATHS: Record = { - [MenuScene.Desktop]: 'DesktopBackground\\Shell', - [MenuScene.File]: '*\\shell', - [MenuScene.Folder]: 'Directory\\shell', - [MenuScene.Drive]: 'Drive\\shell', - [MenuScene.DirectoryBackground]:'Directory\\Background\\shell', - [MenuScene.RecycleBin]: 'CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell', +const SCENE_REGISTRY_PATHS: Record = { + [MenuScene.Desktop]: ['DesktopBackground\\Shell'], + [MenuScene.File]: ['*\\shell', 'AllFilesystemObjects\\shell', 'SystemFileAssociations\\*\\shell'], + [MenuScene.Folder]: ['Directory\\shell', 'Folder\\shell', 'AllFilesystemObjects\\shell'], + [MenuScene.Drive]: ['Drive\\shell'], + [MenuScene.DirectoryBackground]:['Directory\\Background\\shell'], + [MenuScene.RecycleBin]: ['CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shell'], }; -// Shell 扩展(COM)注册路径:shellex\ContextMenuHandlers -const SCENE_SHELLEX_PATHS: Record = { - [MenuScene.Desktop]: 'DesktopBackground\\shellex\\ContextMenuHandlers', - [MenuScene.File]: '*\\shellex\\ContextMenuHandlers', - [MenuScene.Folder]: 'Directory\\shellex\\ContextMenuHandlers', - [MenuScene.Drive]: 'Drive\\shellex\\ContextMenuHandlers', - [MenuScene.DirectoryBackground]:'Directory\\Background\\shellex\\ContextMenuHandlers', - [MenuScene.RecycleBin]: 'CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shellex\\ContextMenuHandlers', +const SCENE_SHELLEX_PATHS: Record = { + [MenuScene.Desktop]: ['DesktopBackground\\shellex\\ContextMenuHandlers'], + [MenuScene.File]: ['*\\shellex\\ContextMenuHandlers', 'AllFilesystemObjects\\shellex\\ContextMenuHandlers'], + [MenuScene.Folder]: ['Directory\\shellex\\ContextMenuHandlers', 'Folder\\shellex\\ContextMenuHandlers', 'AllFilesystemObjects\\shellex\\ContextMenuHandlers'], + [MenuScene.Drive]: ['Drive\\shellex\\ContextMenuHandlers'], + [MenuScene.DirectoryBackground]:['Directory\\Background\\shellex\\ContextMenuHandlers'], + [MenuScene.RecycleBin]: ['CLSID\\{645FF040-5081-101B-9F08-00AA002F954E}\\shellex\\ContextMenuHandlers'], }; // 完整 HKCR 前缀(用于显示) @@ -30,10 +27,8 @@ const HKCR_PREFIX = 'HKEY_CLASSES_ROOT'; export class RegistryService { private readonly ps: PowerShellBridge; - private readonly cache: RegistryCache; private readonly resolver: ShellExtNameResolver; private readonly cmdStoreIndex: CommandStoreIndex; - /** 事务回滚数据:registryKey → 原始 isEnabled */ private rollbackData = new Map(); private inTransaction = false; private nextId = 1; @@ -42,12 +37,10 @@ export class RegistryService { ps: PowerShellBridge, resolver: ShellExtNameResolver, cmdStoreIndex: CommandStoreIndex, - cache?: RegistryCache, ) { this.ps = ps; this.resolver = resolver; this.cmdStoreIndex = cmdStoreIndex; - this.cache = cache ?? new RegistryCache(); } /** @@ -55,39 +48,50 @@ export class RegistryService { * 优先从缓存读取,缓存未命中时执行 PowerShell 查询 */ 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]; + const basePaths = SCENE_REGISTRY_PATHS[scene]; + const shellexPaths = SCENE_SHELLEX_PATHS[scene]; try { - // 并行读取 Classic Shell 命令 + Shell 扩展(COM ContextMenuHandlers) - const script = this.ps.buildGetItemsScript(basePath); - const shellexScript = this.ps.buildGetShellExtItemsScript(shellexPath); - const [raw, shellexRaw] = await Promise.all([ - this.ps.execute(script, priority), - this.ps.execute(shellexScript, priority).catch((e) => { - log.warn(`getMenuItems shellex(${scene}) failed (non-fatal):`, e); - return [] as PsRawShellExtItem[]; - }), + // 每个场景只启动 2 个 PS 进程:一个扫描所有 classic 路径,一个扫描所有 shellex 路径 + const [classicRaw, shellexRaw] = await Promise.all([ + this.ps.execute(this.ps.buildGetItemsScript(basePaths), priority) + .then(r => Array.isArray(r) ? r : (r ? [r] : [])) + .catch((e) => { log.warn(`getMenuItems classic(${scene}) failed:`, e); return [] as PsRawClassicItem[]; }), + this.ps.execute(this.ps.buildGetShellExtItemsScript(shellexPaths), priority) + .then(r => Array.isArray(r) ? r : []) + .catch((e) => { log.warn(`getMenuItems shellex(${scene}) failed:`, e); return [] as PsRawShellExtItem[]; }), ]); - const classicItems = Array.isArray(raw) ? raw : (raw ? [raw] : []); - const shellexItems = Array.isArray(shellexRaw) ? shellexRaw : []; + + // 去重(按 subKeyName / actualClsid,保留首次出现的) + const seenClassic = new Set(); + const classicItems: PsRawClassicItem[] = []; + for (const item of classicRaw) { + const dedup = item.subKeyName.toLowerCase(); + if (!seenClassic.has(dedup)) { seenClassic.add(dedup); classicItems.push(item); } + } + const seenShellExt = new Set(); + const shellexItems: PsRawShellExtItem[] = []; + for (const item of shellexRaw) { + const dedup = item.actualClsid.toLowerCase(); + if (!seenShellExt.has(dedup)) { seenShellExt.add(dedup); shellexItems.push(item); } + } // Classic Shell 条目:通过 resolver 解析名称(保护:单条失败不影响整体) const classicEntries: MenuItemEntry[] = classicItems.map((r: PsRawClassicItem) => { let name: string; + let nameFromFallback = false; try { name = this.cleanDisplayName(this.resolver.resolveClassicName(r)); } catch (e) { log.warn(`[RegistryService] resolveClassicName failed for "${r.subKeyName}":`, String(e)); name = this.cleanDisplayName(r.subKeyName); } + // 当解析结果等于原始键名时,标记 fallback(无本地化数据可用) + if (name === this.cleanDisplayName(r.subKeyName)) { + nameFromFallback = true; + } + const protection = this.classifyProtection(r); + const origin = this.classifyOriginClassic(r, protection.level); return { id: this.nextId++, name, @@ -99,44 +103,49 @@ export class RegistryService { registryKey: r.registryKey, type: r.command && r.command.trim() ? MenuItemType.Custom : MenuItemType.System, dllPath: null, + protectionLevel: protection.level, + protectionReason: protection.reason, + isExtended: r.hasExtended || undefined, + hasSubCommands: r.hasSubCommands || undefined, + nameFromFallback: nameFromFallback || undefined, + origin, }; }); // Shell 扩展条目:通过 resolver 解析名称(保护:单条失败不影响整体) const shellexEntries: MenuItemEntry[] = shellexItems.map((r: PsRawShellExtItem) => { let name: string; + let nameFromFallback = false; try { name = this.cleanDisplayName(this.resolver.resolveExtName(r, this.cmdStoreIndex)); } catch (e) { log.warn(`[RegistryService] resolveExtName failed for "${r.cleanName}":`, String(e)); name = this.cleanDisplayName(r.cleanName); } + if (name === this.cleanDisplayName(r.cleanName)) { + nameFromFallback = true; + } + const protection = this.classifyShellExtProtection(r.actualClsid); + const origin = this.classifyOriginShellExt(r, protection.level); return { id: this.nextId++, name, command: r.actualClsid, - iconPath: null, + iconPath: r.clsidIcon ?? null, isEnabled: r.isEnabled, source: r.handlerKeyName, menuScene: scene, registryKey: r.registryKey, type: MenuItemType.ShellExt, dllPath: r.dllPath ?? null, + protectionLevel: protection.level, + protectionReason: protection.reason, + nameFromFallback: nameFromFallback || undefined, + origin, }; }); const result = [...classicEntries, ...shellexEntries]; - - // 逐条诊断日志: 打印每个 ShellExt 条目的解析结果和原始数据 - for (let i = 0; i < shellexEntries.length; i++) { - const entry = shellexEntries[i]; - const raw = shellexItems[i]; - log.info(`[ResolveTrace] ${scene} | "${entry.name}" ← cleanName="${raw.cleanName}" clsid=${raw.actualClsid} dll=${raw.dllPath || 'none'} clsidDef="${raw.clsidDefault || ''}" clsidLS="${raw.clsidLocalizedString || ''}" clsidMUI="${raw.clsidMUIVerb || ''}" progId="${raw.progIdName || ''}" dllDesc="${raw.dllFileDescription || ''}" dllProd="${raw.dllProductName || ''}" siblingMUI="${raw.siblingMUIVerb || ''}" defVal="${raw.defaultVal || ''}"`); - } - - // 写入缓存 - this.cache.set(scene, result); - return result; } catch (e) { log.error(`getMenuItems(${scene}) failed:`, e); @@ -217,35 +226,7 @@ export class RegistryService { * 获取场景对应的完整注册表路径(用于 UI 显示) */ getFullRegistryPath(scene: MenuScene): string { - 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(); + return `${HKCR_PREFIX}\\${SCENE_REGISTRY_PATHS[scene][0]}`; } private async setItemEnabledInternal(registryKey: string, enabled: boolean): Promise { @@ -274,4 +255,77 @@ export class RegistryService { .trim(); } + classifyProtection( + raw: PsRawClassicItem, + ): { level: ItemProtectionLevel; reason?: string } { + if (raw.hasSuppression) { + return { level: ItemProtectionLevel.Protected, reason: '系统策略禁止修改' }; + } + if (raw.hasProgrammaticAccessOnly) { + return { level: ItemProtectionLevel.Protected, reason: '编程专用条目' }; + } + const verb = raw.subKeyName.toLowerCase(); + const coreVerbs = ['open', 'explore', 'find', 'properties']; + if (coreVerbs.includes(verb) && !raw.command?.trim()) { + return { level: ItemProtectionLevel.Protected, reason: '系统核心功能' }; + } + if (raw.hasExtended) { + return { level: ItemProtectionLevel.Warning, reason: '仅 Shift+右键可见' }; + } + const warningVerbs = ['runas', 'runasuser']; + if (warningVerbs.includes(verb)) { + return { level: ItemProtectionLevel.Warning, reason: '系统管理功能' }; + } + return { level: ItemProtectionLevel.Normal }; + } + + classifyShellExtProtection(clsid: string): { level: ItemProtectionLevel; reason?: string } { + if (SYSTEM_SHELL_EXT_CLSIDS.has(clsid.toLowerCase())) { + return { level: ItemProtectionLevel.Warning, reason: '系统内置扩展' }; + } + return { level: ItemProtectionLevel.Normal }; + } + + /** Classic Shell 条目来源判定(系统 vs 第三方) */ + classifyOriginClassic( + raw: PsRawClassicItem, + protectionLevel: ItemProtectionLevel, + ): 'system' | 'third-party' { + if (protectionLevel === ItemProtectionLevel.Protected) return 'system'; + const verb = raw.subKeyName.toLowerCase(); + const systemVerbs = ['open', 'explore', 'find', 'properties', 'runas', 'runasuser', 'edit', 'print', 'printto', 'preview', 'play']; + if (systemVerbs.includes(verb)) return 'system'; + return 'third-party'; + } + + /** Shell 扩展条目来源判定(系统 vs 第三方)—— CLSID 白名单或 DLL 位于 Windows 系统目录视为 system */ + classifyOriginShellExt( + raw: PsRawShellExtItem, + _protectionLevel: ItemProtectionLevel, + ): 'system' | 'third-party' { + if (SYSTEM_SHELL_EXT_CLSIDS.has(raw.actualClsid.toLowerCase())) return 'system'; + if (raw.dllPath) { + const lc = raw.dllPath.toLowerCase().replace(/\//g, '\\'); + if (lc.includes('\\windows\\system32\\') || + lc.includes('\\windows\\syswow64\\') || + lc.includes('\\windows\\winsxs\\') || + lc.includes('\\windows\\immersivecontrolpanel\\') || + lc.match(/^[a-z]:\\windows\\[^\\]+\.dll$/i)) { + return 'system'; + } + } + return 'third-party'; + } } + +const SYSTEM_SHELL_EXT_CLSIDS = new Set([ + '{f81e9010-6ea4-11ce-a7ff-00aa003ca9f6}', // Shell DocObject Viewer + '{90aa3a4e-1cba-4233-b8bb-535773d48449}', // 共享 + '{ffe2a43c-56b9-4bf5-9a79-cc6d4285608a}', // CopyAsPath + '{7ad84985-87b4-4a16-be58-8b72a5b390f7}', // CopyAsPathMenu + '{49707377-a065-4a55-a672-40d0c9a30529}', // PlayTo Menu + '{e2bf9676-5f8f-435c-97eb-11607a5bedf7}', // 新建 + '{d969a300-e7ff-11d0-a93b-00a0c90f2719}', // 任务栏固定 + '{b63ea76d-1f85-456f-a19c-48159efa858b}', // 快速访问固定 + '{081e31a0-8c7a-48cc-93b1-ed49cc3a8ac7}', // 开始菜单固定 +]); diff --git a/src/main/services/ShellExtNameResolver.ts b/src/main/services/ShellExtNameResolver.ts index e6be60c..1325e51 100644 --- a/src/main/services/ShellExtNameResolver.ts +++ b/src/main/services/ShellExtNameResolver.ts @@ -1,45 +1,68 @@ -import { IWin32Shell } from './Win32Shell'; +import { IWin32Shell, PrimaryLang } from './Win32Shell'; import log from '../utils/logger'; -// ---- 标准谓词翻译表 ---- +// ---- 标准谓词翻译表(多语言)---- // Windows 对 open/edit/print 等标准 shell 动词有内置翻译,MUIVerb 为空时生效 // 参考: https://learn.microsoft.com/en-us/windows/win32/shell/context-menu-handlers -const STANDARD_VERBS: Record = { - 'open': { zh: '打开', en: 'Open' }, - 'edit': { zh: '编辑', en: 'Edit' }, - 'print': { zh: '打印', en: 'Print' }, - 'printto': { zh: '打印到', en: 'Print to' }, - 'find': { zh: '搜索', en: 'Find' }, - 'explore': { zh: '浏览', en: 'Explore' }, - 'play': { zh: '播放', en: 'Play' }, - 'preview': { zh: '预览', en: 'Preview' }, - 'runas': { zh: '以管理员身份运行', en: 'Run as administrator' }, - 'runasuser': { zh: '以其他用户身份运行', en: 'Run as different user' }, - 'properties': { zh: '属性', en: 'Properties' }, - 'cut': { zh: '剪切', en: 'Cut' }, - 'copy': { zh: '复制', en: 'Copy' }, - 'paste': { zh: '粘贴', en: 'Paste' }, - 'delete': { zh: '删除', en: 'Delete' }, - 'rename': { zh: '重命名', en: 'Rename' }, - 'sendto': { zh: '发送到', en: 'Send to' }, - 'new': { zh: '新建', en: 'New' }, - 'select': { zh: '选择', en: 'Select' }, - 'refresh': { zh: '刷新', en: 'Refresh' }, - 'view': { zh: '查看', en: 'View' }, - 'sort': { zh: '排序', en: 'Sort' }, - 'share': { zh: '共享', en: 'Share' }, - 'format': { zh: '格式化', en: 'Format' }, - 'eject': { zh: '弹出', en: 'Eject' }, - 'install': { zh: '安装', en: 'Install' }, - 'config': { zh: '配置', en: 'Configure' }, - 'scan': { zh: '扫描', en: 'Scan' }, - 'restore': { zh: '还原', en: 'Restore' }, - 'togglehidden': { zh: '显示/隐藏', en: 'Toggle Hidden' }, - 'pintohome': { zh: '固定到快速访问', en: 'Pin to Quick access' }, - 'pintotaskbar': { zh: '固定到任务栏', en: 'Pin to taskbar' }, - 'unpintotaskbar': { zh: '从任务栏取消固定', en: 'Unpin from taskbar' }, - 'pinToStart': { zh: '固定到"开始"屏幕', en: 'Pin to Start' }, - 'unpinFromStart': { zh: '从"开始"屏幕取消固定', en: 'Unpin from Start' }, +// 翻译覆盖:zh / en / ja / ko / de / fr / ru / es(其他语言回退 en) +type LangTranslations = Partial> & { en: string }; + +const STANDARD_VERBS: Record = { + 'open': { en: 'Open', zh: '打开', ja: '開く', ko: '열기', de: 'Öffnen', fr: 'Ouvrir', ru: 'Открыть', es: 'Abrir' }, + 'edit': { en: 'Edit', zh: '编辑', ja: '編集', ko: '편집', de: 'Bearbeiten', fr: 'Modifier', ru: 'Изменить', es: 'Editar' }, + 'print': { en: 'Print', zh: '打印', ja: '印刷', ko: '인쇄', de: 'Drucken', fr: 'Imprimer', ru: 'Печать', es: 'Imprimir' }, + 'printto': { en: 'Print to', zh: '打印到', ja: '印刷先', ko: '인쇄 위치', de: 'Drucken auf', fr: 'Imprimer sur', ru: 'Печать на', es: 'Imprimir en' }, + 'find': { en: 'Find', zh: '搜索', ja: '検索', ko: '찾기', de: 'Suchen', fr: 'Rechercher', ru: 'Найти', es: 'Buscar' }, + 'explore': { en: 'Explore', zh: '浏览', ja: 'エクスプローラー', ko: '탐색', de: 'Durchsuchen', fr: 'Explorer', ru: 'Проводник', es: 'Explorar' }, + 'play': { en: 'Play', zh: '播放', ja: '再生', ko: '재생', de: 'Wiedergeben', fr: 'Lire', ru: 'Воспроизвести', es: 'Reproducir' }, + 'preview': { en: 'Preview', zh: '预览', ja: 'プレビュー', ko: '미리 보기', de: 'Vorschau', fr: 'Aperçu', ru: 'Просмотр', es: 'Vista previa' }, + 'runas': { en: 'Run as administrator', zh: '以管理员身份运行', ja: '管理者として実行', ko: '관리자 권한으로 실행', de: 'Als Administrator ausführen', fr: 'Exécuter en tant qu’administrateur', ru: 'Запуск от имени администратора', es: 'Ejecutar como administrador' }, + 'runasuser': { en: 'Run as different user', zh: '以其他用户身份运行', ja: '別のユーザーとして実行', ko: '다른 사용자로 실행', de: 'Als anderer Benutzer ausführen', fr: 'Exécuter en tant qu’un autre utilisateur', ru: 'Запуск от имени другого пользователя', es: 'Ejecutar como otro usuario' }, + 'properties': { en: 'Properties', zh: '属性', ja: 'プロパティ', ko: '속성', de: 'Eigenschaften', fr: 'Propriétés', ru: 'Свойства', es: 'Propiedades' }, + 'cut': { en: 'Cut', zh: '剪切', ja: '切り取り', ko: '잘라내기', de: 'Ausschneiden', fr: 'Couper', ru: 'Вырезать', es: 'Cortar' }, + 'copy': { en: 'Copy', zh: '复制', ja: 'コピー', ko: '복사', de: 'Kopieren', fr: 'Copier', ru: 'Копировать', es: 'Copiar' }, + 'paste': { en: 'Paste', zh: '粘贴', ja: '貼り付け', ko: '붙여넣기', de: 'Einfügen', fr: 'Coller', ru: 'Вставить', es: 'Pegar' }, + 'delete': { en: 'Delete', zh: '删除', ja: '削除', ko: '삭제', de: 'Löschen', fr: 'Supprimer', ru: 'Удалить', es: 'Eliminar' }, + 'rename': { en: 'Rename', zh: '重命名', ja: '名前の変更', ko: '이름 바꾸기', de: 'Umbenennen', fr: 'Renommer', ru: 'Переименовать', es: 'Cambiar nombre' }, + 'sendto': { en: 'Send to', zh: '发送到', ja: '送る', ko: '보내기', de: 'Senden an', fr: 'Envoyer vers', ru: 'Отправить', es: 'Enviar a' }, + 'new': { en: 'New', zh: '新建', ja: '新規作成', ko: '새로 만들기', de: 'Neu', fr: 'Nouveau', ru: 'Создать', es: 'Nuevo' }, + 'select': { en: 'Select', zh: '选择', ja: '選択', ko: '선택', de: 'Auswählen', fr: 'Sélectionner', ru: 'Выбрать', es: 'Seleccionar' }, + 'refresh': { en: 'Refresh', zh: '刷新', ja: '更新', ko: '새로 고침', de: 'Aktualisieren', fr: 'Actualiser', ru: 'Обновить', es: 'Actualizar' }, + 'view': { en: 'View', zh: '查看', ja: '表示', ko: '보기', de: 'Ansicht', fr: 'Affichage', ru: 'Вид', es: 'Ver' }, + 'sort': { en: 'Sort', zh: '排序', ja: '並べ替え', ko: '정렬', de: 'Sortieren', fr: 'Trier', ru: 'Сортировка', es: 'Ordenar' }, + 'share': { en: 'Share', zh: '共享', ja: '共有', ko: '공유', de: 'Freigeben', fr: 'Partager', ru: 'Поделиться', es: 'Compartir' }, + 'format': { en: 'Format', zh: '格式化', ja: 'フォーマット', ko: '포맷', de: 'Formatieren', fr: 'Formater', ru: 'Форматировать', es: 'Formatear' }, + 'eject': { en: 'Eject', zh: '弹出', ja: '取り出し', ko: '꺼내기', de: 'Auswerfen', fr: 'Éjecter', ru: 'Извлечь', es: 'Expulsar' }, + 'install': { en: 'Install', zh: '安装', ja: 'インストール', ko: '설치', de: 'Installieren', fr: 'Installer', ru: 'Установить', es: 'Instalar' }, + 'config': { en: 'Configure', zh: '配置', ja: '構成', ko: '구성', de: 'Konfigurieren', fr: 'Configurer', ru: 'Настроить', es: 'Configurar' }, + 'scan': { en: 'Scan', zh: '扫描', ja: 'スキャン', ko: '검사', de: 'Scannen', fr: 'Analyser', ru: 'Сканировать', es: 'Examinar' }, + 'restore': { en: 'Restore', zh: '还原', ja: '復元', ko: '복원', de: 'Wiederherstellen', fr: 'Restaurer', ru: 'Восстановить', es: 'Restaurar' }, + 'togglehidden': { en: 'Toggle Hidden', zh: '显示/隐藏', ja: '非表示の切替', ko: '숨김 전환', de: 'Ausgeblendet umschalten', fr: 'Basculer masqué', ru: 'Скрыть/показать', es: 'Alternar oculto' }, + 'pintohome': { en: 'Pin to Quick access', zh: '固定到快速访问', ja: 'クイック アクセスにピン留め', ko: '즐겨찾기에 고정', de: 'An Schnellzugriff anheften', fr: 'Épingler à l’accès rapide', ru: 'Закрепить в быстром доступе', es: 'Anclar a Acceso rápido' }, + 'unpinfromhome': { en: 'Unpin from Quick access', zh: '从快速访问取消固定', ja: 'クイック アクセスから外す', ko: '즐겨찾기에서 제거', de: 'Von Schnellzugriff lösen', fr: 'Détacher de l’accès rapide', ru: 'Открепить от быстрого доступа', es: 'Desanclar del Acceso rápido' }, + 'pintotaskbar': { en: 'Pin to taskbar', zh: '固定到任务栏', ja: 'タスクバーにピン留めする', ko: '작업 표시줄에 고정', de: 'An Taskleiste anheften', fr: 'Épingler à la barre des tâches', ru: 'Закрепить на панели задач', es: 'Anclar a la barra de tareas' }, + 'unpinfromtaskbar': { en: 'Unpin from taskbar', zh: '从任务栏取消固定', ja: 'タスクバーからピン留めを外す', ko: '작업 표시줄에서 제거', de: 'Von Taskleiste lösen', fr: 'Détacher de la barre des tâches', ru: 'Открепить от панели задач', es: 'Desanclar de la barra de tareas' }, + 'pintostart': { en: 'Pin to Start', zh: '固定到"开始"屏幕', ja: 'スタートにピン留め', ko: '시작 화면에 고정', de: 'An Start anheften', fr: 'Épingler à l’écran d’accueil', ru: 'Закрепить на начальном экране', es: 'Anclar a Inicio' }, + 'unpinfromstart': { en: 'Unpin from Start', zh: '从"开始"屏幕取消固定', ja: 'スタートからピン留めを外す', ko: '시작 화면에서 제거', de: 'Von Start lösen', fr: 'Détacher de l’écran d’accueil', ru: 'Открепить от начального экрана', es: 'Desanclar de Inicio' }, + 'compress': { en: 'Compress', zh: '压缩', ja: '圧縮', ko: '압축', de: 'Komprimieren', fr: 'Compresser', ru: 'Сжать', es: 'Comprimir' }, + 'extract': { en: 'Extract', zh: '解压', ja: '展開', ko: '압축 풀기', de: 'Extrahieren', fr: 'Extraire', ru: 'Извлечь', es: 'Extraer' }, + 'extractall': { en: 'Extract All', zh: '全部解压', ja: 'すべて展開', ko: '모두 압축 풀기', de: 'Alle extrahieren', fr: 'Tout extraire', ru: 'Извлечь всё', es: 'Extraer todo' }, + 'extracthere': { en: 'Extract here', zh: '解压到当前文件夹', ja: 'ここに展開', ko: '여기에 압축 풀기', de: 'Hierher extrahieren', fr: 'Extraire ici', ru: 'Извлечь здесь', es: 'Extraer aquí' }, + 'extractto': { en: 'Extract to...', zh: '解压到...', ja: '指定先に展開', ko: '...에 압축 풀기', de: 'Extrahieren nach...', fr: 'Extraire vers...', ru: 'Извлечь в...', es: 'Extraer en...' }, + 'burn': { en: 'Burn to disc', zh: '刻录到光盘', ja: 'ディスクに書き込む', ko: '디스크에 굽기', de: 'Auf Datenträger brennen', fr: 'Graver sur un disque', ru: 'Записать на диск', es: 'Grabar en disco' }, + 'openwith': { en: 'Open with', zh: '打开方式', ja: 'プログラムから開く', ko: '연결 프로그램', de: 'Öffnen mit', fr: 'Ouvrir avec', ru: 'Открыть с помощью', es: 'Abrir con' }, + 'openfilelocation': { en: 'Open file location', zh: '打开文件所在的位置', ja: 'ファイルの場所を開く', ko: '파일 위치 열기', de: 'Dateispeicherort öffnen', fr: 'Ouvrir l’emplacement du fichier', ru: 'Расположение файла', es: 'Abrir ubicación del archivo' }, + 'opennewwindow': { en: 'Open in new window', zh: '在新窗口中打开', ja: '新しいウィンドウで開く', ko: '새 창에서 열기', de: 'In neuem Fenster öffnen', fr: 'Ouvrir dans une nouvelle fenêtre', ru: 'Открыть в новом окне', es: 'Abrir en nueva ventana' }, + 'opennewtab': { en: 'Open in new tab', zh: '在新标签页中打开', ja: '新しいタブで開く', ko: '새 탭에서 열기', de: 'In neuem Tab öffnen', fr: 'Ouvrir dans un nouvel onglet', ru: 'Открыть в новой вкладке', es: 'Abrir en nueva pestaña' }, + 'sharewith': { en: 'Share with', zh: '共享对象', ja: '共有相手', ko: '공유 대상', de: 'Freigeben für', fr: 'Partager avec', ru: 'Поделиться с', es: 'Compartir con' }, + 'showmore': { en: 'Show more options', zh: '显示更多选项', ja: 'その他のオプションを表示', ko: '추가 옵션 표시', de: 'Weitere Optionen anzeigen', fr: 'Afficher plus d’options', ru: 'Показать дополнительные параметры', es: 'Mostrar más opciones' }, + 'createshortcut': { en: 'Create shortcut', zh: '创建快捷方式', ja: 'ショートカットの作成', ko: '바로 가기 만들기', de: 'Verknüpfung erstellen', fr: 'Créer un raccourci', ru: 'Создать ярлык', es: 'Crear acceso directo' }, + 'addtofavorites': { en: 'Add to Favorites', zh: '添加到收藏夹', ja: 'お気に入りに追加', ko: '즐겨찾기에 추가', de: 'Zu Favoriten hinzufügen', fr: 'Ajouter aux favoris', ru: 'Добавить в избранное', es: 'Agregar a favoritos' }, + 'openelevated': { en: 'Open elevated', zh: '以提升的权限打开', ja: '昇格して開く', ko: '권한 상승 열기', de: 'Mit erhöhten Rechten öffnen', fr: 'Ouvrir avec privilèges élevés', ru: 'Открыть с повышенными правами', es: 'Abrir con privilegios' }, + 'mount': { en: 'Mount', zh: '装载', ja: 'マウント', ko: '탑재', de: 'Bereitstellen', fr: 'Monter', ru: 'Подключить', es: 'Montar' }, + 'unmount': { en: 'Unmount', zh: '卸载', ja: 'マウント解除', ko: '탑재 해제', de: 'Trennen', fr: 'Démonter', ru: 'Отключить', es: 'Desmontar' }, + 'cmd': { en: 'Open in Terminal', zh: '在终端中打开', ja: 'ターミナルで開く', ko: '터미널에서 열기', de: 'In Terminal öffnen', fr: 'Ouvrir dans le terminal', ru: 'Открыть в терминале', es: 'Abrir en Terminal' }, + 'powershell': { en: 'Open in PowerShell', zh: '在 PowerShell 中打开', ja: 'PowerShell で開く', ko: 'PowerShell에서 열기', de: 'In PowerShell öffnen', fr: 'Ouvrir dans PowerShell', ru: 'Открыть в PowerShell', es: 'Abrir en PowerShell' }, }; // ---- 数据契约:PS 脚本返回的原始数据 ---- @@ -53,6 +76,11 @@ export interface PsRawClassicItem { isEnabled: boolean; command: string; registryKey: string; + hasExtended: boolean; + hasSubCommands: boolean; + hasSuppression: boolean; + hasProgrammaticAccessOnly: boolean; + hasHasLUAShield: boolean; } export interface PsRawShellExtItem { @@ -64,6 +92,7 @@ export interface PsRawShellExtItem { clsidLocalizedString: string | null; clsidMUIVerb: string | null; clsidDefault: string | null; + clsidIcon: string | null; dllPath: string | null; dllFileDescription: string | null; dllProductName: string | null; @@ -133,11 +162,12 @@ export class CommandStoreIndex { } } -function translateStandardVerb(name: string, language: 'zh' | 'en'): string | null { +function translateStandardVerb(name: string, language: PrimaryLang): string | null { const lc = name.toLowerCase().trim(); const entry = STANDARD_VERBS[lc]; if (entry) { - return entry[language] || entry.en; + // 命中目标语言→使用,否则总是回退英文 + return entry[language] ?? entry.en; } return null; } @@ -145,9 +175,10 @@ function translateStandardVerb(name: string, language: 'zh' | 'en'): string | nu // ---- Shell 扩展名称解析器 ---- export class ShellExtNameResolver { - private readonly language: 'zh' | 'en'; + private readonly language: PrimaryLang; - constructor(private readonly win32: IWin32Shell, language: 'zh' | 'en' = 'zh') { + // 兼容旧 'zh'/'en' 构造签名:也接受完整 PrimaryLang + constructor(private readonly win32: IWin32Shell, language: PrimaryLang = 'zh') { this.language = language; } diff --git a/src/main/services/SystemInfoService.ts b/src/main/services/SystemInfoService.ts new file mode 100644 index 0000000..bd8ce76 --- /dev/null +++ b/src/main/services/SystemInfoService.ts @@ -0,0 +1,116 @@ +import { PowerShellBridge } from './PowerShellBridge'; +import log from '../utils/logger'; + +export interface SystemMenuStyle { + osVersion: 'win10' | 'win11'; + menuStyle: 'classic' | 'win11-new'; + buildNumber: number; +} + +interface RawSystemInfo { + buildNumber: number; + classicMenuForced: boolean; +} + +export class SystemInfoService { + private cached: SystemMenuStyle | null = null; + + constructor(private readonly ps: PowerShellBridge) {} + + async getMenuStyle(): Promise { + if (this.cached) return this.cached; + + const raw = await this.ps.execute( + SystemInfoService.buildDetectScript(), + ); + + const isWin11 = raw.buildNumber >= 22000; + const result: SystemMenuStyle = { + osVersion: isWin11 ? 'win11' : 'win10', + menuStyle: isWin11 && !raw.classicMenuForced ? 'win11-new' : 'classic', + buildNumber: raw.buildNumber, + }; + + log.info( + `[SystemInfo] OS=${result.osVersion} build=${result.buildNumber} menuStyle=${result.menuStyle}`, + ); + this.cached = result; + return result; + } + + invalidateCache(): void { + this.cached = null; + } + + /** 切换菜单样式(HKCU 用户级,无需管理员)+ 重启 explorer 让其生效 */ + async setMenuStyle(target: 'classic' | 'win11-new'): Promise { + const script = target === 'classic' + ? SystemInfoService.buildSetClassicScript() + : SystemInfoService.buildSetWin11NewScript(); + await this.ps.execute<{ ok: boolean }>(script); + this.invalidateCache(); + log.info(`[SystemInfo] Menu style switched to: ${target}`); + } + + async restartExplorer(): Promise { + await this.ps.execute<{ ok: boolean }>(SystemInfoService.buildRestartExplorerScript()); + log.info('[SystemInfo] explorer.exe restarted'); + } + + /** 切到 Win10 经典菜单:在 HKCU CLSID 镜像下创建空字符串的 InprocServer32 默认值 */ + static buildSetClassicScript(): string { + return ` +$ErrorActionPreference = 'Stop' +$keyPath = 'HKCU:\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\\InprocServer32' +New-Item -Path $keyPath -Force | Out-Null +# 设置默认值为空字符串 +New-ItemProperty -Path $keyPath -Name '(default)' -Value '' -PropertyType String -Force | Out-Null +Write-Output '{"ok":true}' +`.trim(); + } + + /** 切到 Win11 新版菜单:删除整个 {86ca1aa0...} HKCU 覆盖键 */ + static buildSetWin11NewScript(): string { + return ` +$ErrorActionPreference = 'Stop' +$keyPath = 'HKCU:\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}' +if (Test-Path -LiteralPath $keyPath) { + Remove-Item -LiteralPath $keyPath -Recurse -Force +} +Write-Output '{"ok":true}' +`.trim(); + } + + /** 重启 explorer.exe(杀进程后由系统的 ShellInfrastructureHost 自动拉起,亦显式启动一次保险) */ + static buildRestartExplorerScript(): string { + return ` +$ErrorActionPreference = 'SilentlyContinue' +Stop-Process -Name explorer -Force +Start-Sleep -Milliseconds 600 +$running = Get-Process -Name explorer -ErrorAction SilentlyContinue +if (-not $running) { + Start-Process explorer.exe +} +Write-Output '{"ok":true}' +`.trim(); + } + + static buildDetectScript(): string { + return ` +$ErrorActionPreference = 'SilentlyContinue' +$buildNumber = 0 +$ntKey = Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -Name CurrentBuildNumber -ErrorAction SilentlyContinue +if ($ntKey) { $buildNumber = [int]$ntKey.CurrentBuildNumber } +$classicMenuForced = $false +$classicKey = 'HKCU:\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\\InprocServer32' +if (Test-Path -LiteralPath $classicKey) { + $val = (Get-Item -LiteralPath $classicKey).GetValue('') + if ($val -ne $null -and $val -eq '') { $classicMenuForced = $true } +} +[PSCustomObject]@{ + buildNumber = $buildNumber + classicMenuForced = $classicMenuForced +} | ConvertTo-Json -Compress +`.trim(); + } +} diff --git a/src/main/services/Win32Shell.ts b/src/main/services/Win32Shell.ts index cb0f131..8680e43 100644 --- a/src/main/services/Win32Shell.ts +++ b/src/main/services/Win32Shell.ts @@ -1,9 +1,29 @@ import koffi from 'koffi'; import log from '../utils/logger'; +/** 完整 BCP47 短码(按系统 UI 语言映射,用于 STANDARD_VERBS 多语言查找) */ +export type PrimaryLang = 'zh' | 'en' | 'ja' | 'ko' | 'de' | 'fr' | 'ru' | 'es' | 'pt' | 'it'; + +/** Windows LANGID 主语言 ID(低 10 位)→ BCP47 短码 */ +const PRIMARY_LANG_MAP: Record = { + 0x04: 'zh', // LANG_CHINESE + 0x09: 'en', // LANG_ENGLISH + 0x11: 'ja', // LANG_JAPANESE + 0x12: 'ko', // LANG_KOREAN + 0x07: 'de', // LANG_GERMAN + 0x0C: 'fr', // LANG_FRENCH + 0x19: 'ru', // LANG_RUSSIAN + 0x0A: 'es', // LANG_SPANISH + 0x16: 'pt', // LANG_PORTUGUESE + 0x10: 'it', // LANG_ITALIAN +}; + export interface IWin32Shell { resolveIndirect(source: string): string | null; + /** @deprecated 仅 zh/en 二分,新代码请用 primaryLang */ readonly uiLanguage: 'zh' | 'en'; + /** 扩展的系统 UI 主语言代码(10 种语言,其他系统回退 en) */ + readonly primaryLang: PrimaryLang; } export class Win32Shell implements IWin32Shell { @@ -16,7 +36,13 @@ export class Win32Shell implements IWin32Shell { private readonly uiLangId: number; get uiLanguage(): 'zh' | 'en' { - return this.uiLangId === 0x04 ? 'zh' : 'en'; + return (this.uiLangId & 0x3FF) === 0x04 ? 'zh' : 'en'; + } + + get primaryLang(): PrimaryLang { + // GetUserDefaultUILanguage 返回 LANGID,低 10 位为主语言 ID + const primaryId = this.uiLangId & 0x3FF; + return PRIMARY_LANG_MAP[primaryId] ?? 'en'; } constructor() { @@ -31,12 +57,13 @@ export class Win32Shell implements IWin32Shell { const kernel32 = koffi.load('kernel32.dll'); const getLangId = kernel32.func('__stdcall', 'GetUserDefaultUILanguage', 'uint16', []); - this.uiLangId = getLangId() & 0xFF; - log.info(`[Win32Shell] Initialized OK — uiLanguage=${this.uiLanguage}`); + // 注意:保留完整 16 位 LANGID(之前 & 0xFF 截断会丢失次语言信息,对 primaryLang 不影响) + this.uiLangId = getLangId(); + log.info(`[Win32Shell] Initialized OK — uiLanguage=${this.uiLanguage} primaryLang=${this.primaryLang} (LANGID=0x${this.uiLangId.toString(16)})`); } catch (e) { log.error(`[Win32Shell] FAILED — reason: ${String(e)}`); this.koffiAvailable = false; - this.uiLangId = 0x09; + this.uiLangId = 0x0409; // en-US fallback } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7d48a8e..3edf88d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import type { RestoreDiffItem, ToggleItemParams, BatchToggleParams, + SystemMenuStyle, } from '../shared/types'; import type { MenuScene } from '../shared/enums'; @@ -78,6 +79,12 @@ const api = { diagnose: () => invoke>(IPC.SYS_DIAGNOSE), + getMenuStyle: () => + invoke(IPC.SYS_MENU_STYLE), + + setMenuStyle: (target: 'classic' | 'win11-new') => + invoke(IPC.SYS_SET_MENU_STYLE, target), + openExternal: (url: string) => invoke(IPC.SYS_OPEN_EXTERNAL, url), diff --git a/src/renderer/index.html b/src/renderer/index.html index ba62632..d276cac 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -95,6 +95,21 @@ .tag-system { background: #EEF0F3; color: #505050; } .tag-custom { background: #F3EFFE; color: #7B4BBA; } .tag-shellext { background: var(--warning-bg); color: var(--warning); } + .tag-extended { background: #E8EAF6; color: #3F51B5; font-size: 10px; } + .tag-subcmd { background: #E0F2F1; color: #00796B; font-size: 10px; padding: 1px 4px; } + .protected-row { opacity: 0.65; } + .fallback-name-tag { font-size: 10px; color: var(--text3); font-style: italic; margin-left: 6px; font-weight: normal; } + /* 分组 header */ + .group-header { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--surface2); border-bottom: 1px solid var(--border2); font-size: 12px; color: var(--text2); font-weight: 600; cursor: pointer; user-select: none; } + .group-header:hover { background: var(--bg); } + .group-header-system { background: rgba(159, 122, 234, 0.06); color: #5E4A99; } + .group-header-danger { background: var(--danger-bg); color: var(--danger); } + .group-arrow { width: 12px; display: inline-block; text-align: center; } + .group-count { margin-left: auto; background: var(--surface); border: 1px solid var(--border); padding: 1px 7px; border-radius: 10px; font-size: 11px; color: var(--text2); font-weight: normal; } + .group-header-danger .group-count { background: rgba(255,255,255,0.7); border-color: rgba(196,43,28,0.2); color: var(--danger); } + .dangerous-hidden-hint { display: flex; align-items: center; gap: 8px; padding: 10px 16px; margin-top: 8px; background: var(--surface2); border: 1px dashed var(--border); border-radius: var(--radius-sm); font-size: 11px; color: var(--text2); cursor: pointer; } + .dangerous-hidden-hint:hover { background: var(--bg); color: var(--accent); } + .toggle-switch:disabled { opacity: 0.4; cursor: not-allowed; } .toggle-switch { width: 40px; height: 20px; border-radius: 10px; border: none; cursor: pointer; position: relative; transition: background 0.2s; flex-shrink: 0; padding: 0; } .toggle-switch.on { background: var(--accent); } .toggle-switch.off { background: #BDBDBD; } @@ -450,6 +465,34 @@ +
+
+
显示高危系统条目
+
默认隐藏会影响资源管理器正常工作的系统核心条目(如 open/properties 等核心 verb);开启后将显示这些条目,但禁用它们可能导致桌面/资源管理器异常
+
+
+ +
+
+ + +
+
右键菜单样式
+
+
+
当前样式
+ +
+
+ + +
+
+
+
+
⚠ 切换会重启资源管理器进程,未保存的文件资源管理器窗口会被关闭。本设置仅在 Windows 11 上有效。
+
+
@@ -528,6 +571,7 @@
·
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 6775833..d070b8c 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -196,8 +196,9 @@ document.addEventListener('DOMContentLoaded', async () => { const defaultNav = document.querySelector('.nav-item[data-scene="Desktop"]'); await switchPage('main', defaultNav ?? undefined, MenuScene.Desktop); - // 后台预加载其余场景的 badge 数量(不阻塞 UI) - preloadBadgeCounts(MenuScene.Desktop); + // 注:不再启动时预加载其他场景的 badge。它们会在用户切到对应场景时延迟加载 + // 避免 5 个并发 PS 进程争抢首屏 PS 槽,导致用户切场景时排队等待 + void preloadBadgeCounts; // 显式忽略,保留导入避免 lint 警告 // 窗口最大化状态同步 window.addEventListener('resize', updateMaximizeBtn); diff --git a/src/renderer/pages/mainPage.ts b/src/renderer/pages/mainPage.ts index b25f68b..5379b82 100644 --- a/src/renderer/pages/mainPage.ts +++ b/src/renderer/pages/mainPage.ts @@ -1,8 +1,35 @@ import '../api/bridge'; -import { MenuScene, MenuItemType } from '../../shared/enums'; -import type { MenuItemEntry, ToggleItemParams } from '../../shared/types'; +import { MenuScene, MenuItemType, ItemProtectionLevel } from '../../shared/enums'; +import type { MenuItemEntry, ToggleItemParams, SystemMenuStyle } from '../../shared/types'; import { t, registerRefreshCallback } from '../i18n'; import { escapeHtml } from '../utils/html'; +import { getSettingsStore } from '../utils/settingsStore'; + +// ── 分组类型 ── +type GroupKey = 'app' | 'system' | 'dangerous'; + +function categorizeItem(item: MenuItemEntry): GroupKey { + if (item.protectionLevel === ItemProtectionLevel.Protected) return 'dangerous'; + if (item.origin === 'system' || item.protectionLevel === ItemProtectionLevel.Warning) return 'system'; + return 'app'; +} + +// 折叠状态:内存保存,不持久化;默认应用展开,系统/高危折叠 +const groupCollapseState: Map> = new Map(); + +function getCollapsedSet(scene: MenuScene): Set { + let set = groupCollapseState.get(scene); + if (!set) { + set = new Set(['system', 'dangerous']); + groupCollapseState.set(scene, set); + } + return set; +} + +interface MainWindow extends Window { + showUndo?: (msg: string, itemId: number) => void; + invalidateAllScenesCache?: () => void; +} export const SCENE_REG_ROOTS: Record = { [MenuScene.Desktop]: 'HKEY_CLASSES_ROOT\\DesktopBackground\\Shell', @@ -86,12 +113,45 @@ export async function loadScene(scene: MenuScene, forceRefresh = false): Promise } const listEl = document.getElementById('itemList'); - if (listEl) listEl.innerHTML = `
${t('main.loading')}
`; + const loadStart = Date.now(); + // 仅在首次加载(无旧内容可显示)时清空列表显示 loading; + // 切场景时保留旧场景列表可见,只在标题上标记加载状态,避免用户看到的列表"消失" + const hasOldContent = currentItems.length > 0 && listEl && !listEl.querySelector('.loading-state'); + if (listEl && !hasOldContent) { + listEl.innerHTML = `
+
+
+ ${t('main.loading')} + 正在扫描注册表… +
+
`; + } + // 始终在标题上显示 loading 提示 + const titleEl = document.getElementById('sceneTitle'); + if (titleEl) titleEl.innerHTML = `${getSceneName(scene)} 加载中…`; + + // 进度文本:每秒更新 elapsed 提示 + const elapsedTimer = setInterval(() => { + const el = document.getElementById('loadingElapsed'); + const elapsed = Math.floor((Date.now() - loadStart) / 1000); + if (titleEl) { + titleEl.innerHTML = `${getSceneName(scene)} 加载中 ${elapsed}s…`; + } + if (!el) return; + if (elapsed >= 8) { + el.textContent = `正在扫描注册表… 已耗时 ${elapsed}s(首次加载较慢,请稍候)`; + } else if (elapsed >= 3) { + el.textContent = `正在扫描注册表… 已耗时 ${elapsed}s`; + } + }, 1000); - selectedItemId = null; - resetDetailPanel(); + if (!hasOldContent) { + selectedItemId = null; + resetDetailPanel(); + } const result = await window.api.getMenuItems(scene); + clearInterval(elapsedTimer); loadingScene = false; if (!result.success) { @@ -119,7 +179,7 @@ async function silentRefreshScene(scene: MenuScene): Promise { } } -// ── 渲染条目列表 ── +// ── 渲染条目列表(按 origin/protection 三段分组)── export function renderItems(): void { const listEl = document.getElementById('itemList'); if (!listEl) return; @@ -137,7 +197,17 @@ export function renderItems(): void { ); } - if (!items.length) { + // 按 origin/protection 分组 + const showDangerous = getSettingsStore().getSettings().showDangerousItems; + const groups: Record = { app: [], system: [], dangerous: [] }; + for (const item of items) { + groups[categorizeItem(item)].push(item); + } + const dangerousHiddenCount = !showDangerous ? groups.dangerous.length : 0; + if (!showDangerous) groups.dangerous = []; + + const totalVisible = groups.app.length + groups.system.length + groups.dangerous.length; + if (!totalVisible && !dangerousHiddenCount) { listEl.innerHTML = `
${t('main.noItems')}
@@ -145,11 +215,55 @@ export function renderItems(): void { return; } - listEl.innerHTML = items.map((item) => renderItemCard(item)).join(''); + const collapsed = getCollapsedSet(currentScene); + const sections: string[] = []; + const renderSection = (key: GroupKey, title: string, accent: 'app' | 'system' | 'danger') => { + const list = groups[key]; + if (!list.length) return; + const isCollapsed = collapsed.has(key); + const arrow = isCollapsed ? '▸' : '▾'; + const accentClass = accent === 'danger' ? 'group-header-danger' : accent === 'system' ? 'group-header-system' : ''; + sections.push(`
+ ${arrow} + ${escapeHtml(title)} + ${list.length} +
`); + if (!isCollapsed) { + sections.push(...list.map((item) => renderItemCard(item))); + } + }; + + renderSection('app', t('main.groupApp') ?? '应用菜单', 'app'); + renderSection('system', t('main.groupSystem') ?? '系统菜单', 'system'); + renderSection('dangerous', `${t('main.groupDangerous') ?? '高危系统条目'} ⚠`, 'danger'); + + if (dangerousHiddenCount > 0) { + sections.push(`
+ + ${dangerousHiddenCount} 条高危系统条目已隐藏 · 在设置中开启「显示高危条目」可查看 +
`); + } + + listEl.innerHTML = sections.join(''); +} + +// 折叠/展开分组 +export function toggleGroup(key: GroupKey): void { + const collapsed = getCollapsedSet(currentScene); + if (collapsed.has(key)) collapsed.delete(key); + else collapsed.add(key); + renderItems(); +} + +// 跳转到设置页"安全"section +export function openSecuritySettings(): void { + const navSettings = document.querySelector('.nav-item[data-page="settings"]'); + if (navSettings) navSettings.click(); } function renderItemCard(item: MenuItemEntry, showScene = false): string { const isSelected = item.id === selectedItemId; + const isProtected = item.protectionLevel === ItemProtectionLevel.Protected; const typeTag = item.type === MenuItemType.Custom ? `${t('item.custom')}` @@ -159,26 +273,39 @@ function renderItemCard(item: MenuItemEntry, showScene = false): string { const stateTag = item.isEnabled ? `${t('item.enabled')}` : `${t('item.disabled')}`; + const extendedTag = item.isExtended ? 'Shift' : ''; + const subCmdTag = item.hasSubCommands ? '' : ''; const sourceText = showScene ? `${getSceneName(item.menuScene)}${item.source ? ' · ' + item.source : ''}` : (item.source || t('item.sourceUnknown')); + const toggleDisabled = isProtected ? ' disabled' : ''; + const toggleTitle = isProtected && item.protectionReason + ? ` title="${escapeHtml(item.protectionReason)}"` + : ''; + + const fallbackTag = item.nameFromFallback + ? '(原始键名)' + : ''; + return ` -
📄
-
${escapeHtml(item.name)}
+
${escapeHtml(item.name)}${fallbackTag}
${escapeHtml(item.command || '—')}
${escapeHtml(sourceText)}
${typeTag} + ${extendedTag} + ${subCmdTag} ${stateTag} - @@ -206,6 +333,15 @@ export async function toggleItem(id: number): Promise { const item = currentItems.find((i) => i.id === id); if (!item) return; + if (item.protectionLevel === ItemProtectionLevel.Protected) { + showOperationError(item.protectionReason || '该条目受系统保护,无法修改'); + return; + } + if (item.protectionLevel === ItemProtectionLevel.Warning) { + const action = item.isEnabled ? '禁用' : '启用'; + if (!confirm(`${item.name} 是${item.protectionReason || '系统功能'},确定要${action}吗?`)) return; + } + const params: ToggleItemParams = { registryKey: item.registryKey, isEnabled: item.isEnabled, @@ -228,9 +364,9 @@ export async function toggleItem(id: number): Promise { 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 }) - .showUndo?.(`${t('main.actionDone')}${action}「${item.name}」`, id); - (window as Window & { invalidateAllScenesCache?: () => void }).invalidateAllScenesCache?.(); + const win = window as MainWindow; + win.showUndo?.(`${t('main.actionDone')}${action}「${item.name}」`, id); + win.invalidateAllScenesCache?.(); updateStatusBarFromCurrent(); if (selectedItemId === id) showDetail(id); @@ -289,8 +425,27 @@ export function showDetail(id: number): void {
${t('item.type')}
${item.type === MenuItemType.Custom ? t('item.custom') : item.type === MenuItemType.ShellExt ? t('item.shellExt') : t('item.system')} + ${item.isExtended ? 'Shift+右键' : ''} + ${item.hasSubCommands ? '含子菜单' : ''} +
+
+ ${item.origin ? `
+
来源分类
+
+ ${item.origin === 'system' ? '系统内置' : '第三方应用'} +
+
` : ''} + ${item.protectionLevel !== ItemProtectionLevel.Normal ? `
+
保护级别
+
+ ${item.protectionLevel === ItemProtectionLevel.Protected ? '受保护' : '需确认'} + ${item.protectionReason ? `${escapeHtml(item.protectionReason)}` : ''}
+ ${item.protectionLevel === ItemProtectionLevel.Protected ? `
+ + ⚠ 此条目影响资源管理器核心功能,禁用可能导致桌面/资源管理器异常 +
` : ''}` : ''}
${t('item.source')}
${escapeHtml(item.source || '—')}
@@ -340,7 +495,17 @@ export function showDetail(id: number): void {
`; - if (actionsEl) actionsEl.style.display = 'flex'; + if (actionsEl) { + const isProtected = item.protectionLevel === ItemProtectionLevel.Protected; + actionsEl.style.display = 'flex'; + const toggleBtn = actionsEl.querySelector('.btn-primary') as HTMLButtonElement | null; + if (toggleBtn) { + toggleBtn.disabled = isProtected; + toggleBtn.title = isProtected ? (item.protectionReason || '受保护条目') : ''; + toggleBtn.style.opacity = isProtected ? '0.4' : ''; + toggleBtn.style.cursor = isProtected ? 'not-allowed' : ''; + } + } } export function flashCopyBtn(btn: HTMLButtonElement): void { @@ -358,10 +523,29 @@ export function setFilter(mode: 'all' | 'enabled' | 'disabled', btn: HTMLElement } // ── 状态栏 ── +let cachedMenuStyle: SystemMenuStyle | null = null; + function updateStatusBar(scene: MenuScene): void { const sbScene = document.getElementById('sbScene'); if (sbScene) sbScene.textContent = `${t('statusBar.currentScene')}${getSceneName(scene)}`; updateStatusBarFromCurrent(); + if (!cachedMenuStyle) { + void loadMenuStyleLabel(); + } +} + +async function loadMenuStyleLabel(): Promise { + const result = await window.api.getMenuStyle(); + if (!result.success) return; + cachedMenuStyle = result.data; + const el = document.getElementById('sbMenuStyle'); + if (!el) return; + const label = cachedMenuStyle.menuStyle === 'win11-new' + ? 'Win11 新版菜单' + : cachedMenuStyle.osVersion === 'win11' + ? 'Win11 经典菜单' + : 'Win10 经典菜单'; + el.textContent = label; } function updateStatusBarFromCurrent(): void { @@ -473,15 +657,24 @@ export function restoreSceneTitle(scene: MenuScene): void { resetDetailPanel(); } -// ── 预加载其余场景的 badge 数量(串行,每个场景完成后立即更新,渐进显示)── +// ── 预加载其余场景的 badge 数量(延迟 + 并发,依赖 PS 信号量自然限流)── +// 延迟 1.2s 启动让首屏 Desktop 渲染完毕,避免争抢 PS 槽位; +// 并发发起所有场景请求,每个完成后立即更新对应 badge +let preloadBadgeCancelled = false; export async function preloadBadgeCounts(skipScene: MenuScene): Promise { + preloadBadgeCancelled = false; + await new Promise(resolve => setTimeout(resolve, 1200)); + if (preloadBadgeCancelled) return; + const allScenes = Object.values(MenuScene) as MenuScene[]; const targetScenes = allScenes.filter((scene) => scene !== skipScene); - for (const scene of targetScenes) { + await Promise.all(targetScenes.map(async (scene) => { + if (preloadBadgeCancelled) return; const result = await window.api.getMenuItems(scene).catch(() => null); + if (preloadBadgeCancelled) return; const badgeEl = document.getElementById(`badge-${scene}`); - if (!badgeEl) continue; + if (!badgeEl) return; if (result && result.success && 'data' in result) { badgeEl.textContent = String(result.data.length); @@ -489,7 +682,11 @@ export async function preloadBadgeCounts(skipScene: MenuScene): Promise { } else { badgeEl.textContent = '?'; } - } + })); +} + +export function cancelBadgePreload(): void { + preloadBadgeCancelled = true; } export function onNavigateAway(): void { @@ -511,6 +708,12 @@ export function onNavigateAway(): void { })(); // 挂载到 window 供 HTML inline onclick 调用 -const mainPageApi = { selectItem, toggleItem, setFilter, flashCopyBtn, toggleFromDetail, deleteSelected }; +const mainPageApi = { selectItem, toggleItem, setFilter, flashCopyBtn, toggleFromDetail, deleteSelected, toggleGroup, openSecuritySettings }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any)._mainPage = mainPageApi; + +// 监听"显示高危条目"开关变化,立即刷新当前列表 +window.addEventListener('cm:show-dangerous-changed', () => { + renderItems(); + updateStatusBarFromCurrent(); +}); diff --git a/src/renderer/pages/settingsPage.ts b/src/renderer/pages/settingsPage.ts index dde67ae..790c4d3 100644 --- a/src/renderer/pages/settingsPage.ts +++ b/src/renderer/pages/settingsPage.ts @@ -9,6 +9,67 @@ let isSettingsInitialized = false; export async function initSettings(): Promise { await updateAdminStatus(); initAppearanceSettings(); + initSecuritySettings(); + void initMenuStyleSettings(); +} + +async function initMenuStyleSettings(): Promise { + const currentEl = document.getElementById('menuStyleCurrent'); + const btnClassic = document.getElementById('btnSetClassic') as HTMLButtonElement | null; + const btnWin11 = document.getElementById('btnSetWin11') as HTMLButtonElement | null; + const result = await window.api.getMenuStyle(); + if (!result.success) { + if (currentEl) currentEl.textContent = '检测失败'; + return; + } + const { menuStyle, osVersion, buildNumber } = result.data; + const label = menuStyle === 'win11-new' + ? 'Win11 新版菜单(精简)' + : osVersion === 'win11' ? 'Win10 经典菜单(已切回经典样式)' : 'Win10 经典菜单'; + if (currentEl) currentEl.textContent = `${label} · build ${buildNumber}`; + + // 非 Win11 系统:禁用切换按钮 + if (osVersion !== 'win11') { + if (btnClassic) { btnClassic.disabled = true; btnClassic.style.opacity = '0.4'; btnClassic.style.cursor = 'not-allowed'; } + if (btnWin11) { btnWin11.disabled = true; btnWin11.style.opacity = '0.4'; btnWin11.style.cursor = 'not-allowed'; } + return; + } + // 当前样式按钮置灰 + if (menuStyle === 'classic' && btnClassic) { btnClassic.disabled = true; btnClassic.style.opacity = '0.4'; } + if (menuStyle === 'win11-new' && btnWin11) { btnWin11.disabled = true; btnWin11.style.opacity = '0.4'; } +} + +export async function switchMenuStyle(target: 'classic' | 'win11-new'): Promise { + const label = target === 'classic' ? 'Win10 经典菜单' : 'Win11 新版菜单'; + if (!confirm(`确定要切换到「${label}」?\n\n这将重启资源管理器进程(explorer.exe),所有打开的文件资源管理器窗口会被关闭。\n\n桌面/任务栏会短暂消失后自动恢复。`)) return; + const result = await window.api.setMenuStyle(target); + if (!result.success) { + alert(`切换失败: ${result.error}`); + return; + } + alert(`已切换到「${label}」。如果资源管理器没有自动恢复,请手动按 Win+E 启动。`); + // 重新初始化样式显示 + await initMenuStyleSettings(); +} + +function initSecuritySettings(): void { + const store = getSettingsStore(); + const showDangerous = store.getSettings().showDangerousItems; + const toggle = document.getElementById('showDangerousToggle'); + if (toggle) { + toggle.classList.toggle('on', showDangerous); + toggle.classList.toggle('off', !showDangerous); + } +} + +export function toggleShowDangerous(btn: HTMLElement): void { + const store = getSettingsStore(); + const newState = !store.getSettings().showDangerousItems; + btn.classList.toggle('on', newState); + btn.classList.toggle('off', !newState); + store.setSetting('showDangerousItems', newState); + // 通知 mainPage 立即刷新当前列表 + window.dispatchEvent(new CustomEvent('cm:show-dangerous-changed', { detail: newState })); } async function updateAdminStatus(): Promise { @@ -95,6 +156,8 @@ export async function runDiagnose(): Promise { const settingsPageApi = { requestAdminRestart, toggleSwitch, + toggleShowDangerous, + switchMenuStyle, openLogDir, runDiagnose, }; diff --git a/src/renderer/utils/settingsStore.ts b/src/renderer/utils/settingsStore.ts index cc9bd06..91b1c71 100644 --- a/src/renderer/utils/settingsStore.ts +++ b/src/renderer/utils/settingsStore.ts @@ -3,6 +3,8 @@ import type { SupportedLanguage } from '../i18n'; export interface AppSettings { theme: 'system' | 'light' | 'dark'; language: SupportedLanguage; + /** 是否显示高危系统条目(默认关闭,避免用户误禁导致资源管理器异常) */ + showDangerousItems: boolean; } const SETTINGS_KEY = 'contextmaster_settings'; @@ -10,6 +12,7 @@ const SETTINGS_KEY = 'contextmaster_settings'; const DEFAULT_SETTINGS: AppSettings = { theme: 'system', language: 'zh-CN', + showDangerousItems: false, }; class SettingsStore { diff --git a/src/shared/enums.ts b/src/shared/enums.ts index 13b2b37..4029478 100644 --- a/src/shared/enums.ts +++ b/src/shared/enums.ts @@ -27,3 +27,9 @@ export enum BackupType { Auto = 'Auto', Manual = 'Manual', } + +export enum ItemProtectionLevel { + Normal = 'normal', + Warning = 'warning', + Protected = 'protected', +} diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index c279405..dc96e0d 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -28,6 +28,8 @@ export const IPC = { SYS_OPEN_EXTERNAL: 'sys:openExternal', SYS_LOG_TO_FILE: 'sys:logToFile', SYS_DIAGNOSE: 'sys:diagnose', + SYS_MENU_STYLE: 'sys:menuStyle', + SYS_SET_MENU_STYLE: 'sys:setMenuStyle', WIN_MINIMIZE: 'win:minimize', WIN_MAXIMIZE: 'win:maximize', WIN_CLOSE: 'win:close', diff --git a/src/shared/types.ts b/src/shared/types.ts index 431b13e..14a290e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,4 +1,4 @@ -import { MenuScene, MenuItemType, OperationType, BackupType } from './enums'; +import { MenuScene, MenuItemType, OperationType, BackupType, ItemProtectionLevel } from './enums'; // IPC 统一返回包装 export type IpcResult = @@ -16,7 +16,15 @@ export interface MenuItemEntry { menuScene: MenuScene; registryKey: string; type: MenuItemType; - dllPath?: string | null; // 仅 ShellExt 类型有值,指向 InprocServer32 DLL + dllPath?: string | null; + protectionLevel: ItemProtectionLevel; + protectionReason?: string; + isExtended?: boolean; + hasSubCommands?: boolean; + /** name 是否回退自原始键名/cleanName(无任何本地化数据可用)—— UI 用于显示"原始键名"标签 */ + nameFromFallback?: boolean; + /** 条目来源分类:系统内置 vs 第三方应用 */ + origin?: 'system' | 'third-party'; } // 操作记录 @@ -70,3 +78,10 @@ export interface ExportBackupParams { export interface WindowInfo { isMaximized: boolean; } + +// 系统菜单样式信息 +export interface SystemMenuStyle { + osVersion: 'win10' | 'win11'; + menuStyle: 'classic' | 'win11-new'; + buildNumber: number; +} diff --git a/tests/unit/main/services/MenuManagerService.test.ts b/tests/unit/main/services/MenuManagerService.test.ts index d821f33..82c32eb 100644 --- a/tests/unit/main/services/MenuManagerService.test.ts +++ b/tests/unit/main/services/MenuManagerService.test.ts @@ -31,6 +31,9 @@ describe('MenuManagerService', () => { commitTransaction: vi.fn(), rollback: vi.fn(), invalidateCache: vi.fn(), + invalidateAllCache: vi.fn(), + getCacheStats: vi.fn(), + logCacheStats: vi.fn(), } as MockedObject; mockHistory = { diff --git a/tests/unit/main/services/PowerShellBridge.test.ts b/tests/unit/main/services/PowerShellBridge.test.ts index 43eba49..7f942d1 100644 --- a/tests/unit/main/services/PowerShellBridge.test.ts +++ b/tests/unit/main/services/PowerShellBridge.test.ts @@ -35,15 +35,16 @@ describe('PowerShellBridge', () => { describe('buildGetItemsScript', () => { it('should return script for getting menu items', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + const script = bridge.buildGetItemsScript(['DesktopBackground\\Shell']); expect(script).toContain('DesktopBackground\\Shell'); - expect(script).toContain('Get-ChildItem'); + // 用 .NET Registry API 直接访问(OpenSubKey),不再用 PS provider 的 Get-ChildItem + expect(script).toContain('OpenSubKey'); expect(script).toContain('ConvertTo-Json'); }); it('应输出 rawMUIVerb / rawDefault / rawLocalizedDisplayName 原始字段', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + const script = bridge.buildGetItemsScript(['DesktopBackground\\Shell']); expect(script).toContain('rawMUIVerb'); expect(script).toContain('rawDefault'); @@ -51,20 +52,39 @@ describe('PowerShellBridge', () => { }); it('不应包含 CmHelper 或 Resolve-MenuName(名称解析已移至 TS 层)', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + const script = bridge.buildGetItemsScript(['DesktopBackground\\Shell']); expect(script).not.toContain('CmHelper'); expect(script).not.toContain('Resolve-MenuName'); expect(script).not.toContain('SHLoadIndirectString'); }); - it('应将正确的注册表路径嵌入脚本', () => { - const script = bridge.buildGetItemsScript('*\\shell'); - expect(script).toContain('HKCR:\\*\\shell'); + it('应将正确的注册表路径嵌入 $scanPaths 数组', () => { + const script = bridge.buildGetItemsScript(['*\\shell']); + expect(script).toContain("'*\\shell'"); + }); + + it('多路径应全部嵌入同一脚本', () => { + const script = bridge.buildGetItemsScript(['*\\shell', 'AllFilesystemObjects\\shell']); + expect(script).toContain("'*\\shell'"); + expect(script).toContain("'AllFilesystemObjects\\shell'"); + }); + + it('应输出保护级别相关的采集字段', () => { + const script = bridge.buildGetItemsScript(['DesktopBackground\\Shell']); + + expect(script).toContain('hasExtended'); + expect(script).toContain('hasSubCommands'); + expect(script).toContain('hasSuppression'); + expect(script).toContain('hasProgrammaticAccessOnly'); + expect(script).toContain('hasHasLUAShield'); + expect(script).toContain('Extended'); + expect(script).toContain('SuppressionPolicy'); + expect(script).toContain('ProgrammaticAccessOnly'); }); it('不应包含热键清理逻辑(热键清理已移至 TS 层 cleanDisplayName)', () => { - const script = bridge.buildGetItemsScript('DesktopBackground\\Shell'); + const script = bridge.buildGetItemsScript(['DesktopBackground\\Shell']); // -replace 只用于键名前缀剥离($handlerKeyName -replace '^-+'),不含加速键正则 expect(script).not.toMatch(/\(&\\w\)|\(\\w\)|&\\w/); }); @@ -73,7 +93,7 @@ describe('PowerShellBridge', () => { describe('buildGetShellExtItemsScript', () => { it('不应包含 CmHelper / Resolve-ExtName / Test-IsGenericName(解析已移至 TS)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('CmHelper'); @@ -87,7 +107,7 @@ describe('PowerShellBridge', () => { it('不应包含 CommandStore 索引构建(已移至独立脚本)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('cmdStoreVerbs'); @@ -96,7 +116,7 @@ describe('PowerShellBridge', () => { it('应输出 handlerKeyName / cleanName / defaultVal 原始字段', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('handlerKeyName'); @@ -106,7 +126,7 @@ describe('PowerShellBridge', () => { it('应输出 CLSID 子键原始字段 (clsidLocalizedString / clsidMUIVerb / clsidDefault)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('clsidLocalizedString'); @@ -116,7 +136,7 @@ describe('PowerShellBridge', () => { it('应读取 InprocServer32 DLL 路径并输出 dllPath 字段', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('InprocServer32'); @@ -126,7 +146,7 @@ describe('PowerShellBridge', () => { it('应输出 siblingMUIVerb 字段', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('siblingMUIVerb'); @@ -134,17 +154,18 @@ describe('PowerShellBridge', () => { it('应包含 sibling shell 路径推导逻辑', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('$shellPath'); - expect(script).toContain('$siblingVerbPath'); expect(script).toContain('ContextMenuHandlers$'); + // sibling shell 同名 verb 查找:OpenSubKey(shellPath + '\\' + cleanName) + expect(script).toMatch(/\$shellPath\s*\+\s*'\\+'\s*\+\s*\$cleanName/); }); it('ForEach 循环应使用 $actualClsid 和 $defaultVal 分离 CLSID', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).toContain('$actualClsid'); @@ -152,7 +173,7 @@ describe('PowerShellBridge', () => { it('不应包含硬编码 friendlyNames 映射表', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('$friendlyNames'); @@ -161,7 +182,7 @@ describe('PowerShellBridge', () => { it('不应包含热键清理逻辑(已移至 TS 层)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); // -replace 只用于键名前缀剥离,不含加速键正则 @@ -170,7 +191,7 @@ describe('PowerShellBridge', () => { it('不应包含 ReadDllStrings(已移除)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('ReadDllStrings'); @@ -179,7 +200,7 @@ describe('PowerShellBridge', () => { it('不应包含 CmHelper.Ver 版本校验', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain("[CmHelper]::Ver"); @@ -187,7 +208,7 @@ describe('PowerShellBridge', () => { it('不应包含 Level 级别注释(Level 逻辑已移至 TS)', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('Level 0:'); @@ -198,7 +219,7 @@ describe('PowerShellBridge', () => { it('不应包含 C# 源码 Add-Type 编译', () => { const script = bridge.buildGetShellExtItemsScript( - 'DesktopBackground\\shellex\\ContextMenuHandlers' + ['DesktopBackground\\shellex\\ContextMenuHandlers'] ); expect(script).not.toContain('using System;'); diff --git a/tests/unit/main/services/RegistryService.test.ts b/tests/unit/main/services/RegistryService.test.ts index 9905ecf..ad81323 100644 --- a/tests/unit/main/services/RegistryService.test.ts +++ b/tests/unit/main/services/RegistryService.test.ts @@ -3,7 +3,7 @@ import { RegistryService } from '@/main/services/RegistryService'; import { PowerShellBridge } from '@/main/services/PowerShellBridge'; import { ShellExtNameResolver, CommandStoreIndex } from '@/main/services/ShellExtNameResolver'; import { IWin32Shell } from '@/main/services/Win32Shell'; -import { MenuScene, MenuItemType } from '@shared/enums'; +import { MenuScene, MenuItemType, ItemProtectionLevel } from '@shared/enums'; // Mock PowerShellBridge vi.mock('@/main/services/PowerShellBridge'); @@ -63,9 +63,17 @@ describe('RegistryService', () => { isEnabled: true, command: 'test.exe', registryKey: 'HKCR\\Test\\shell\\Test Menu', + hasExtended: false, + hasSubCommands: false, + hasSuppression: false, + hasProgrammaticAccessOnly: false, + hasHasLUAShield: false, }]; - mockPs.execute.mockResolvedValueOnce(rawItems).mockResolvedValueOnce([]); + // File 场景现在只有 2 次 execute 调用(多路径合并为单脚本) + mockPs.execute + .mockResolvedValueOnce(rawItems) // 所有 classic 路径(*\shell 等)合并 + .mockResolvedValueOnce([]); // 所有 shellex 路径合并 const result = await service.getMenuItems(MenuScene.File); @@ -85,11 +93,13 @@ describe('RegistryService', () => { clsidLocalizedString: null, clsidMUIVerb: null, clsidDefault: null, + clsidIcon: null, dllPath: 'C:\\Program Files\\YunShellExt\\YunShellExt64.dll', siblingMUIVerb: null, registryKey: 'DesktopBackground\\shellex\\ContextMenuHandlers\\YunShellExt', }]; + // Desktop 场景有 1 个 classic + 1 个 shellex → 2 次 mockPs.execute.mockResolvedValueOnce([]).mockResolvedValueOnce(shellextItems); const result = await service.getMenuItems(MenuScene.Desktop); @@ -224,6 +234,79 @@ describe('RegistryService', () => { }); }); + describe('classifyProtection', () => { + function makeRaw(overrides: Record = {}) { + return { + subKeyName: 'test', + rawMUIVerb: null, + rawDefault: null, + rawLocalizedDisplayName: null, + rawIcon: null, + isEnabled: true, + command: '', + registryKey: 'test', + hasExtended: false, + hasSubCommands: false, + hasSuppression: false, + hasProgrammaticAccessOnly: false, + hasHasLUAShield: false, + ...overrides, + }; + } + + it('SuppressionPolicy → Protected', () => { + const result = service.classifyProtection(makeRaw({ hasSuppression: true })); + expect(result.level).toBe(ItemProtectionLevel.Protected); + }); + + it('ProgrammaticAccessOnly → Protected', () => { + const result = service.classifyProtection(makeRaw({ hasProgrammaticAccessOnly: true })); + expect(result.level).toBe(ItemProtectionLevel.Protected); + }); + + it('open verb without command → Protected', () => { + const result = service.classifyProtection(makeRaw({ subKeyName: 'open', command: '' })); + expect(result.level).toBe(ItemProtectionLevel.Protected); + }); + + it('open verb with command → Normal (has custom command)', () => { + const result = service.classifyProtection(makeRaw({ subKeyName: 'open', command: 'notepad.exe' })); + expect(result.level).toBe(ItemProtectionLevel.Normal); + }); + + it('Extended verb → Warning', () => { + const result = service.classifyProtection(makeRaw({ hasExtended: true })); + expect(result.level).toBe(ItemProtectionLevel.Warning); + }); + + it('runas verb → Warning', () => { + const result = service.classifyProtection(makeRaw({ subKeyName: 'runas' })); + expect(result.level).toBe(ItemProtectionLevel.Warning); + }); + + it('custom verb → Normal', () => { + const result = service.classifyProtection(makeRaw({ subKeyName: 'myapp', command: 'myapp.exe "%1"' })); + expect(result.level).toBe(ItemProtectionLevel.Normal); + }); + }); + + describe('classifyShellExtProtection', () => { + it('known system CLSID → Warning', () => { + const result = service.classifyShellExtProtection('{90AA3A4E-1CBA-4233-B8BB-535773D48449}'); + expect(result.level).toBe(ItemProtectionLevel.Warning); + }); + + it('unknown CLSID → Normal', () => { + const result = service.classifyShellExtProtection('{12345678-1234-1234-1234-123456789abc}'); + expect(result.level).toBe(ItemProtectionLevel.Normal); + }); + + it('case-insensitive CLSID matching', () => { + const result = service.classifyShellExtProtection('{90aa3a4e-1cba-4233-b8bb-535773d48449}'); + expect(result.level).toBe(ItemProtectionLevel.Warning); + }); + }); + describe('transaction management', () => { it('should create rollback point correctly', () => { const items = [ diff --git a/tests/unit/main/services/ShellExtNameResolver.test.ts b/tests/unit/main/services/ShellExtNameResolver.test.ts index b7c20fb..5727a77 100644 --- a/tests/unit/main/services/ShellExtNameResolver.test.ts +++ b/tests/unit/main/services/ShellExtNameResolver.test.ts @@ -115,6 +115,16 @@ describe('ShellExtNameResolver', () => { // empty string skipped, ' ' has length >= 2 so used as-is expect(resolver.resolveClassicName(item)).toBe(' '); }); + + it('should fallback to rawDefault when indirect MUIVerb resolves to empty string', () => { + vi.mocked(win32.resolveIndirect).mockReturnValue(null); + const item = createClassicItem({ + rawMUIVerb: '@broken.dll,-1', + rawDefault: 'FallbackDefault', + rawLocalizedDisplayName: null, + }); + expect(resolver.resolveClassicName(item)).toBe('FallbackDefault'); + }); }); // ---- Shell 扩展名称解析(多级回退) ---- @@ -264,6 +274,28 @@ describe('ShellExtNameResolver', () => { expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); // fallback }); + it('Level 2.5: 应跳过长度 > 64 的 DLL FileDescription', () => { + const longDesc = 'A'.repeat(65); + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllFileDescription: longDesc, + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('TestExt'); + }); + + it('Level 2.5: 应返回 ProductName 当 FileDescription 通过泛型过滤', () => { + const item = createShellExtItem({ + clsidLocalizedString: null, + clsidMUIVerb: null, + clsidDefault: null, + dllFileDescription: null, + dllProductName: 'Visual Studio Code', + }); + expect(resolver.resolveExtName(item, cmdStore)).toBe('Visual Studio Code'); + }); + // Level 3: directName plain string it('Level 3: 应返回非泛型的 plain directName', () => { const item = createShellExtItem({ diff --git a/tests/unit/main/services/SystemInfoService.test.ts b/tests/unit/main/services/SystemInfoService.test.ts new file mode 100644 index 0000000..ea66cba --- /dev/null +++ b/tests/unit/main/services/SystemInfoService.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SystemInfoService } from '@/main/services/SystemInfoService'; + +vi.mock('@/main/utils/logger', () => ({ + default: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('SystemInfoService', () => { + const mockPs = { + execute: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('detects Win11 with new-style menu', async () => { + mockPs.execute.mockResolvedValue({ buildNumber: 22631, classicMenuForced: false }); + const service = new SystemInfoService(mockPs as never); + const result = await service.getMenuStyle(); + expect(result).toEqual({ + osVersion: 'win11', + menuStyle: 'win11-new', + buildNumber: 22631, + }); + }); + + it('detects Win11 with classic menu forced', async () => { + mockPs.execute.mockResolvedValue({ buildNumber: 22631, classicMenuForced: true }); + const service = new SystemInfoService(mockPs as never); + const result = await service.getMenuStyle(); + expect(result).toEqual({ + osVersion: 'win11', + menuStyle: 'classic', + buildNumber: 22631, + }); + }); + + it('detects Win10', async () => { + mockPs.execute.mockResolvedValue({ buildNumber: 19045, classicMenuForced: false }); + const service = new SystemInfoService(mockPs as never); + const result = await service.getMenuStyle(); + expect(result).toEqual({ + osVersion: 'win10', + menuStyle: 'classic', + buildNumber: 19045, + }); + }); + + it('caches result after first call', async () => { + mockPs.execute.mockResolvedValue({ buildNumber: 22631, classicMenuForced: false }); + const service = new SystemInfoService(mockPs as never); + await service.getMenuStyle(); + await service.getMenuStyle(); + expect(mockPs.execute).toHaveBeenCalledTimes(1); + }); + + it('invalidateCache clears cached result', async () => { + mockPs.execute.mockResolvedValue({ buildNumber: 22631, classicMenuForced: false }); + const service = new SystemInfoService(mockPs as never); + await service.getMenuStyle(); + service.invalidateCache(); + await service.getMenuStyle(); + expect(mockPs.execute).toHaveBeenCalledTimes(2); + }); + + describe('buildDetectScript', () => { + it('reads CurrentBuildNumber and classic menu override key', () => { + const script = SystemInfoService.buildDetectScript(); + expect(script).toContain('CurrentBuildNumber'); + expect(script).toContain('{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}'); + expect(script).toContain('InprocServer32'); + expect(script).toContain('classicMenuForced'); + }); + }); +});