Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion src/main/appWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type UwpPackageChangeEvent
} from './core/uwpPackageMonitor'
import {
getLinuxApplicationPaths,
getMacApplicationPaths,
getWindowsFlatScanPaths,
getWindowsRecursiveScanPaths
Expand Down Expand Up @@ -66,6 +67,10 @@ class AppWatcher {
return getMacApplicationPaths()
}

if (process.platform === 'linux') {
return getLinuxApplicationPaths()
}

return []
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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 文件删除
Expand All @@ -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)
Expand Down
23 changes: 19 additions & 4 deletions src/main/core/commandScanner/linuxScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 图标主题中查找图标文件路径
Expand All @@ -144,7 +156,8 @@ async function findIconPath(iconName: string): Promise<string | null> {
// 先检查各个主题目录下的常用尺寸
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']) {
Expand Down Expand Up @@ -232,12 +245,14 @@ function getLinuxDesktopPaths(): string[] {

/**
* 扫描单个目录下的所有 .desktop 文件
* 同时接受普通文件与符号链接:Flatpak 导出目录中的 .desktop 文件是符号链接,
* Dirent.isFile() 对 symlink 返回 false,仅用 isFile 会漏掉整个 Flatpak 应用集。
*/
async function scanDesktopDir(dirPath: string): Promise<string[]> {
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 []
Expand Down
126 changes: 126 additions & 0 deletions tests/main/appWatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})
})
82 changes: 82 additions & 0 deletions tests/main/linuxScanner.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})