diff --git a/src/main/appWatcher.ts b/src/main/appWatcher.ts index 9a42d412..958c622a 100644 --- a/src/main/appWatcher.ts +++ b/src/main/appWatcher.ts @@ -9,6 +9,7 @@ import { type UwpPackageChangeEvent } from './core/uwpPackageMonitor' import { + getLinuxApplicationPaths, getMacApplicationPaths, getWindowsFlatScanPaths, getWindowsRecursiveScanPaths @@ -66,6 +67,10 @@ class AppWatcher { return getMacApplicationPaths() } + if (process.platform === 'linux') { + return getLinuxApplicationPaths() + } + return [] } @@ -130,6 +135,22 @@ class AppWatcher { return true } + if (process.platform === 'linux') { + // .desktop 文件始终监听(无论位于顶层还是子目录) + if (basename.endsWith('.desktop')) { + return false + } + + // 放行 watch 目录下的子目录,便于 chokidar 下钻(flatpak 导出目录、子包目录等); + // 其余文件(.png、.directory、install 缓存等)忽略。 + try { + return !fs.statSync(filePath).isDirectory() + } catch { + // stat 失败(如 unlink 事件时文件已不存在):不忽略,交由上层按 .desktop 后缀判断 + return false + } + } + return true } @@ -147,7 +168,7 @@ class AppWatcher { console.log('[AppWatcher] 开始监听应用目录变化(扁平根):', flatRootPaths) // 递归 watcher - // Windows 需要递归监听子目录,macOS 只需要一级 + // Windows 需要递归监听子目录,macOS/Linux 只需一级(Linux 的 .desktop 直接位于目录下) this.recursiveWatcher = this.createWatcher(recursivePaths, isWindows ? 5 : 1, isWindows) // 扁平根 watcher @@ -234,6 +255,46 @@ class AppWatcher { }) } + if (process.platform === 'linux') { + // Linux: 监听 .desktop 文件(deb/Flatpak 安装会在应用目录新增 .desktop 文件) + watcher.on('add', (filePath: string) => { + if (filePath.endsWith('.desktop')) { + console.log('[AppWatcher] 检测到新的 .desktop 文件:', filePath) + this.notifyChange('add', filePath) + } + }) + } + + // 监听内容修改事件。 + // 关键:用户编辑已有 .desktop 文件(例如把 NoDisplay 从 true 改成 false、 + // 重新跑 update-desktop-database)只会触发 chokidar 的 'change' 事件, + // 不会触发 add / unlink。原实现漏了这个分支,导致编辑已有 .desktop + // 文件后 ZTools 的应用缓存永远不刷新。 + // 这里按平台只监听对应后缀,避免被 Windows 的 .lnk / macOS 的 .app 干扰。 + if (process.platform === 'win32') { + watcher.on('change', (filePath: string) => { + if (filePath.endsWith('.lnk')) { + console.log('[AppWatcher] 检测到快捷方式修改:', filePath) + this.notifyChange('add', filePath) + } + }) + } else if (process.platform === 'darwin') { + // macOS 没有可识别的文件级“应用”句柄,监听顶层 .app 容器目录即可。 + watcher.on('change', (filePath: string) => { + if (filePath.endsWith('.app')) { + console.log('[AppWatcher] 检测到应用目录修改:', filePath) + this.notifyChange('add', filePath) + } + }) + } else if (process.platform === 'linux') { + watcher.on('change', (filePath: string) => { + if (filePath.endsWith('.desktop')) { + console.log('[AppWatcher] 检测到 .desktop 文件修改:', filePath) + this.notifyChange('add', filePath) + } + }) + } + // 监听删除事件 if (process.platform === 'win32') { // Windows: 监听 .lnk 文件删除 @@ -255,6 +316,16 @@ class AppWatcher { }) } + if (process.platform === 'linux') { + // Linux: 监听 .desktop 文件删除(卸载 deb/Flatpak 会移除 .desktop 文件) + watcher.on('unlink', (filePath: string) => { + if (filePath.endsWith('.desktop')) { + console.log('[AppWatcher] 检测到 .desktop 文件删除:', filePath) + this.notifyChange('remove', filePath) + } + }) + } + // 监听错误 watcher.on('error', (error: unknown) => { console.error('[AppWatcher] 应用目录监听错误:', error) diff --git a/src/main/core/commandScanner/linuxScanner.ts b/src/main/core/commandScanner/linuxScanner.ts index e12136a5..6da5d22a 100644 --- a/src/main/core/commandScanner/linuxScanner.ts +++ b/src/main/core/commandScanner/linuxScanner.ts @@ -113,12 +113,24 @@ function getIconSearchPaths(): string[] { '/usr/share/pixmaps', path.join(home, '.icons'), '/usr/local/share/icons', - '/usr/local/share/pixmaps' + '/usr/local/share/pixmaps', + // Flatpak 图标导出目录(与 .desktop 导出目录对应),目录不存在时 readdir 抛错被上层捕获 + path.join(home, '.local/share/flatpak/exports/share/icons'), + '/var/lib/flatpak/exports/share/icons' ] } const ICON_EXTENSIONS = ['.png', '.svg', '.xpm'] -const ICON_PREFERRED_SIZES = ['256x256', '128x128', '64x64', '48x48', '32x32', 'scalable'] +// 512x512 靠前:Flatpak 常仅导出 512 尺寸的图标 +const ICON_PREFERRED_SIZES = [ + '512x512', + '256x256', + '128x128', + '64x64', + '48x48', + '32x32', + 'scalable' +] /** * 在 XDG 图标主题中查找图标文件路径 @@ -144,7 +156,8 @@ async function findIconPath(iconName: string): Promise { // 先检查各个主题目录下的常用尺寸 try { const entries = await fs.readdir(searchPath, { withFileTypes: true }) - const themes = entries.filter((e) => e.isDirectory()).map((e) => e.name) + // 兼容 symbol link 主题目录(部分发行版将 hicolor 链接到别处) + const themes = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name) for (const theme of ['hicolor', ...themes]) { for (const size of ICON_PREFERRED_SIZES) { for (const category of ['apps', 'applications']) { @@ -232,12 +245,14 @@ function getLinuxDesktopPaths(): string[] { /** * 扫描单个目录下的所有 .desktop 文件 + * 同时接受普通文件与符号链接:Flatpak 导出目录中的 .desktop 文件是符号链接, + * Dirent.isFile() 对 symlink 返回 false,仅用 isFile 会漏掉整个 Flatpak 应用集。 */ async function scanDesktopDir(dirPath: string): Promise { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }) return entries - .filter((e) => e.isFile() && e.name.endsWith('.desktop')) + .filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith('.desktop')) .map((e) => path.join(dirPath, e.name)) } catch { return [] diff --git a/tests/main/appWatcher.test.ts b/tests/main/appWatcher.test.ts index 51ee7bd8..27a2288b 100644 --- a/tests/main/appWatcher.test.ts +++ b/tests/main/appWatcher.test.ts @@ -66,6 +66,8 @@ import chokidar from 'chokidar' import path from 'path' import appsAPI from '../../src/main/api/renderer/commands' import { + getLinuxApplicationPaths, + getMacApplicationPaths, getWindowsFlatScanPaths, getWindowsRecursiveScanPaths } from '../../src/main/utils/systemPaths' @@ -201,3 +203,127 @@ describe('AppWatcher 双 watcher 接线', () => { expect(uwpMonitorMock.stop).toHaveBeenCalledTimes(1) }) }) + +describe('AppWatcher Linux 监听', () => { + it('Linux 仅创建递归 watcher,路径来自 getLinuxApplicationPaths(depth:1)', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + expect(watchMock).toHaveBeenCalledTimes(1) + const [recursivePaths, recursiveOpts] = watchMock.mock.calls[0] + expect(recursiveOpts?.depth).toBe(1) + expect(recursivePaths).toEqual(getLinuxApplicationPaths()) + }) + + it('.desktop add/unlink 事件路由到防抖 notifyChange → refreshAppsCache', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as MockWatcherApi + const desktopPath = path.join(getLinuxApplicationPaths()[0], 'NewApp.desktop') + + // add 事件:防抖未到时不刷新 + recursiveWatcher.__emit('add', desktopPath) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + + // 推进防抖窗口(DEBOUNCE_DELAY = 1000ms)后刷新 + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(1) + + // unlink 事件同样触发刷新 + recursiveWatcher.__emit('unlink', desktopPath) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(2) + }) + + it('非 .desktop 文件事件不触发刷新', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + appWatcher.init({} as never) + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as MockWatcherApi + + recursiveWatcher.__emit('add', path.join(getLinuxApplicationPaths()[0], 'notes.txt')) + recursiveWatcher.__emit('unlink', path.join(getLinuxApplicationPaths()[0], 'notes.txt')) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + }) +}) + +describe('AppWatcher change 事件处理', () => { + it('Linux: .desktop change 路由到防抖 notifyChange → refreshAppsCache', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as MockWatcherApi + const desktopPath = path.join(getLinuxApplicationPaths()[0], 'EditedApp.desktop') + + // change 事件:防抖未到时不刷新 + recursiveWatcher.__emit('change', desktopPath) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + + // 推进防抖窗口后刷新 + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(1) + }) + + it('Linux: 非 .desktop 文件 change 不触发刷新', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as MockWatcherApi + + recursiveWatcher.__emit('change', path.join(getLinuxApplicationPaths()[0], 'notes.txt')) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + }) + + it('Win32: .lnk change 路由到防抖 notifyChange → refreshAppsCache', () => { + // beforeEach 默认 stub 为 win32 + 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 = getWindowsFlatScanPaths()[0] + const lnkPath = path.join(rootPath, 'EditedApp.lnk') + + flatWatcher.__emit('change', lnkPath) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(1) + }) + + it('Win32: 非 .lnk 文件 change 不触发刷新', () => { + 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('change', path.join(getWindowsFlatScanPaths()[0], 'notes.txt')) + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + }) + + it('macOS: .app change 路由到防抖 notifyChange → refreshAppsCache', () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + appWatcher.init({} as never) + + const watchMock = vi.mocked(chokidar.watch) + const recursiveWatcher = watchMock.mock.results[0].value as MockWatcherApi + const appPath = path.join(getMacApplicationPaths()[0], 'EditedApp.app') + + recursiveWatcher.__emit('change', appPath) + expect(appsAPI.refreshAppsCache).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1000) + expect(appsAPI.refreshAppsCache).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/main/linuxScanner.test.ts b/tests/main/linuxScanner.test.ts new file mode 100644 index 00000000..be622966 --- /dev/null +++ b/tests/main/linuxScanner.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +// linuxScanner → ../../utils/common → core/native/index → *.node?asset, +// vitest 无法解析原生模块,因此 mock 掉 common 中仅用的 extractAcronym。 +vi.mock('../../src/main/utils/common', () => ({ + extractAcronym: (name: string) => + name + .split(/[\s-]+/) + .map((w) => w[0]) + .join('') + .toLowerCase() +})) + +// linuxScanner.scanApplications 依赖真实文件系统: +// - getLinuxDesktopPaths() 使用 os.homedir() + XDG_DATA_DIRS +// - Flatpak 目录会额外扫描硬编码的 /var/lib/flatpak/...(若存在) +// 因此本测试把 homedir 指到临时目录,并只断言「我们的 .desktop 被收集」, +// 不受测试机是否装有真实 Flatpak 应用影响。 + +let tempBase: string +let originalXdgDataDirs: string | undefined + +beforeEach(() => { + tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'ztools-linux-scan-')) + originalXdgDataDirs = process.env.XDG_DATA_DIRS + // 将 XDG_DATA_DIRS 指向临时目录,避免扫描真实 /usr/share + process.env.XDG_DATA_DIRS = path.join(tempBase, 'share') + // 将 homedir 重定向,使用户级/Flatpak 用户级目录落在临时目录内 + vi.spyOn(os, 'homedir').mockReturnValue(tempBase) +}) + +afterEach(() => { + vi.restoreAllMocks() + if (originalXdgDataDirs === undefined) { + delete process.env.XDG_DATA_DIRS + } else { + process.env.XDG_DATA_DIRS = originalXdgDataDirs + } + fs.rmSync(tempBase, { recursive: true, force: true }) +}) + +describe('linuxScanner 收集 Flatpak 符号链接 .desktop', () => { + it('普通 .desktop 与符号链接 .desktop 都会被扫描到', async () => { + // 用户级应用目录 + const userApps = path.join(tempBase, '.local', 'share', 'applications') + fs.mkdirSync(userApps, { recursive: true }) + + // 1) 普通 .desktop 文件 + fs.writeFileSync( + path.join(userApps, 'normal.desktop'), + `[Desktop Entry] +Type=Application +Name=Normal App +Exec=/usr/bin/normal +` + ) + + // 2) 模拟 Flatpak 导出:.desktop 是指向目录外真实文件的符号链接 + const targetsDir = path.join(tempBase, 'targets') + fs.mkdirSync(targetsDir, { recursive: true }) + const targetFile = path.join(targetsDir, 'flatpak-app') + fs.writeFileSync( + targetFile, + `[Desktop Entry] +Type=Application +Name=Flatpak App +Exec=flatpak run org.example.App +` + ) + fs.symlinkSync(targetFile, path.join(userApps, 'org.example.App.desktop')) + + const { scanApplications } = await import('../../src/main/core/commandScanner/linuxScanner') + const apps = await scanApplications() + const names = apps.map((a) => a.name) + + expect(names).toContain('Normal App') + expect(names).toContain('Flatpak App') + }) +})