diff --git a/src/main/appWatcher.ts b/src/main/appWatcher.ts index 1329a423..4e12d9fc 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,8 @@ const SKIP_FOLDERS = [ ] class AppWatcher { - private watcher: FSWatcher | null = null + private recursiveWatcher: FSWatcher | null = null + private flatRootWatcher: FSWatcher | null = null private mainWindow: BrowserWindow | null = null private debounceTimer: NodeJS.Timeout | null = null private readonly DEBOUNCE_DELAY = 1000 // 1秒防抖 @@ -31,8 +36,8 @@ class AppWatcher { this.startWatching() } - // 获取监听路径 - private getWatchPaths(): string[] { + // 获取递归监听路径 + private getRecursiveWatchPaths(): string[] { if (process.platform === 'win32') { return getWindowsScanPaths() } @@ -44,6 +49,15 @@ class AppWatcher { return [] } + // 获取扁平根监听路径 + private getFlatRootWatchPaths(): string[] { + if (process.platform === 'win32') { + return getWindowsRootScanPaths() + } + + return [] + } + // 判断是否应该忽略 private shouldIgnore(filePath: string, watchPaths: string[]): boolean { const basename = path.basename(filePath) @@ -97,18 +111,32 @@ 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 需要递归监听子目录,macOS 只需要一级 + this.recursiveWatcher = this.createWatcher(recursivePaths, isWindows ? 5 : 1, isWindows) - // 创建监听器 - this.watcher = chokidar.watch(watchPaths, { - // Windows 需要递归监听子目录,macOS 只需要一级 - depth: isWindows ? 5 : 1, - // 根据平台设置忽略规则 + // 扁平根 watcher + if (flatRootPaths.length > 0) { + this.flatRootWatcher = this.createWatcher(flatRootPaths, 0, isWindows) + } + + this.bindWatcherEvents(this.recursiveWatcher) + if (this.flatRootWatcher) { + this.bindWatcherEvents(this.flatRootWatcher) + } + } + + private createWatcher(watchPaths: string[], depth: number, usePolling: boolean): FSWatcher { + return chokidar.watch(watchPaths, { + depth, + // 忽略规则 ignored: (filePath: string) => { return this.shouldIgnore(filePath, watchPaths) }, @@ -117,9 +145,9 @@ class AppWatcher { // 忽略初始添加事件(避免启动时触发大量事件) 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 +156,13 @@ class AppWatcher { pollInterval: 100 } }) + } + 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 +172,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 +183,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 +193,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 +202,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 +232,15 @@ class AppWatcher { // 停止监听 public stop(): void { - if (this.watcher) { - console.log('[AppWatcher] 停止监听应用目录') - this.watcher.close() - this.watcher = null + 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..88aafdb7 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,7 +203,105 @@ export async function parseUrlFile(filePath: string): Promise +): Promise { + 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 + } + + // 处理 .lnk 快捷方式 + if (ext !== '.lnk') return + + // 优先使用本地化显示名称,降级为磁盘文件名 + // 解决 Windows 系统快捷方式文件名为英文(如 File Explorer.lnk)但显示名为中文的问题 + const appName = displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.lnk') + + // 尝试解析快捷方式目标(必须先解析才能获取真实路径) + let shortcutDetails: Electron.ShortcutDetails | null = null + try { + shortcutDetails = shell.readShortcutLink(fullPath) + } catch { + // 解析失败,使用快捷方式本身 + } + + // 获取目标路径和应用路径 + const targetPath = shortcutDetails?.target?.trim() || '' + + // 如果 .lnk 指向 .url 文件,解析 .url 内容判断是否为应用协议 + if (targetPath.toLowerCase().endsWith('.url')) { + const urlInfo = await parseUrlFile(targetPath) + if (!urlInfo) return // http/https 或解析失败,跳过 + + if (SKIP_NAME_PATTERN.test(appName)) return + + const iconPath = urlInfo.iconFile || fullPath + const icon = getIconUrl(iconPath) + + apps.push({ + name: appName, + path: urlInfo.url, + icon, + acronym: extractAcronym(appName) + }) + return + } + + // 过滤检查:仅按名称过滤(不按目标类型/路径过滤) + if (shouldSkipShortcut(appName)) { + return + } + + // 始终使用 .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 + } + + apps.push(app) +} + +// 递归扫描目录中的快捷方式(Programs 子树 / 桌面) async function scanDirectory( dirPath: string, apps: Command[], @@ -212,108 +311,50 @@ async function scanDirectory( 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() - - // 处理 .url 快捷方式(应用协议链接,如 steam://) - if (ext === '.url') { - const urlInfo = await parseUrlFile(fullPath) - if (!urlInfo) continue - - // 优先使用本地化显示名称,降级为磁盘文件名 - const appName = - displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.url') - - // 过滤检查 - if (SKIP_NAME_PATTERN.test(appName)) continue - - // 图标:优先使用 .url 文件中的 IconFile,否则使用 .url 文件本身 - const iconPath = urlInfo.iconFile || fullPath - const icon = getIconUrl(iconPath) - - apps.push({ - name: appName, - path: urlInfo.url, // 使用协议链接作为启动路径 - icon, - acronym: extractAcronym(appName) - }) + await scanDirectory(path.join(dirPath, entry.name), apps, displayNameMap) continue } - // 处理 .lnk 快捷方式 - if (ext !== '.lnk') continue - - // 优先使用本地化显示名称,降级为磁盘文件名 - // 解决 Windows 系统快捷方式文件名为英文(如 File Explorer.lnk)但显示名为中文的问题 - const appName = - displayNameMap.get(fullPath.toLowerCase()) || path.basename(entry.name, '.lnk') - - // 尝试解析快捷方式目标(必须先解析才能获取真实路径) - let shortcutDetails: Electron.ShortcutDetails | null = null try { - shortcutDetails = shell.readShortcutLink(fullPath) - } catch { - // 解析失败,使用快捷方式本身 + await processShortcutEntry(dirPath, entry, apps, displayNameMap) + } catch (error) { + // 单个文件失败不影响目录内其余扫描 + console.error(`[Scanner] 处理快捷方式失败 ${path.join(dirPath, entry.name)}:`, error) } + } + } catch (error) { + console.error(`[Scanner] 扫描目录失败 ${dirPath}:`, error) + } +} - // 获取目标路径和应用路径 - const targetPath = shortcutDetails?.target?.trim() || '' - - // 如果 .lnk 指向 .url 文件,解析 .url 内容判断是否为应用协议 - if (targetPath.toLowerCase().endsWith('.url')) { - const urlInfo = await parseUrlFile(targetPath) - if (!urlInfo) continue // http/https 或解析失败,跳过 - - if (SKIP_NAME_PATTERN.test(appName)) continue - - const iconPath = urlInfo.iconFile || fullPath - const icon = getIconUrl(iconPath) - - apps.push({ - name: appName, - path: urlInfo.url, - icon, - acronym: extractAcronym(appName) - }) - continue - } +/** + * 扁平扫描(Start Menu 根专用) + * 仅处理本层文件,不下钻 Programs 子目录,避免重复索引 + */ +export async function scanDirectoryFlat( + dirPath: string, + apps: Command[], + displayNameMap: Map +): Promise { + try { + const entries = await fsPromises.readdir(dirPath, { withFileTypes: true }) - // 过滤检查:仅按名称过滤(不按目标类型/路径过滤) - if (shouldSkipShortcut(appName)) { - continue - } + for (const entry of entries) { + if (entry.isDirectory()) continue - // 始终使用 .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 + try { + await processShortcutEntry(dirPath, entry, apps, displayNameMap) + } catch (error) { + // 单个文件失败不影响目录内其余扫描 + console.error(`[Scanner] 处理快捷方式失败 ${path.join(dirPath, entry.name)}:`, error) } - - apps.push(app) } } catch (error) { console.error(`[Scanner] 扫描目录失败 ${dirPath}:`, error) @@ -348,14 +389,20 @@ export async function scanApplications(): Promise { // 获取 Windows 扫描路径(开始菜单 + 桌面) const scanPaths = getWindowsScanPaths() + // 获取 Start Menu 根路径 + const rootScanPaths = getWindowsRootScanPaths() // 获取本地化显示名称(解决 Windows 系统快捷方式文件名为英文的问题) const displayNameMap = await getLocalizedDisplayNames(scanPaths) - // 扫描所有目录 + // 递归扫描 Programs + 桌面 for (const menuPath of scanPaths) { await scanDirectory(menuPath, apps, displayNameMap) } + // 扁平扫描 Start Menu 根 + 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..07b88e23 100644 --- a/src/main/utils/systemPaths.ts +++ b/src/main/utils/systemPaths.ts @@ -36,6 +36,15 @@ export function getWindowsScanPaths(): string[] { return [programDataStartMenu, userStartMenu, userDesktop, publicDesktop] } +/** + * 获取 Windows 开始菜单根路径 + */ +export function getWindowsRootScanPaths(): string[] { + return getWindowsScanPaths() + .filter((p) => p.endsWith(`${path.sep}Programs`)) + .map(path.dirname) +} + /** * 获取 macOS 应用目录路径 */ diff --git a/tests/main/appWatcher.test.ts b/tests/main/appWatcher.test.ts new file mode 100644 index 00000000..de57bfc0 --- /dev/null +++ b/tests/main/appWatcher.test.ts @@ -0,0 +1,135 @@ +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 = (): MockWatcherApi => { + const handlers: Record void>> = {} + const api: MockWatcherApi = { + 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('扁平根路径为空时(非 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) + // 仅创建递归 watcher;扁平 watcher 因路径为空而跳过 + expect(watchMock).toHaveBeenCalledTimes(1) + }) + + 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..6b9339da --- /dev/null +++ b/tests/main/systemPaths.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest' + +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')) + } + }) +}) diff --git a/tests/main/windowsScanner.test.ts b/tests/main/windowsScanner.test.ts index db767ac9..971031a3 100644 --- a/tests/main/windowsScanner.test.ts +++ b/tests/main/windowsScanner.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect, vi } from 'vitest' + +vi.mock('../../src/main/core/native/index', () => ({ + MuiResolver: { resolve: vi.fn(() => new Map()) } +})) + import { shouldSkipShortcut, getIconUrl, @@ -95,6 +100,24 @@ describe('deduplicateCommands', () => { expect(result[0].path).toBe('C:\\Users\\test\\Start Menu\\Programs\\App.lnk') }) + it('应合并 Start Menu 根级与 Programs 子树同名同目标的快捷方式', () => { + 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..a39e6b12 --- /dev/null +++ b/tests/main/windowsScannerFlat.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +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() } +})) + +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 path from 'path' +import { shell } from 'electron' +import { + scanDirectoryFlat, + 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 } + +// 装配扁平扫描的公共 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 } as unknown as Dirent +} + +const ROOT = 'C:/StartMenuRoot' + +// 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 }) +}) + +// ========== scanDirectoryFlat ========== + +describe('scanDirectoryFlat(扁平根扫描)', () => { + 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) + 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 apps = setupFlatScan([ + dirent('App1.lnk'), + dirent('Programs', true), + dirent('Extra', true) + ]) + await scanDirectoryFlat(ROOT, apps, new Map()) + + expect(apps.map((a) => a.name)).toEqual(['App1']) + // readdir 仅被调用一次(根目录),未对 Programs / Extra 子目录下钻 + expect(fsPromises.readdir).toHaveBeenCalledTimes(1) + }) + + it('应用 shouldSkipShortcut 过滤(根级 Uninstall.lnk 被跳过)', async () => { + const apps = setupFlatScan([dirent('App1.lnk'), dirent('Uninstall.lnk')]) + await scanDirectoryFlat(ROOT, apps, new Map()) + + const names = apps.map((a) => a.name) + expect(names).toContain('App1') + expect(names).not.toContain('Uninstall') + }) + + it('正确填充 _dedupeTarget(目标路径)', async () => { + const target = 'C:/Program Files/App1/app1.exe' + const apps = setupFlatScan([dirent('App1.lnk')], target) + await scanDirectoryFlat(ROOT, apps, new Map()) + + expect(apps[0]._dedupeTarget).toBe(target) + }) + + 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 () => { + vi.mocked(fsPromises.readFile).mockResolvedValue( + '[InternetShortcut]\nURL=steam://rungameid/1\nIconFile=C:/s.ico' + ) + const apps = setupFlatScan([dirent('Steam.url')]) + await scanDirectoryFlat(ROOT, apps, new Map()) + + expect(apps).toHaveLength(1) + expect(apps[0].name).toBe('Steam') + expect(apps[0].path).toBe('steam://rungameid/1') + }) +}) + +// ========== 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) => (fsTree[dirPath as string] ?? []) as never + ) + // 每个 .lnk 解析出各自不同的目标(避免意外去重合并) + 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() + + // 根级 + 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')) + }) +})