From cebbfb66f59b0749d301d94f0ab5a4abc62d1d5b Mon Sep 17 00:00:00 2001 From: gdm257 Date: Tue, 16 Jun 2026 04:36:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=89=AB=E6=8F=8F=E5=B9=B6=E7=9B=91?= =?UTF-8?q?=E5=90=AC=20Windows=20=E5=BC=80=E5=A7=8B=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=B9=E7=BA=A7=E5=BF=AB=E6=8D=B7=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/appWatcher.ts | 99 +++++--- .../core/commandScanner/windowsScanner.ts | 235 +++++++++++------- src/main/utils/systemPaths.ts | 34 +++ tests/main/appWatcher.test.ts | 128 ++++++++++ tests/main/systemPaths.test.ts | 55 ++++ tests/main/windowsScanner.test.ts | 26 ++ tests/main/windowsScannerFlat.test.ts | 195 +++++++++++++++ 7 files changed, 651 insertions(+), 121 deletions(-) create mode 100644 tests/main/appWatcher.test.ts create mode 100644 tests/main/systemPaths.test.ts create mode 100644 tests/main/windowsScannerFlat.test.ts diff --git a/src/main/appWatcher.ts b/src/main/appWatcher.ts index 1329a423..7cad148c 100644 --- a/src/main/appWatcher.ts +++ b/src/main/appWatcher.ts @@ -3,7 +3,11 @@ import { BrowserWindow } from 'electron' import fs from 'fs' import path from 'path' import appsAPI from './api/renderer/commands' -import { getMacApplicationPaths, getWindowsScanPaths } from './utils/systemPaths' +import { + getMacApplicationPaths, + getWindowsRootScanPaths, + getWindowsScanPaths +} from './utils/systemPaths' // 要跳过的文件夹名称 const SKIP_FOLDERS = [ @@ -20,7 +24,10 @@ const SKIP_FOLDERS = [ ] class AppWatcher { - private watcher: FSWatcher | null = null + // 递归 watcher:覆盖 Programs 子树 + 桌面(Windows depth:5)/ macOS .app 目录 + private recursiveWatcher: FSWatcher | null = null + // 扁平根 watcher:覆盖 Start Menu 根级直接文件(Windows depth:0),与 scanDirectoryFlat 扫描范围对齐 + private flatRootWatcher: FSWatcher | null = null private mainWindow: BrowserWindow | null = null private debounceTimer: NodeJS.Timeout | null = null private readonly DEBOUNCE_DELAY = 1000 // 1秒防抖 @@ -31,8 +38,8 @@ class AppWatcher { this.startWatching() } - // 获取监听路径 - private getWatchPaths(): string[] { + // 获取递归监听路径(Programs 子树 + 桌面 / macOS 应用目录) + private getRecursiveWatchPaths(): string[] { if (process.platform === 'win32') { return getWindowsScanPaths() } @@ -44,7 +51,20 @@ class AppWatcher { return [] } - // 判断是否应该忽略 + // 获取扁平根监听路径(仅 Windows:Start Menu 根,不下钻 Programs) + private getFlatRootWatchPaths(): string[] { + if (process.platform === 'win32') { + return getWindowsRootScanPaths() + } + + return [] + } + + // 判断是否应该忽略。 + // 该规则被递归 watcher 与扁平根 watcher 复用(各自传入对应的 watchPaths): + // - 递归 watcher:放行根/子目录(供下钻)与 .lnk + // - 扁平根 watcher:放行 Start Menu 根与根级 .lnk;depth:0 已在 chokidar 层阻止下钻, + // 且 Windows 仅监听 .lnk 的 add/unlink(不监听 addDir),故放行目录条目不产生多余刷新 private shouldIgnore(filePath: string, watchPaths: string[]): boolean { const basename = path.basename(filePath) @@ -97,29 +117,44 @@ class AppWatcher { // 启动监听 private startWatching(): void { - // 根据平台设置监听目录 - const watchPaths = this.getWatchPaths() + const recursivePaths = this.getRecursiveWatchPaths() + const flatRootPaths = this.getFlatRootWatchPaths() + const isWindows = process.platform === 'win32' - console.log('[AppWatcher] 开始监听应用目录变化:', watchPaths) + console.log('[AppWatcher] 开始监听应用目录变化(递归):', recursivePaths) + console.log('[AppWatcher] 开始监听应用目录变化(扁平根):', flatRootPaths) - const isWindows = process.platform === 'win32' + // 递归 watcher(行为不变):Windows depth:5 覆盖 Programs 子树与桌面 + this.recursiveWatcher = this.createWatcher(recursivePaths, isWindows ? 5 : 1, isWindows) - // 创建监听器 - this.watcher = chokidar.watch(watchPaths, { - // Windows 需要递归监听子目录,macOS 只需要一级 - depth: isWindows ? 5 : 1, - // 根据平台设置忽略规则 + // 扁平根 watcher:Windows 对 Start Menu 根以 depth:0 监听,仅根级直接文件,不下钻 Programs + // 监听范围 MUST 等于扫描范围(scanDirectoryFlat 同样扁平),故不靠与 Programs 的去重来避免重复 + if (flatRootPaths.length > 0) { + this.flatRootWatcher = this.createWatcher(flatRootPaths, 0, isWindows) + } + + this.bindWatcherEvents(this.recursiveWatcher) + if (this.flatRootWatcher) { + this.bindWatcherEvents(this.flatRootWatcher) + } + } + + // 创建 chokidar watcher(两个 watcher 共用相同的选项骨架,仅 depth / paths 不同) + private createWatcher(paths: string[], depth: number, usePolling: boolean): FSWatcher { + return chokidar.watch(paths, { + depth, + // 根据平台设置忽略规则(传入该 watcher 自己的 watchPaths) ignored: (filePath: string) => { - return this.shouldIgnore(filePath, watchPaths) + return this.shouldIgnore(filePath, paths) }, // 持久化监听 persistent: true, // 忽略初始添加事件(避免启动时触发大量事件) ignoreInitial: true, // Windows 使用轮询避免 fs.watch 占用文件夹句柄导致无法重命名/删除 - usePolling: isWindows, - interval: isWindows ? 5000 : undefined, - binaryInterval: isWindows ? 5000 : undefined, + usePolling, + interval: usePolling ? 5000 : undefined, + binaryInterval: usePolling ? 5000 : undefined, // 监听文件夹事件 followSymlinks: false, // 避免在 macOS 上出现问题 @@ -128,11 +163,14 @@ class AppWatcher { pollInterval: 100 } }) + } + // 绑定 add / unlink / error / ready 事件(两个 watcher 共用同一套处理 + 防抖 notifyChange) + private bindWatcherEvents(watcher: FSWatcher): void { // 监听添加事件 if (process.platform === 'win32') { // Windows: 监听 .lnk 文件 - this.watcher.on('add', (filePath: string) => { + watcher.on('add', (filePath: string) => { if (filePath.endsWith('.lnk')) { console.log('[AppWatcher] 检测到新快捷方式:', filePath) this.notifyChange('add', filePath) @@ -142,7 +180,7 @@ class AppWatcher { if (process.platform === 'darwin') { // macOS: 监听 .app 目录 - this.watcher.on('addDir', (filePath: string) => { + watcher.on('addDir', (filePath: string) => { if (filePath.endsWith('.app')) { console.log('[AppWatcher] 检测到新应用:', filePath) this.notifyChange('add', filePath) @@ -153,7 +191,7 @@ class AppWatcher { // 监听删除事件 if (process.platform === 'win32') { // Windows: 监听 .lnk 文件删除 - this.watcher.on('unlink', (filePath: string) => { + watcher.on('unlink', (filePath: string) => { if (filePath.endsWith('.lnk')) { console.log('[AppWatcher] 检测到快捷方式删除:', filePath) this.notifyChange('remove', filePath) @@ -163,7 +201,7 @@ class AppWatcher { if (process.platform === 'darwin') { // macOS: 监听 .app 目录删除 - this.watcher.on('unlinkDir', (filePath: string) => { + watcher.on('unlinkDir', (filePath: string) => { if (filePath.endsWith('.app')) { console.log('[AppWatcher] 检测到应用删除:', filePath) this.notifyChange('remove', filePath) @@ -172,12 +210,12 @@ class AppWatcher { } // 监听错误 - this.watcher.on('error', (error: unknown) => { + watcher.on('error', (error: unknown) => { console.error('[AppWatcher] 应用目录监听错误:', error) }) // 监听准备完成 - this.watcher.on('ready', () => { + watcher.on('ready', () => { console.log('[AppWatcher] 应用目录监听器已就绪') }) } @@ -202,11 +240,16 @@ class AppWatcher { // 停止监听 public stop(): void { - if (this.watcher) { - console.log('[AppWatcher] 停止监听应用目录') - this.watcher.close() - this.watcher = null + // 同时关闭递归 watcher 与扁平根 watcher + const watchers = [this.recursiveWatcher, this.flatRootWatcher] + for (const watcher of watchers) { + if (watcher) { + console.log('[AppWatcher] 停止监听应用目录') + watcher.close() + } } + this.recursiveWatcher = null + this.flatRootWatcher = null if (this.debounceTimer) { clearTimeout(this.debounceTimer) diff --git a/src/main/core/commandScanner/windowsScanner.ts b/src/main/core/commandScanner/windowsScanner.ts index 599d2f53..42ac334e 100644 --- a/src/main/core/commandScanner/windowsScanner.ts +++ b/src/main/core/commandScanner/windowsScanner.ts @@ -1,8 +1,9 @@ import { shell } from 'electron' +import type { Dirent } from 'fs' import fsPromises from 'fs/promises' import path from 'path' import { extractAcronym } from '../../utils/common' -import { getWindowsScanPaths } from '../../utils/systemPaths' +import { getWindowsRootScanPaths, getWindowsScanPaths } from '../../utils/systemPaths' import { MuiResolver } from '../native/index' import { Command } from './types' @@ -202,118 +203,158 @@ export async function parseUrlFile(filePath: string): Promise ): Promise { - try { - const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name) - - // 处理子目录 - if (entry.isDirectory()) { - // 跳过 SDK、示例、文档等开发相关文件夹 - if (SKIP_FOLDERS.includes(entry.name.toLowerCase())) { - continue - } - // 递归扫描子目录 - await scanDirectory(fullPath, apps, displayNameMap) - continue - } - - if (!entry.isFile()) continue - - const ext = path.extname(entry.name).toLowerCase() + const fullPath = path.join(dirPath, entry.name) + const ext = path.extname(entry.name).toLowerCase() + + // 处理 .url 快捷方式(应用协议链接,如 steam://) + if (ext === '.url') { + const urlInfo = await parseUrlFile(fullPath) + if (!urlInfo) return + + // 优先使用本地化显示名称,降级为磁盘文件名 + const appName = displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.url') + + // 过滤检查 + if (SKIP_NAME_PATTERN.test(appName)) return + + // 图标:优先使用 .url 文件中的 IconFile,否则使用 .url 文件本身 + const iconPath = urlInfo.iconFile || fullPath + const icon = getIconUrl(iconPath) + + apps.push({ + name: appName, + path: urlInfo.url, // 使用协议链接作为启动路径 + icon, + acronym: extractAcronym(appName) + }) + return + } - // 处理 .url 快捷方式(应用协议链接,如 steam://) - if (ext === '.url') { - const urlInfo = await parseUrlFile(fullPath) - if (!urlInfo) continue + // 处理 .lnk 快捷方式 + if (ext !== '.lnk') return - // 优先使用本地化显示名称,降级为磁盘文件名 - const appName = - displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.url') + // 优先使用本地化显示名称,降级为磁盘文件名 + // 解决 Windows 系统快捷方式文件名为英文(如 File Explorer.lnk)但显示名为中文的问题 + const appName = displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.lnk') - // 过滤检查 - if (SKIP_NAME_PATTERN.test(appName)) continue + // 尝试解析快捷方式目标(必须先解析才能获取真实路径) + let shortcutDetails: Electron.ShortcutDetails | null = null + try { + shortcutDetails = shell.readShortcutLink(fullPath) + } catch { + // 解析失败,使用快捷方式本身 + } - // 图标:优先使用 .url 文件中的 IconFile,否则使用 .url 文件本身 - const iconPath = urlInfo.iconFile || fullPath - const icon = getIconUrl(iconPath) + // 获取目标路径和应用路径 + const targetPath = shortcutDetails?.target?.trim() || '' - apps.push({ - name: appName, - path: urlInfo.url, // 使用协议链接作为启动路径 - icon, - acronym: extractAcronym(appName) - }) - continue - } + // 如果 .lnk 指向 .url 文件,解析 .url 内容判断是否为应用协议 + if (targetPath.toLowerCase().endsWith('.url')) { + const urlInfo = await parseUrlFile(targetPath) + if (!urlInfo) return // http/https 或解析失败,跳过 - // 处理 .lnk 快捷方式 - if (ext !== '.lnk') continue + if (SKIP_NAME_PATTERN.test(appName)) return - // 优先使用本地化显示名称,降级为磁盘文件名 - // 解决 Windows 系统快捷方式文件名为英文(如 File Explorer.lnk)但显示名为中文的问题 - const appName = - displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.lnk') + const iconPath = urlInfo.iconFile || fullPath + const icon = getIconUrl(iconPath) - // 尝试解析快捷方式目标(必须先解析才能获取真实路径) - let shortcutDetails: Electron.ShortcutDetails | null = null - try { - shortcutDetails = shell.readShortcutLink(fullPath) - } catch { - // 解析失败,使用快捷方式本身 - } + apps.push({ + name: appName, + path: urlInfo.url, + icon, + acronym: extractAcronym(appName) + }) + return + } - // 获取目标路径和应用路径 - const targetPath = shortcutDetails?.target?.trim() || '' + // 过滤检查:仅按名称过滤(不按目标类型/路径过滤) + if (shouldSkipShortcut(appName)) { + return + } - // 如果 .lnk 指向 .url 文件,解析 .url 内容判断是否为应用协议 - if (targetPath.toLowerCase().endsWith('.url')) { - const urlInfo = await parseUrlFile(targetPath) - if (!urlInfo) continue // http/https 或解析失败,跳过 + // 始终使用 .lnk 快捷方式路径作为启动路径 + // Windows Shell API (shell.openPath) 能正确处理 .lnk 文件的启动(包括参数、工作目录等) + // 图标使用 .lnk 路径即可,SHGetFileInfoW 能正确解析快捷方式的图标(包括自定义图标) + const icon = getIconUrl(fullPath) + + // 创建应用对象 + // _dedupeTarget 用于去重:同名且指向同一目标的快捷方式只保留一个 + // (用户开始菜单和系统开始菜单可能有同名同目标的 .lnk,路径不同但应合并) + const app: Command & { _dedupeTarget?: string } = { + name: appName, + path: fullPath, + icon, + acronym: extractAcronym(appName), + _dedupeTarget: targetPath || undefined + } - if (SKIP_NAME_PATTERN.test(appName)) continue + apps.push(app) +} - const iconPath = urlInfo.iconFile || fullPath - const icon = getIconUrl(iconPath) +// 递归扫描目录中的快捷方式(Programs 子树 / 桌面) +async function scanDirectory( + dirPath: string, + apps: Command[], + displayNameMap: Map +): Promise { + try { + const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }) - apps.push({ - name: appName, - path: urlInfo.url, - icon, - acronym: extractAcronym(appName) - }) + for (const entry of entries) { + // 处理子目录:跳过开发相关文件夹,其余递归下钻 + if (entry.isDirectory()) { + // 跳过 SDK、示例、文档等开发相关文件夹 + if (SKIP_FOLDERS.includes(entry.name.toLowerCase())) { + continue + } + // 递归扫描子目录 + await scanDirectory(path.join(dirPath, entry.name), apps, displayNameMap) continue } - // 过滤检查:仅按名称过滤(不按目标类型/路径过滤) - if (shouldSkipShortcut(appName)) { - continue - } + // 处理文件(共享逐文件逻辑) + await processShortcutEntry(dirPath, entry, apps, displayNameMap) + } + } catch (error) { + console.error(`[Scanner] 扫描目录失败 ${dirPath}:`, error) + } +} - // 始终使用 .lnk 快捷方式路径作为启动路径 - // Windows Shell API (shell.openPath) 能正确处理 .lnk 文件的启动(包括参数、工作目录等) - // 图标使用 .lnk 路径即可,SHGetFileInfoW 能正确解析快捷方式的图标(包括自定义图标) - const icon = getIconUrl(fullPath) - - // 创建应用对象 - // _dedupeTarget 用于去重:同名且指向同一目标的快捷方式只保留一个 - // (用户开始菜单和系统开始菜单可能有同名同目标的 .lnk,路径不同但应合并) - const app: Command & { _dedupeTarget?: string } = { - name: appName, - path: fullPath, - icon, - acronym: extractAcronym(appName), - _dedupeTarget: targetPath || undefined - } +/** + * 扁平扫描目录中的快捷方式(Start Menu 根专用)。 + * + * 仅处理本层 entries(根级直接文件),**不下钻子目录**。 + * 这是本变更的核心不变式:Start Menu 根的 `Programs` 子树已由 `scanDirectory` + * 递归覆盖,此处若下钻会造成重复扫描与重复索引。 + * + * 导出便于单元测试(与 `shouldSkipShortcut` 等风格一致)。 + */ +export async function scanDirectoryFlat( + dirPath: string, + apps: Command[], + displayNameMap: Map +): Promise { + try { + const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + // 扁平扫描:跳过子目录,仅处理本层文件 + if (entry.isDirectory()) continue - apps.push(app) + await processShortcutEntry(dirPath, entry, apps, displayNameMap) } } catch (error) { console.error(`[Scanner] 扫描目录失败 ${dirPath}:`, error) @@ -346,16 +387,24 @@ export async function scanApplications(): Promise { const apps: Command[] = [] - // 获取 Windows 扫描路径(开始菜单 + 桌面) + // 获取 Windows 扫描路径(开始菜单 Programs 子树 + 桌面,递归) const scanPaths = getWindowsScanPaths() + // 获取 Windows Start Menu 根扫描路径(用户级 / 系统级,扁平、不下钻 Programs) + const rootScanPaths = getWindowsRootScanPaths() // 获取本地化显示名称(解决 Windows 系统快捷方式文件名为英文的问题) + // 注:Start Menu 根 desktop.ini 无 [LocalizedFileNames] 条目(见 design Decision 2), + // 根级 .lnk 经 displayNameMap 降级为磁盘文件名,故本地化解析仍以 Programs 子树为准。 const displayNameMap = await getLocalizedDisplayNames(scanPaths) - // 扫描所有目录 + // 扫描所有目录:Programs 子树 + 桌面走递归扫描(行为不变) for (const menuPath of scanPaths) { await scanDirectory(menuPath, apps, displayNameMap) } + // Start Menu 根走扁平扫描(仅根级直接文件,不下钻 Programs,避免与递归扫描重复) + for (const rootPath of rootScanPaths) { + await scanDirectoryFlat(rootPath, apps, displayNameMap) + } const deduplicatedApps = deduplicateCommands(apps) diff --git a/src/main/utils/systemPaths.ts b/src/main/utils/systemPaths.ts index 6b45973c..92adb525 100644 --- a/src/main/utils/systemPaths.ts +++ b/src/main/utils/systemPaths.ts @@ -36,6 +36,40 @@ export function getWindowsScanPaths(): string[] { return [programDataStartMenu, userStartMenu, userDesktop, publicDesktop] } +/** + * 获取 Windows 开始菜单 **根** 扫描路径(不含 `Programs` 子文件夹)。 + * + * Windows 允许将 `.lnk` / `.url` 直接放在 `Start Menu\` 根目录下 + * (用户级与系统级均如此),这些快捷方式在开始菜单中可见。 + * 但 `getWindowsScanPaths()` 把开始菜单路径硬编码到 `...\Start Menu\Programs`, + * 递归扫描从该子文件夹起,故根级快捷方式会被遗漏(issue #551)。 + * + * 本函数返回两条 Start Menu 根路径,供扁平(非递归)扫描与监听使用: + * 仅扫描根级直接文件,不下钻子目录(`Programs` 子树由 `getWindowsScanPaths` 递归覆盖)。 + */ +export function getWindowsRootScanPaths(): string[] { + // 系统级开始菜单根 + const programDataStartMenuRoot = path.join( + 'C:', + 'ProgramData', + 'Microsoft', + 'Windows', + 'Start Menu' + ) + + // 用户级开始菜单根 + const userStartMenuRoot = path.join( + os.homedir(), + 'AppData', + 'Roaming', + 'Microsoft', + 'Windows', + 'Start Menu' + ) + + return [programDataStartMenuRoot, userStartMenuRoot] +} + /** * 获取 macOS 应用目录路径 */ diff --git a/tests/main/appWatcher.test.ts b/tests/main/appWatcher.test.ts new file mode 100644 index 00000000..7e2dfc0f --- /dev/null +++ b/tests/main/appWatcher.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// chokidar 的 mock watcher:记录 on 注册的回调,并提供 __emit 触发事件 +const { createMockWatcher } = vi.hoisted(() => { + const createMockWatcher = () => { + const handlers: Record void>> = {} + const api = { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + ;(handlers[event] ||= []).push(cb) + return api + }), + close: vi.fn(), + __emit: (event: string, ...args: unknown[]) => { + for (const cb of handlers[event] || []) cb(...args) + } + } + return api + } + return { createMockWatcher } +}) + +vi.mock('chokidar', () => ({ + default: { watch: vi.fn(() => createMockWatcher()) } +})) + +// appWatcher 用 BrowserWindow(类型);systemPaths 用 app.getPath —— 一并 mock +vi.mock('electron', () => ({ + BrowserWindow: vi.fn(), + app: { getPath: vi.fn((name: string) => `/mock/${name}`) } +})) + +// notifyChange -> appsAPI.refreshAppsCache;mock 避免加载重量级 commands.ts(LMDB 等依赖) +vi.mock('../../src/main/api/renderer/commands', () => ({ + default: { refreshAppsCache: vi.fn() } +})) + +import chokidar from 'chokidar' +import path from 'path' +import appsAPI from '../../src/main/api/renderer/commands' +import { getWindowsScanPaths, getWindowsRootScanPaths } from '../../src/main/utils/systemPaths' +import appWatcher from '../../src/main/appWatcher' + +let originalPlatform: string +beforeEach(() => { + vi.clearAllMocks() + originalPlatform = process.platform + // stub 为 win32:使 getRecursiveWatchPaths / getFlatRootWatchPaths 返回 Windows 路径, + // 从而同时创建递归 watcher 与扁平根 watcher + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + vi.useFakeTimers() +}) +afterEach(() => { + appWatcher.stop() + vi.useRealTimers() + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) +}) + +describe('AppWatcher 双 watcher 接线', () => { + it('启动递归(depth:5)与扁平(depth:0)两个 watcher', () => { + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + expect(watchMock).toHaveBeenCalledTimes(2) + + const [recursivePaths, recursiveOpts] = watchMock.mock.calls[0] + const [flatPaths, flatOpts] = watchMock.mock.calls[1] + + expect(recursiveOpts.depth).toBe(5) + expect(flatOpts.depth).toBe(0) + expect(recursivePaths).toEqual(getWindowsScanPaths()) + expect(flatPaths).toEqual(getWindowsRootScanPaths()) + }) + + it('扁平根 watcher 不为空时才创建(覆盖 getWindowsRootScanPaths)', () => { + appWatcher.init({} as never) + const watchMock = vi.mocked(chokidar.watch) + // 第二次 watch 调用的 paths 即扁平根路径 + expect(watchMock.mock.calls[1][0]).toEqual(getWindowsRootScanPaths()) + }) + + it('.lnk add/unlink 事件路由到防抖 notifyChange → refreshAppsCache', () => { + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + const flatWatcher = watchMock.mock.results[1].value as { + __emit: (event: string, ...args: unknown[]) => void + } + const rootPath = getWindowsRootScanPaths()[0] + const lnkPath = path.join(rootPath, 'NewApp.lnk') + + // add 事件:防抖未到时不刷新 + flatWatcher.__emit('add', lnkPath) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + + // 推进防抖窗口(DEBOUNCE_DELAY = 1000ms)后刷新 + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(1) + + // unlink 事件同样触发刷新 + flatWatcher.__emit('unlink', lnkPath) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(2) + }) + + it('非 .lnk 文件事件不触发刷新', () => { + appWatcher.init({} as never) + const watchMock = vi.mocked(chokidar.watch) + const flatWatcher = watchMock.mock.results[1].value as { + __emit: (event: string, ...args: unknown[]) => void + } + + flatWatcher.__emit('add', path.join(getWindowsRootScanPaths()[0], 'notes.txt')) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + }) + + it('stop 关闭两个 watcher', () => { + appWatcher.init({} as never) + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as { close: ReturnType } + const flatWatcher = watchMock.mock.results[1].value as { close: ReturnType } + + appWatcher.stop() + + expect(recursiveWatcher.close).toHaveBeenCalledTimes(1) + expect(flatWatcher.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/main/systemPaths.test.ts b/tests/main/systemPaths.test.ts new file mode 100644 index 00000000..795a72d1 --- /dev/null +++ b/tests/main/systemPaths.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' + +// systemPaths.ts 顶部 import { app } from 'electron'(getWindowsScanPaths 用 app.getPath), +// mock 提供 app.getPath,避免依赖真实 Electron 运行时,确保跨平台运行。 +vi.mock('electron', () => ({ + app: { getPath: vi.fn((name: string) => `/mock/${name}`) } +})) + +import os from 'os' +import path from 'path' +import { getWindowsRootScanPaths, getWindowsScanPaths } from '../../src/main/utils/systemPaths' + +// ========== getWindowsRootScanPaths ========== + +describe('getWindowsRootScanPaths(Start Menu 根路径)', () => { + it('应返回用户级与系统级 Start Menu 根路径', () => { + const paths = getWindowsRootScanPaths() + expect(paths).toHaveLength(2) + + // 系统级根 + const programDataRoot = path.join('C:', 'ProgramData', 'Microsoft', 'Windows', 'Start Menu') + expect(paths).toContain(programDataRoot) + + // 用户级根 + const userRoot = path.join( + os.homedir(), + 'AppData', + 'Roaming', + 'Microsoft', + 'Windows', + 'Start Menu' + ) + expect(paths).toContain(userRoot) + }) + + it('路径均不以 Programs 结尾(指向 Start Menu 根,区别于 getWindowsScanPaths)', () => { + for (const p of getWindowsRootScanPaths()) { + expect(p.endsWith('Programs')).toBe(false) + expect(p.endsWith('Start Menu')).toBe(true) + } + }) + + it('与 getWindowsScanPaths 的开始菜单路径互补(root + Programs)', () => { + const roots = getWindowsRootScanPaths() + const scans = getWindowsScanPaths() + // 每个 Start Menu 根在 getWindowsScanPaths 中都应有一个 root\Programs 子文件夹 + for (const root of roots) { + expect(scans).toContain(path.join(root, 'Programs')) + } + }) + + it('不改动 getWindowsScanPaths 行为(仍返回 4 条路径)', () => { + expect(getWindowsScanPaths()).toHaveLength(4) + }) +}) diff --git a/tests/main/windowsScanner.test.ts b/tests/main/windowsScanner.test.ts index db767ac9..c81ebb00 100644 --- a/tests/main/windowsScanner.test.ts +++ b/tests/main/windowsScanner.test.ts @@ -1,4 +1,11 @@ import { describe, it, expect, vi } from 'vitest' + +// mock 原生模块:避免 vite import-analysis 解析 native/index.ts 中的 .node?asset 导入 +// (windowsScanner 仅类型上依赖 MuiResolver,本文件的纯函数 / parseUrlFile 测试不触发它) +vi.mock('../../src/main/core/native/index', () => ({ + MuiResolver: { resolve: vi.fn(() => new Map()) } +})) + import { shouldSkipShortcut, getIconUrl, @@ -95,6 +102,25 @@ describe('deduplicateCommands', () => { expect(result[0].path).toBe('C:\\Users\\test\\Start Menu\\Programs\\App.lnk') }) + it('应合并 Start Menu 根级与 Programs 子树同名同目标的快捷方式', () => { + // issue #551 场景:根级 .lnk 与 Programs 子树 .lnk 同名同目标,应合并为单一指令 + const apps = [ + { + name: 'App', + path: 'C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\App.lnk', + _dedupeTarget: 'C:\\Program Files\\App\\app.exe' + }, + { + name: 'App', + path: 'C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\App.lnk', + _dedupeTarget: 'C:\\Program Files\\App\\app.exe' + } + ] + const result = deduplicateCommands(apps) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('App') + }) + it('应保留不同名但同目标的应用(核心特性)', () => { const apps = [ { diff --git a/tests/main/windowsScannerFlat.test.ts b/tests/main/windowsScannerFlat.test.ts new file mode 100644 index 00000000..f1d0b5eb --- /dev/null +++ b/tests/main/windowsScannerFlat.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// windowsScanner.ts import { shell } from 'electron';systemPaths.ts import { app } from 'electron'。 +// 一并 mock,避免依赖真实 Electron 运行时,确保跨平台运行。 +vi.mock('electron', () => ({ + shell: { readShortcutLink: vi.fn() }, + app: { getPath: vi.fn((name: string) => `/mock/${name}`) } +})) + +vi.mock('fs/promises', () => ({ + default: { readdir: vi.fn(), readFile: vi.fn() } +})) + +// mock 原生模块,避免加载仅 win32 可用的 .node(MuiResolver 在本测试中不被触发) +vi.mock('../../src/main/core/native/index', () => ({ + MuiResolver: { resolve: vi.fn(() => new Map()) } +})) + +import fsPromises from 'fs/promises' +import type { Dirent } from 'fs' +import os from 'os' +import path from 'path' +import { shell } from 'electron' +import { + scanDirectoryFlat, + scanApplications +} from '../../src/main/core/commandScanner/windowsScanner' +import { getWindowsScanPaths, getWindowsRootScanPaths } from '../../src/main/utils/systemPaths' + +// 构造一个 Dirent 桩(readdir withFileTypes 返回 Dirent[]) +function dirent(name: string, isDir = false): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false + } as unknown as Dirent +} + +// stub platform -> 'linux',使 getLocalizedDisplayNames 直接返回空 Map(第一行守卫), +// 从而根级 .lnk 一律降级为磁盘文件名(断言以磁盘文件名 / 路径为准),且不触发原生 MuiResolver。 +let originalPlatform: string +beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + vi.clearAllMocks() +}) +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) +}) + +// ========== 4.2 scanDirectoryFlat ========== + +describe('scanDirectoryFlat(扁平根扫描)', () => { + it('① 收集根级 .lnk 为 Command(name / path / icon 正确)', async () => { + const dir = 'C:/StartMenuRoot' + vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk')]) + vi.mocked(shell.readShortcutLink).mockReturnValue({ + target: 'C:/Program Files/App1/app1.exe' + } as never) + + const apps: unknown[] = [] + await scanDirectoryFlat(dir, apps as never, new Map()) + + expect(apps).toHaveLength(1) + const app = apps[0] as { name: string; path: string; icon: string } + expect(app.name).toBe('App1') + expect(app.path).toBe(path.join(dir, 'App1.lnk')) + expect(app.icon).toContain('ztools-icon://') + }) + + it('② 不下钻子目录(子目录内 .lnk 不被收集——核心不变式)', async () => { + const dir = 'C:/StartMenuRoot' + vi.mocked(fsPromises.readdir).mockResolvedValue([ + dirent('App1.lnk'), + dirent('Programs', true), + dirent('Extra', true) + ]) + vi.mocked(shell.readShortcutLink).mockReturnValue({ target: 'C:/x.exe' } as never) + + const apps: unknown[] = [] + await scanDirectoryFlat(dir, apps as never, new Map()) + + const names = (apps as Array<{ name: string }>).map((a) => a.name) + expect(names).toEqual(['App1']) + // readdir 仅被调用一次(根目录),未对 Programs / Extra 子目录下钻 + expect(fsPromises.readdir).toHaveBeenCalledTimes(1) + }) + + it('③ 应用 shouldSkipShortcut 过滤(根级 Uninstall.lnk 被跳过)', async () => { + const dir = 'C:/StartMenuRoot' + vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk'), dirent('Uninstall.lnk')]) + vi.mocked(shell.readShortcutLink).mockReturnValue({ target: 'C:/x.exe' } as never) + + const apps: unknown[] = [] + await scanDirectoryFlat(dir, apps as never, new Map()) + + const names = (apps as Array<{ name: string }>).map((a) => a.name) + expect(names).toContain('App1') + expect(names).not.toContain('Uninstall') + }) + + it('④ 正确填充 _dedupeTarget(目标路径)', async () => { + const dir = 'C:/StartMenuRoot' + vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk')]) + vi.mocked(shell.readShortcutLink).mockReturnValue({ + target: 'C:/Program Files/App1/app1.exe' + } as never) + + const apps: Array<{ _dedupeTarget?: string }> = [] + await scanDirectoryFlat(dir, apps as never, new Map()) + + expect(apps[0]._dedupeTarget).toBe('C:/Program Files/App1/app1.exe') + }) + + it('.url 根级快捷方式也被收集(应用协议)', async () => { + const dir = 'C:/StartMenuRoot' + vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('Steam.url')]) + vi.mocked(fsPromises.readFile).mockResolvedValue( + '[InternetShortcut]\nURL=steam://rungameid/1\nIconFile=C:/s.ico' + ) + + const apps: unknown[] = [] + await scanDirectoryFlat(dir, apps as never, new Map()) + + expect(apps).toHaveLength(1) + const app = apps[0] as { name: string; path: string } + expect(app.name).toBe('Steam') + expect(app.path).toBe('steam://rungameid/1') + }) +}) + +// ========== 4.4 scanApplications 集成 ========== + +describe('scanApplications(Start Menu 根级 + Programs 子树集成)', () => { + it('根级与 Programs 子树的 .lnk 均被索引,根级子目录内的不被索引', async () => { + // 基于真实路径函数构造 mock 文件系统(path.join 在当前平台产生一致分隔符) + const [programDataRoot, userRoot] = getWindowsRootScanPaths() + const programDataPrograms = path.join(programDataRoot, 'Programs') + const userPrograms = path.join(userRoot, 'Programs') + + const fsTree: Record = { + // 系统级 Start Menu 根:根级 .lnk + Programs 子目录 + 一个普通根级子目录 + [programDataRoot]: [ + dirent('RootApp.lnk'), + dirent('Uninstall.lnk'), // 应被 shouldSkipShortcut 过滤 + dirent('Programs', true), // Programs 子树(flat 不下钻,由递归 scanDirectory 覆盖) + dirent('ExtraDir', true) // 根级普通子目录(flat 不下钻) + ], + // Programs 子树:深层 .lnk(递归扫描) + [programDataPrograms]: [dirent('DeepApp.lnk')], + // 根级普通子目录内的 .lnk:扁平扫描 MUST NOT 索引 + [path.join(programDataRoot, 'ExtraDir')]: [dirent('HiddenApp.lnk')], + // 用户级根 + 用户级 Programs + [userRoot]: [dirent('UserRootApp.lnk')], + [userPrograms]: [dirent('UserProgramsApp.lnk')] + } + + vi.mocked(fsPromises.readdir).mockImplementation(async (dirPath: unknown) => { + return fsTree[dirPath as string] ?? [] + }) + // 每个 .lnk 解析出各自不同的目标(避免意外去重合并) + vi.mocked(shell.readShortcutLink).mockImplementation((filePath: unknown) => { + const base = path.basename(filePath as string) + return { target: `C:/Program Files/${base}.exe` } as never + }) + + const result = await scanApplications() + const names = result.map((a) => a.name).sort() + + // 根级 + Programs 子树的 .lnk 均被索引 + expect(names).toContain('RootApp') + expect(names).toContain('UserRootApp') + expect(names).toContain('DeepApp') + expect(names).toContain('UserProgramsApp') + + // 根级子目录内的 .lnk 不被索引(扁平扫描核心不变式) + expect(names).not.toContain('HiddenApp') + // 卸载类快捷方式被过滤 + expect(names).not.toContain('Uninstall') + + // 精确:恰好 4 个 + expect(names).toEqual(['DeepApp', 'RootApp', 'UserProgramsApp', 'UserRootApp']) + }) + + it('getWindowsScanPaths / getWindowsRootScanPaths 路径互补(sanity check)', () => { + // 确保测试 mock 文件系统与路径函数一致 + const [programDataRoot] = getWindowsRootScanPaths() + expect(getWindowsScanPaths()).toContain(path.join(programDataRoot, 'Programs')) + }) +}) From 79724f120ab8172272544038c98008195979ec92 Mon Sep 17 00:00:00 2001 From: gdm257 Date: Wed, 17 Jun 2026 00:42:10 +0900 Subject: [PATCH 2/2] refactor: improve code readability --- src/main/appWatcher.ts | 29 ++-- .../core/commandScanner/windowsScanner.ts | 42 +++--- src/main/utils/systemPaths.ts | 33 +---- tests/main/appWatcher.test.ts | 23 +-- tests/main/systemPaths.test.ts | 6 - tests/main/windowsScanner.test.ts | 3 - tests/main/windowsScannerFlat.test.ts | 136 +++++++++--------- 7 files changed, 113 insertions(+), 159 deletions(-) diff --git a/src/main/appWatcher.ts b/src/main/appWatcher.ts index 7cad148c..4e12d9fc 100644 --- a/src/main/appWatcher.ts +++ b/src/main/appWatcher.ts @@ -24,9 +24,7 @@ const SKIP_FOLDERS = [ ] class AppWatcher { - // 递归 watcher:覆盖 Programs 子树 + 桌面(Windows depth:5)/ macOS .app 目录 private recursiveWatcher: FSWatcher | null = null - // 扁平根 watcher:覆盖 Start Menu 根级直接文件(Windows depth:0),与 scanDirectoryFlat 扫描范围对齐 private flatRootWatcher: FSWatcher | null = null private mainWindow: BrowserWindow | null = null private debounceTimer: NodeJS.Timeout | null = null @@ -38,7 +36,7 @@ class AppWatcher { this.startWatching() } - // 获取递归监听路径(Programs 子树 + 桌面 / macOS 应用目录) + // 获取递归监听路径 private getRecursiveWatchPaths(): string[] { if (process.platform === 'win32') { return getWindowsScanPaths() @@ -51,7 +49,7 @@ class AppWatcher { return [] } - // 获取扁平根监听路径(仅 Windows:Start Menu 根,不下钻 Programs) + // 获取扁平根监听路径 private getFlatRootWatchPaths(): string[] { if (process.platform === 'win32') { return getWindowsRootScanPaths() @@ -60,11 +58,7 @@ class AppWatcher { return [] } - // 判断是否应该忽略。 - // 该规则被递归 watcher 与扁平根 watcher 复用(各自传入对应的 watchPaths): - // - 递归 watcher:放行根/子目录(供下钻)与 .lnk - // - 扁平根 watcher:放行 Start Menu 根与根级 .lnk;depth:0 已在 chokidar 层阻止下钻, - // 且 Windows 仅监听 .lnk 的 add/unlink(不监听 addDir),故放行目录条目不产生多余刷新 + // 判断是否应该忽略 private shouldIgnore(filePath: string, watchPaths: string[]): boolean { const basename = path.basename(filePath) @@ -124,11 +118,11 @@ class AppWatcher { console.log('[AppWatcher] 开始监听应用目录变化(递归):', recursivePaths) console.log('[AppWatcher] 开始监听应用目录变化(扁平根):', flatRootPaths) - // 递归 watcher(行为不变):Windows depth:5 覆盖 Programs 子树与桌面 + // 递归 watcher + // Windows 需要递归监听子目录,macOS 只需要一级 this.recursiveWatcher = this.createWatcher(recursivePaths, isWindows ? 5 : 1, isWindows) - // 扁平根 watcher:Windows 对 Start Menu 根以 depth:0 监听,仅根级直接文件,不下钻 Programs - // 监听范围 MUST 等于扫描范围(scanDirectoryFlat 同样扁平),故不靠与 Programs 的去重来避免重复 + // 扁平根 watcher if (flatRootPaths.length > 0) { this.flatRootWatcher = this.createWatcher(flatRootPaths, 0, isWindows) } @@ -139,13 +133,12 @@ class AppWatcher { } } - // 创建 chokidar watcher(两个 watcher 共用相同的选项骨架,仅 depth / paths 不同) - private createWatcher(paths: string[], depth: number, usePolling: boolean): FSWatcher { - return chokidar.watch(paths, { + private createWatcher(watchPaths: string[], depth: number, usePolling: boolean): FSWatcher { + return chokidar.watch(watchPaths, { depth, - // 根据平台设置忽略规则(传入该 watcher 自己的 watchPaths) + // 忽略规则 ignored: (filePath: string) => { - return this.shouldIgnore(filePath, paths) + return this.shouldIgnore(filePath, watchPaths) }, // 持久化监听 persistent: true, @@ -165,7 +158,6 @@ class AppWatcher { }) } - // 绑定 add / unlink / error / ready 事件(两个 watcher 共用同一套处理 + 防抖 notifyChange) private bindWatcherEvents(watcher: FSWatcher): void { // 监听添加事件 if (process.platform === 'win32') { @@ -240,7 +232,6 @@ class AppWatcher { // 停止监听 public stop(): void { - // 同时关闭递归 watcher 与扁平根 watcher const watchers = [this.recursiveWatcher, this.flatRootWatcher] for (const watcher of watchers) { if (watcher) { diff --git a/src/main/core/commandScanner/windowsScanner.ts b/src/main/core/commandScanner/windowsScanner.ts index 42ac334e..88aafdb7 100644 --- a/src/main/core/commandScanner/windowsScanner.ts +++ b/src/main/core/commandScanner/windowsScanner.ts @@ -204,11 +204,8 @@ export async function parseUrlFile(filePath: string): Promise { const apps: Command[] = [] - // 获取 Windows 扫描路径(开始菜单 Programs 子树 + 桌面,递归) + // 获取 Windows 扫描路径(开始菜单 + 桌面) const scanPaths = getWindowsScanPaths() - // 获取 Windows Start Menu 根扫描路径(用户级 / 系统级,扁平、不下钻 Programs) + // 获取 Start Menu 根路径 const rootScanPaths = getWindowsRootScanPaths() // 获取本地化显示名称(解决 Windows 系统快捷方式文件名为英文的问题) - // 注:Start Menu 根 desktop.ini 无 [LocalizedFileNames] 条目(见 design Decision 2), - // 根级 .lnk 经 displayNameMap 降级为磁盘文件名,故本地化解析仍以 Programs 子树为准。 const displayNameMap = await getLocalizedDisplayNames(scanPaths) - // 扫描所有目录:Programs 子树 + 桌面走递归扫描(行为不变) + // 递归扫描 Programs + 桌面 for (const menuPath of scanPaths) { await scanDirectory(menuPath, apps, displayNameMap) } - // Start Menu 根走扁平扫描(仅根级直接文件,不下钻 Programs,避免与递归扫描重复) + // 扁平扫描 Start Menu 根 for (const rootPath of rootScanPaths) { await scanDirectoryFlat(rootPath, apps, displayNameMap) } diff --git a/src/main/utils/systemPaths.ts b/src/main/utils/systemPaths.ts index 92adb525..07b88e23 100644 --- a/src/main/utils/systemPaths.ts +++ b/src/main/utils/systemPaths.ts @@ -37,37 +37,12 @@ export function getWindowsScanPaths(): string[] { } /** - * 获取 Windows 开始菜单 **根** 扫描路径(不含 `Programs` 子文件夹)。 - * - * Windows 允许将 `.lnk` / `.url` 直接放在 `Start Menu\` 根目录下 - * (用户级与系统级均如此),这些快捷方式在开始菜单中可见。 - * 但 `getWindowsScanPaths()` 把开始菜单路径硬编码到 `...\Start Menu\Programs`, - * 递归扫描从该子文件夹起,故根级快捷方式会被遗漏(issue #551)。 - * - * 本函数返回两条 Start Menu 根路径,供扁平(非递归)扫描与监听使用: - * 仅扫描根级直接文件,不下钻子目录(`Programs` 子树由 `getWindowsScanPaths` 递归覆盖)。 + * 获取 Windows 开始菜单根路径 */ export function getWindowsRootScanPaths(): string[] { - // 系统级开始菜单根 - const programDataStartMenuRoot = path.join( - 'C:', - 'ProgramData', - 'Microsoft', - 'Windows', - 'Start Menu' - ) - - // 用户级开始菜单根 - const userStartMenuRoot = path.join( - os.homedir(), - 'AppData', - 'Roaming', - 'Microsoft', - 'Windows', - 'Start Menu' - ) - - return [programDataStartMenuRoot, userStartMenuRoot] + return getWindowsScanPaths() + .filter((p) => p.endsWith(`${path.sep}Programs`)) + .map(path.dirname) } /** diff --git a/tests/main/appWatcher.test.ts b/tests/main/appWatcher.test.ts index 7e2dfc0f..de57bfc0 100644 --- a/tests/main/appWatcher.test.ts +++ b/tests/main/appWatcher.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' // chokidar 的 mock watcher:记录 on 注册的回调,并提供 __emit 触发事件 +type MockWatcherApi = { + on: Mock + close: Mock + __emit: (event: string, ...args: unknown[]) => void +} const { createMockWatcher } = vi.hoisted(() => { - const createMockWatcher = () => { + const createMockWatcher = (): MockWatcherApi => { const handlers: Record void>> = {} - const api = { + const api: MockWatcherApi = { on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { ;(handlers[event] ||= []).push(cb) return api @@ -65,17 +70,19 @@ describe('AppWatcher 双 watcher 接线', () => { const [recursivePaths, recursiveOpts] = watchMock.mock.calls[0] const [flatPaths, flatOpts] = watchMock.mock.calls[1] - expect(recursiveOpts.depth).toBe(5) - expect(flatOpts.depth).toBe(0) + expect(recursiveOpts?.depth).toBe(5) + expect(flatOpts?.depth).toBe(0) expect(recursivePaths).toEqual(getWindowsScanPaths()) expect(flatPaths).toEqual(getWindowsRootScanPaths()) }) - it('扁平根 watcher 不为空时才创建(覆盖 getWindowsRootScanPaths)', () => { + it('扁平根路径为空时(非 win32,如 darwin)不创建扁平 watcher', () => { + // getFlatRootWatchPaths 仅 win32 返回非空;其余平台为 [],命中 startWatching 的 length>0 守卫 + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) appWatcher.init({} as never) const watchMock = vi.mocked(chokidar.watch) - // 第二次 watch 调用的 paths 即扁平根路径 - expect(watchMock.mock.calls[1][0]).toEqual(getWindowsRootScanPaths()) + // 仅创建递归 watcher;扁平 watcher 因路径为空而跳过 + expect(watchMock).toHaveBeenCalledTimes(1) }) it('.lnk add/unlink 事件路由到防抖 notifyChange → refreshAppsCache', () => { diff --git a/tests/main/systemPaths.test.ts b/tests/main/systemPaths.test.ts index 795a72d1..6b9339da 100644 --- a/tests/main/systemPaths.test.ts +++ b/tests/main/systemPaths.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -// systemPaths.ts 顶部 import { app } from 'electron'(getWindowsScanPaths 用 app.getPath), -// mock 提供 app.getPath,避免依赖真实 Electron 运行时,确保跨平台运行。 vi.mock('electron', () => ({ app: { getPath: vi.fn((name: string) => `/mock/${name}`) } })) @@ -48,8 +46,4 @@ describe('getWindowsRootScanPaths(Start Menu 根路径)', () => { expect(scans).toContain(path.join(root, 'Programs')) } }) - - it('不改动 getWindowsScanPaths 行为(仍返回 4 条路径)', () => { - expect(getWindowsScanPaths()).toHaveLength(4) - }) }) diff --git a/tests/main/windowsScanner.test.ts b/tests/main/windowsScanner.test.ts index c81ebb00..971031a3 100644 --- a/tests/main/windowsScanner.test.ts +++ b/tests/main/windowsScanner.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -// mock 原生模块:避免 vite import-analysis 解析 native/index.ts 中的 .node?asset 导入 -// (windowsScanner 仅类型上依赖 MuiResolver,本文件的纯函数 / parseUrlFile 测试不触发它) vi.mock('../../src/main/core/native/index', () => ({ MuiResolver: { resolve: vi.fn(() => new Map()) } })) @@ -103,7 +101,6 @@ describe('deduplicateCommands', () => { }) it('应合并 Start Menu 根级与 Programs 子树同名同目标的快捷方式', () => { - // issue #551 场景:根级 .lnk 与 Programs 子树 .lnk 同名同目标,应合并为单一指令 const apps = [ { name: 'App', diff --git a/tests/main/windowsScannerFlat.test.ts b/tests/main/windowsScannerFlat.test.ts index f1d0b5eb..a39e6b12 100644 --- a/tests/main/windowsScannerFlat.test.ts +++ b/tests/main/windowsScannerFlat.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -// windowsScanner.ts import { shell } from 'electron';systemPaths.ts import { app } from 'electron'。 -// 一并 mock,避免依赖真实 Electron 运行时,确保跨平台运行。 vi.mock('electron', () => ({ shell: { readShortcutLink: vi.fn() }, app: { getPath: vi.fn((name: string) => `/mock/${name}`) } @@ -11,14 +9,19 @@ vi.mock('fs/promises', () => ({ default: { readdir: vi.fn(), readFile: vi.fn() } })) -// mock 原生模块,避免加载仅 win32 可用的 .node(MuiResolver 在本测试中不被触发) vi.mock('../../src/main/core/native/index', () => ({ MuiResolver: { resolve: vi.fn(() => new Map()) } })) +vi.mock('../../src/main/utils/common', () => ({ + extractAcronym: vi.fn((name: string) => { + if (name === 'BadApp') throw new Error('boom') + return 'GA' + }) +})) + import fsPromises from 'fs/promises' import type { Dirent } from 'fs' -import os from 'os' import path from 'path' import { shell } from 'electron' import { @@ -26,23 +29,28 @@ import { scanApplications } from '../../src/main/core/commandScanner/windowsScanner' import { getWindowsScanPaths, getWindowsRootScanPaths } from '../../src/main/utils/systemPaths' +import type { Command } from '../../src/main/core/commandScanner/types' + +// processShortcutEntry 会 push 带 _dedupeTarget 的对象;用扩展类型承接,避免逐处 cast +type ScannedApp = Command & { _dedupeTarget?: string } -// 构造一个 Dirent 桩(readdir withFileTypes 返回 Dirent[]) +// 装配扁平扫描的公共 mock(根目录条目 + 统一快捷方式目标),返回收集数组。 +function setupFlatScan(entries: Dirent[], target = 'C:/x.exe'): ScannedApp[] { + vi.mocked(fsPromises.readdir).mockResolvedValue(entries as never) + vi.mocked(shell.readShortcutLink).mockReturnValue({ target }) + return [] +} + +// 构造一个 Dirent 桩(readdir withFileTypes 返回 Dirent[])。 +// 扫描器只读取 name / isDirectory(),其余成员靠 cast 跳过。 function dirent(name: string, isDir = false): Dirent { - return { - name, - isDirectory: () => isDir, - isFile: () => !isDir, - isBlockDevice: () => false, - isCharacterDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - isSymbolicLink: () => false - } as unknown as Dirent + return { name, isDirectory: () => isDir, isFile: () => !isDir } as unknown as Dirent } +const ROOT = 'C:/StartMenuRoot' + // stub platform -> 'linux',使 getLocalizedDisplayNames 直接返回空 Map(第一行守卫), -// 从而根级 .lnk 一律降级为磁盘文件名(断言以磁盘文件名 / 路径为准),且不触发原生 MuiResolver。 +// 从而根级 .lnk 一律降级为磁盘文件名(断言以磁盘文件名 / 路径为准),且不触发原生 MuiResolver let originalPlatform: string beforeEach(() => { originalPlatform = process.platform @@ -53,88 +61,73 @@ afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) -// ========== 4.2 scanDirectoryFlat ========== +// ========== scanDirectoryFlat ========== describe('scanDirectoryFlat(扁平根扫描)', () => { - it('① 收集根级 .lnk 为 Command(name / path / icon 正确)', async () => { - const dir = 'C:/StartMenuRoot' - vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk')]) - vi.mocked(shell.readShortcutLink).mockReturnValue({ - target: 'C:/Program Files/App1/app1.exe' - } as never) - - const apps: unknown[] = [] - await scanDirectoryFlat(dir, apps as never, new Map()) + it('收集根级 .lnk 为 Command(name / path / icon 正确)', async () => { + const apps = setupFlatScan([dirent('App1.lnk')], 'C:/Program Files/App1/app1.exe') + await scanDirectoryFlat(ROOT, apps, new Map()) expect(apps).toHaveLength(1) - const app = apps[0] as { name: string; path: string; icon: string } - expect(app.name).toBe('App1') - expect(app.path).toBe(path.join(dir, 'App1.lnk')) - expect(app.icon).toContain('ztools-icon://') + expect(apps[0].name).toBe('App1') + expect(apps[0].path).toBe(path.join(ROOT, 'App1.lnk')) + expect(apps[0].icon).toContain('ztools-icon://') }) - it('② 不下钻子目录(子目录内 .lnk 不被收集——核心不变式)', async () => { - const dir = 'C:/StartMenuRoot' - vi.mocked(fsPromises.readdir).mockResolvedValue([ + it('不下钻子目录(子目录内 .lnk 不被收集——核心不变式)', async () => { + const apps = setupFlatScan([ dirent('App1.lnk'), dirent('Programs', true), dirent('Extra', true) ]) - vi.mocked(shell.readShortcutLink).mockReturnValue({ target: 'C:/x.exe' } as never) - - const apps: unknown[] = [] - await scanDirectoryFlat(dir, apps as never, new Map()) + await scanDirectoryFlat(ROOT, apps, new Map()) - const names = (apps as Array<{ name: string }>).map((a) => a.name) - expect(names).toEqual(['App1']) + expect(apps.map((a) => a.name)).toEqual(['App1']) // readdir 仅被调用一次(根目录),未对 Programs / Extra 子目录下钻 expect(fsPromises.readdir).toHaveBeenCalledTimes(1) }) - it('③ 应用 shouldSkipShortcut 过滤(根级 Uninstall.lnk 被跳过)', async () => { - const dir = 'C:/StartMenuRoot' - vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk'), dirent('Uninstall.lnk')]) - vi.mocked(shell.readShortcutLink).mockReturnValue({ target: 'C:/x.exe' } as never) + it('应用 shouldSkipShortcut 过滤(根级 Uninstall.lnk 被跳过)', async () => { + const apps = setupFlatScan([dirent('App1.lnk'), dirent('Uninstall.lnk')]) + await scanDirectoryFlat(ROOT, apps, new Map()) - const apps: unknown[] = [] - await scanDirectoryFlat(dir, apps as never, new Map()) - - const names = (apps as Array<{ name: string }>).map((a) => a.name) + const names = apps.map((a) => a.name) expect(names).toContain('App1') expect(names).not.toContain('Uninstall') }) - it('④ 正确填充 _dedupeTarget(目标路径)', async () => { - const dir = 'C:/StartMenuRoot' - vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('App1.lnk')]) - vi.mocked(shell.readShortcutLink).mockReturnValue({ - target: 'C:/Program Files/App1/app1.exe' - } as never) + it('正确填充 _dedupeTarget(目标路径)', async () => { + const target = 'C:/Program Files/App1/app1.exe' + const apps = setupFlatScan([dirent('App1.lnk')], target) + await scanDirectoryFlat(ROOT, apps, new Map()) - const apps: Array<{ _dedupeTarget?: string }> = [] - await scanDirectoryFlat(dir, apps as never, new Map()) + expect(apps[0]._dedupeTarget).toBe(target) + }) - expect(apps[0]._dedupeTarget).toBe('C:/Program Files/App1/app1.exe') + it('单个快捷方式处理失败不中断目录内其余扫描(逐 entry try-catch 加固)', async () => { + // BadApp 处理时 extractAcronym 抛错;GoodApp 应仍被收集 + const apps = setupFlatScan([dirent('BadApp.lnk'), dirent('GoodApp.lnk')]) + await scanDirectoryFlat(ROOT, apps, new Map()) + + const names = apps.map((a) => a.name) + expect(names).not.toContain('BadApp') // 抛错未入列 + expect(names).toContain('GoodApp') // 失败不中断后续扫描 }) it('.url 根级快捷方式也被收集(应用协议)', async () => { - const dir = 'C:/StartMenuRoot' - vi.mocked(fsPromises.readdir).mockResolvedValue([dirent('Steam.url')]) vi.mocked(fsPromises.readFile).mockResolvedValue( '[InternetShortcut]\nURL=steam://rungameid/1\nIconFile=C:/s.ico' ) - - const apps: unknown[] = [] - await scanDirectoryFlat(dir, apps as never, new Map()) + const apps = setupFlatScan([dirent('Steam.url')]) + await scanDirectoryFlat(ROOT, apps, new Map()) expect(apps).toHaveLength(1) - const app = apps[0] as { name: string; path: string } - expect(app.name).toBe('Steam') - expect(app.path).toBe('steam://rungameid/1') + expect(apps[0].name).toBe('Steam') + expect(apps[0].path).toBe('steam://rungameid/1') }) }) -// ========== 4.4 scanApplications 集成 ========== +// ========== scanApplications 集成 ========== describe('scanApplications(Start Menu 根级 + Programs 子树集成)', () => { it('根级与 Programs 子树的 .lnk 均被索引,根级子目录内的不被索引', async () => { @@ -160,14 +153,13 @@ describe('scanApplications(Start Menu 根级 + Programs 子树集成)', () = [userPrograms]: [dirent('UserProgramsApp.lnk')] } - vi.mocked(fsPromises.readdir).mockImplementation(async (dirPath: unknown) => { - return fsTree[dirPath as string] ?? [] - }) + vi.mocked(fsPromises.readdir).mockImplementation( + async (dirPath: unknown) => (fsTree[dirPath as string] ?? []) as never + ) // 每个 .lnk 解析出各自不同的目标(避免意外去重合并) - vi.mocked(shell.readShortcutLink).mockImplementation((filePath: unknown) => { - const base = path.basename(filePath as string) - return { target: `C:/Program Files/${base}.exe` } as never - }) + vi.mocked(shell.readShortcutLink).mockImplementation((filePath: string) => ({ + target: `C:/Program Files/${path.basename(filePath)}.exe` + })) const result = await scanApplications() const names = result.map((a) => a.name).sort()