本文档介绍如何实现跨平台的应用快速检索功能,涵盖 macOS、Windows 和 Linux 三个平台的应用发现与图标提取技术。
Electron 实现系统级别的应用检索,需要针对不同平台进行定制化开发。
应用快速检索的核心在于 发现 和 解析。需要在各个操作系统中找到已安装应用程序的元数据,包括应用名称、可执行文件路径、图标等信息。由于不同操作系统的软件安装管理机制各异,因此需要采用不同的策略:
system_profiler 工具,它可以输出详细的 XML 格式的软件信息。通过 child_process 执行该命令,然后解析返回的 XML 数据来获取应用列表。图标则通过解析应用包内的 Info.plist 文件来定位并转换Uninstall 相关的注册表项,这是最直接的方式,但存在数据不一致和残留项的问题.lnk 文件),通过解析这些快捷方式获取应用信息。这种方式更为可靠.desktop 文件来发现应用。这些文件遵循 freedesktop.org 标准,包含了应用的名称、执行命令、图标等元数据获取到应用信息后,为了在界面上统一展示,还需要将各平台不同格式的图标(如 macOS 的 .icns,Windows 的 .ico 或内嵌在 .exe 中的图标)转换为通用的 .png 或 base64 格式
在 macOS 中使用 system_profiler 命令行工具来获取已安装的应用信息。system_profiler 是功能强大的工具,可以提供关于系统硬件、软件的详尽报告。通过指定 SPApplicationsDataType 数据类型和 -xml 输出格式,可以得到一个包含所有已安装应用信息的 XML 文档
常用参数:
-xml: 以 XML 格式输出-detailLevel mini: 提供最小化的信息,以提高效率SPApplicationsDataType: 指定获取应用相关数据示例命令:
/usr/sbin/system_profiler -xml -detailLevel mini SPApplicationsDataType输出的 XML 片段示例:
<dict>
<key>_name</key>
<string>钉钉</string>
<key>path</key>
<string>/Applications/DingTalk.app</string>
<key>version</key>
<string>7.1.6</string>
<!-- ... 其他信息 ... -->
</dict>实现步骤:
child_process.spawn 执行 system_profiler 命令stdout,将输出的 XML 数据拼接完整plist 库解析 XML 字符串,提取应用列表代码实现:
import { spawn } from 'child_process';
import plist from 'plist';
export default function getApps(resolve, reject) {
let resultBuffer = new Buffer.from([]);
// 通过 spawn 调用 system_profiler 脚本
const profileInstalledApps = spawn('/usr/sbin/system_profiler', [
'-xml',
'-detailLevel',
'mini',
'SPApplicationsDataType',
]);
// 监听返回结果,写入 resultBuffer
profileInstalledApps.stdout.on('data', (chunckBuffer) => {
resultBuffer = Buffer.concat([resultBuffer, chunckBuffer]);
});
// 监听退出事件
profileInstalledApps.on('exit', (exitCode) => {
if (exitCode !== 0) {
reject([]);
return;
}
try {
// 解析 XML 文档
const [installedApps] = plist.parse(resultBuffer.toString());
// 返回结果
return resolve(installedApps._items);
} catch (err) {
reject(err);
}
});
// 出错后抛出
profileInstalledApps.on('error', (err) => {
reject(err);
});
}源码参考: getApps.ts
system_profiler 的输出不包含图标信息,因此需要手动提取。macOS 应用的图标信息通常定义在应用包(.app)的 Contents/Info.plist 文件中
实现步骤:
Info.plist: 根据应用路径找到 Contents/Info.plist 文件Info.plist: 读取并解析该文件,找到 CFBundleIconFile 字段,它定义了图标文件的名称(不含扩展名)Contents/Resources/ 目录下,常见的格式为 .icns 或 .tiff.icns 文件,使用 macOS 自带的 sips (Scriptable Image Processing System) 命令将其转换为 .png 格式
sips是 macOS 系统中的一个命令行工具,用于对图像进行处理和操作。该命令的全称是 "Scriptable Image Processing System",它允许用户通过命令行对图像文件进行各种操作,包括格式转换、大小调整、颜色管理等
sips 命令示例:
sips -s format png '/path/to/icon.icns' --out '/path/to/output.png' --resampleHeightWidth 64 64代码实现:
import path from 'path';
import fs from 'fs';
import { exec } from 'child_process';
import plist from 'plist';
const getIconFile = (appFileInput) => {
return new Promise((resolve, reject) => {
// 根据 app 路径,获取 Info.plist 路径
const plistPath = path.join(appFileInput, 'Contents', 'Info.plist');
// 解析 plist 文件
plist.readFile(plistPath, (err, data) => {
// 如果不存在 CFBundleIconFile 则返回 macOS 系统默认图标
if (err || !data.CFBundleIconFile) {
return resolve(
'/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns'
);
}
// 获取 icns 图标路径
const iconFile = path.join(
appFileInput,
'Contents',
'Resources',
data.CFBundleIconFile
);
// 依次通过 文件名、.icns、.tiff 来寻找 app icon
const iconFiles = [iconFile, iconFile + '.icns', iconFile + '.tiff'];
const existedIcon = iconFiles.find((iconFile) => {
return fs.existsSync(iconFile);
});
// 找不到也返回 macOS 系统默认图标
resolve(
existedIcon ||
'/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns'
);
});
});
};
const tiffToPng = (iconFile, pngFileOutput) => {
return new Promise((resolve, reject) => {
// tiff、icns 图标转 png
exec(
`sips -s format png '${iconFile}' --out '${pngFileOutput}' --resampleHeightWidth 64 64`,
(error) => {
error ? reject(error) : resolve(null);
}
);
});
};
// 传入 app 路径,返回对应 app 图片路径
const app2png = (appFileInput, pngFileOutput) => {
return getIconFile(appFileInput).then((iconFile) => {
return tiffToPng(iconFile, pngFileOutput);
});
};
export default app2png;源码参考: app2png.ts
在 Windows 平台上,应用信息的获取方式与 macOS 和 Linux 截然不同。主要有两种途径:读取注册表和遍历“开始菜单”的快捷方式。
1. 注册表方式 (不推荐)
通过查询 Uninstall 相关的注册表项,可以找到大部分已安装的程序信息
HKCU\Software\Microsoft\Windows\CurrentVersion\UninstallHKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall尽管这种方法直接,但存在明显缺陷:
DisplayIcon、DisplayName 等关键字段可能缺失或格式错误2. 快捷方式方式 (推荐)
遍历“开始菜单”中的快捷方式 (.lnk 文件) 是一种更可靠的方法。这些快捷方式通常指向应用的主可执行文件
C:\ProgramData\Microsoft\Windows\Start Menu\Programs%APPDATA%\Microsoft\Windows\Start Menu\Programs实现步骤:
.lnk 文件,使用 Electron 的 shell.readShortcutLink() 方法解析出目标文件路径 (target)代码实现:
import { shell } from "electron"
import fs from "fs/promises"
import path from "path"
import os from "os"
async function getWindowsApps() {
const appList = []
const startMenuPaths = [
path.join("C:", "ProgramData", "Microsoft", "Windows", "Start Menu", "Programs"),
path.join(
os.homedir(),
"AppData",
"Roaming",
"Microsoft",
"Windows",
"Start Menu",
"Programs"
)
]
async function findShortcuts(directory) {
try {
const files = await fs.readdir(directory, { withFileTypes: true })
for (const file of files) {
const fullPath = path.join(directory, file.name)
if (file.isDirectory()) {
await findShortcuts(fullPath)
} else if (path.extname(fullPath).toLowerCase() === ".lnk") {
try {
const shortcutDetails = shell.readShortcutLink(fullPath)
const targetPath = shortcutDetails.target
// 过滤无效或卸载程序
if (!targetPath || targetPath.toLowerCase().includes("uninstall")) {
continue
}
appList.push({
name: path.basename(targetPath, path.extname(targetPath)),
path: targetPath,
icon: "" // Icon path will be resolved later
})
} catch (e) {
// Ignore shortcuts that can't be read
}
}
}
} catch (e) {
// Ignore directories that can't be read
}
}
for (const menuPath of startMenuPaths) {
await findShortcuts(menuPath)
}
// 去重
return [...new Map(appList.map((app) => [app.path, app])).values()]
}源码参考: win.ts
Windows 应用的图标通常内嵌在 .exe 或 .dll 文件中,或以 .ico 文件的形式存在。需要借助原生模块来提取这些图标
实现步骤:
extract-file-icon 这样的 C++ 插件,可以直接从可执行文件中提取图标数据代码实现:
import fs from "fs/promises"
import path from "path"
import os from "os"
import fileIcon from "extract-file-icon"
// 确保图标缓存目录存在
const ICON_CACHE_DIR = path.join(os.tmpdir(), "app-icon-cache")
async function ensureCacheDir() {
try {
await fs.access(ICON_CACHE_DIR)
} catch {
await fs.mkdir(ICON_CACHE_DIR)
}
}
// 获取并缓存应用图标
async function getWindowsAppIcon(appPath, appName) {
await ensureCacheDir()
const iconPath = path.join(ICON_CACHE_DIR, `${appName}.png`)
try {
// 检查缓存
await fs.access(iconPath)
return iconPath
} catch {
// 缓存未命中,提取图标
try {
const buffer = fileIcon(appPath, 64) // size: 64
await fs.writeFile(iconPath, buffer)
return iconPath
} catch (e) {
console.error(`Failed to extract icon for ${appName}:`, e)
return null // or return a path to a default icon
}
}
}注意:
extract-file-icon是原生依赖,需要根据 Electron 版本进行正确的编译 (rebuild)
在 Linux 系统中,应用发现通常通过解析 .desktop 文件来完成。这些文件是遵循 freedesktop.org 标准的桌面条目,充当了应用的快捷方式和元数据中心
.desktop 文件通常分布在以下目录中:
/usr/share/applications/usr/local/share/applications~/.local/share/applications这些文件采用 INI 格式,包含了 [Desktop Entry] 部分,其中定义了应用的各种属性。
关键字段:
Name: 应用名称。Exec: 启动应用的命令。Icon: 图标的名称或路径。NoDisplay: 如果为 true,则表示该应用不应显示在菜单中。Terminal: 是否在终端中运行。实现步骤:
.desktop 文件。[Desktop Entry] 下的键值对。NoDisplay=true 的条目,并对 Exec 命令进行清洗,去除参数(如 %U, %F 等)。Name, Exec, Icon 等关键信息,形成应用列表。代码实现:
import fs from "fs/promises"
import path from "path"
import os from "os"
// 解析 .desktop 文件
async function parseDesktopFile(filePath) {
try {
const content = await fs.readFile(filePath, "utf-8")
const lines = content.split("\n")
const entry = {}
let inDesktopEntry = false
for (const line of lines) {
if (line.trim() === "[Desktop Entry]") {
inDesktopEntry = true
continue
}
if (inDesktopEntry && line.includes("=")) {
const [key, ...valueParts] = line.split("=")
entry[key.trim()] = valueParts.join("=").trim()
}
}
// 过滤无效条目
if (entry.NoDisplay === "true" || !entry.Name || !entry.Exec) {
return null
}
return {
name: entry.Name,
exec: entry.Exec.replace(/%[fFuUdDnNickvm]/g, "").trim(),
icon: entry.Icon || entry.Name.toLowerCase()
}
} catch {
return null
}
}
// 获取 Linux 应用
async function getLinuxApps() {
const appDirs = [
"/usr/share/applications",
"/usr/local/share/applications",
path.join(os.homedir(), ".local", "share", "applications")
]
const apps = []
for (const dir of appDirs) {
try {
const files = await fs.readdir(dir)
for (const file of files) {
if (file.endsWith(".desktop")) {
const appInfo = await parseDesktopFile(path.join(dir, file))
if (appInfo) {
apps.push(appInfo)
}
}
}
} catch {
// Ignore directories that don't exist
}
}
// 去重
return [...new Map(apps.map((app) => [app.name, app])).values()]
}.desktop 文件中的 Icon 字段定义图标的名称,而不是直接的路径。Linux 桌面环境遵循 Icon Theme Specification,会根据图标名称在标准主题目录中搜索对应的图标文件(如 .png, .svg)
图标搜索路径:
/usr/share/icons~/.local/share/icons/usr/share/pixmaps实现步骤:
Icon 字段。hicolor, Adwaita)进行搜索。.png, .svg, .xpm)。代码实现 (简化版):
import fs from "fs/promises"
import path from "path"
const ICON_PATHS = [
"/usr/share/icons/hicolor/scalable/apps",
"/usr/share/icons/hicolor/128x128/apps",
"/usr/share/pixmaps"
]
async function findIconPath(iconName) {
const extensions = [".png", ".svg", ".xpm"]
for (const dir of ICON_PATHS) {
for (const ext of extensions) {
const fullPath = path.join(dir, `${iconName}${ext}`)
try {
await fs.access(fullPath)
return fullPath
} catch {
// Not found, continue
}
}
}
return null // Or a path to a default icon
}这种简化的实现只覆盖了部分常见路径。一个完整的实现需要更全面地遵循图标主题规范,递归搜索不同尺寸和主题的目录
为了方便上层调用,将平台特定的实现封装在统一的 API 后面。该 API 负责根据当前运行的操作系统,调用相应的方法来获取应用列表和图标
无论在哪个平台,检索到的应用信息都将统一为以下 AppInfo 格式:
interface AppInfo {
/**
* 应用名称
*/
name: string
/**
* 应用的可执行文件或包路径
*/
path: string
/**
* 应用图标的本地缓存路径 (PNG 格式)
*/
icon: string
}getApps(): Promise<AppInfo[]>该函数是应用检索的统一入口点。它会异步地获取当前系统上所有已安装的应用,并以 AppInfo[] 的形式返回
Promise<AppInfo[]> - 一个解析为 AppInfo 数组的 Promise。实现逻辑:
该函数内部会通过 process.platform 判断当前操作系统,然后调用对应的平台实现:
getMacApps() 和 app2png()。getWindowsApps() 和 getWindowsAppIcon()。getLinuxApps() 和 findIconPath()。示例:
import { getApps } from "./app-retriever" // 假设统一的 API 在此文件中
async function loadAndDisplayApps() {
try {
console.log("正在检索应用...")
const apps = await getApps()
console.log(`共找到 ${apps.length} 个应用:`)
apps.forEach((app) => {
console.log(`- 名称: ${app.name}`)
console.log(` 路径: ${app.path}`)
console.log(` 图标: ${app.icon}`)
})
} catch (error) {
console.error("检索应用时出错:", error)
}
}
loadAndDisplayApps()指导将应用检索功能集成到你的 Electron 项目中。根据目标平台,可能需要安装以下 npm 包:
plistextract-file-icon (原生模块)npm install plist extract-file-icon注意: extract-file-icon 是一个原生 C++ 插件,安装后需要针对你使用的 Electron 版本进行重新编译。通常,electron-rebuild 工具可以简化这个过程。
npm install --save-dev electron-rebuild
# 在安装或更新 Electron 后运行
./node_modules/.bin/electron-rebuild建议将所有与应用检索相关的代码组织在一个独立的模块中,例如 /src/main/app-retriever
src/
|-- main/
| |-- app-retriever/
| | |-- index.js # 统一的 API 入口 (getApps)
| | |-- macos.js # macOS 实现
| | |-- windows.js # Windows 实现
| | |-- linux.js # Linux 实现
| |-- preload.js
| |-- main.js
|-- renderer/
| |-- app.js
| |-- index.html以下示例展示通过 ipc (进程间通信) 在主进程中检索应用列表,并将其发送到渲染进程进行展示
1. 主进程 (main.js)
在主进程中监听一个 ipc 事件,当收到渲染进程的请求时,调用 getApps 函数,并将结果回传
// src/main/main.js
import { ipcMain } from "electron"
import { getApps } from "./app-retriever" // 引入统一的 API
ipcMain.handle("get-apps", async () => {
const apps = await getApps()
return apps
})2. 预加载脚本 (preload.js)
为了安全地在渲染进程中调用主进程的功能,我们通过 contextBridge 暴露一个接口
// src/main/preload.js
import { contextBridge, ipcRenderer } from "electron"
contextBridge.exposeInMainWorld("electronAPI", {
getApps: () => ipcRenderer.invoke("get-apps")
})3. 渲染进程 (renderer/app.js)
在渲染进程中可以调用 window.electronAPI.getApps() 来获取应用列表,并将其渲染到页面上
// src/renderer/app.js
document.getElementById("load-apps-btn").addEventListener("click", async () => {
const appListElement = document.getElementById("app-list")
appListElement.innerHTML = "<li>正在加载...</li>"
try {
const apps = await window.electronAPI.getApps()
appListElement.innerHTML = apps
.map(
(app) => `
<li>
<img src="${app.icon}" width="32" height="32" />
<span>${app.name}</span>
</li>
`
)
.join("")
} catch (error) {
appListElement.innerHTML = `<li>加载失败: ${error.message}</li>`
}
})HTML (renderer/index.html)
<!DOCTYPE html>
<html>
<head>
<title>应用列表</title>
</head>
<body>
<h1>已安装的应用</h1>
<button id="load-apps-btn">加载应用</button>
<ul id="app-list"></ul>
<script src="app.js"></script>
</body>
</html>应用检索是一个 I/O 密集型操作,如果不进行优化,很容易导致界面卡顿和过度的资源消耗。以下是一些关键的优化策略和最佳实践。
缓存是提升性能最有效的手段。 无论是应用列表还是图标,都应该被缓存。
AppInfo[])以 JSON 格式存储在本地文件系统中(例如,存储在 app.getPath('userData') 目录中)。应用启动时,首先从缓存加载数据,立即呈现给用户,然后可以在后台异步执行一次新的检索,以发现变更。所有文件系统操作、命令执行和图标提取都必须是异步的,以避免阻塞主进程。
async/await: 现代 async/await 语法可以让你用同步的方式编写异步代码,提高可读性。fs 的同步方法(如 readFileSync)或 child_process 的同步方法(如 execSync)。如果应用数量非常多,一次性加载和渲染所有应用可能会对前端性能造成压力。
应用列表不是一成不变的,用户随时可能安装或卸载应用。因此,需要一个智能的刷新机制
/Applications,Windows 的“开始菜单”目录)的变化。当检测到文件系统事件时,触发一次增量更新。这比定时轮询更高效,但实现也更复杂本章节提供在开发和集成应用快速检索功能时可能遇到的常见问题及其解决方案。
A: 这通常与 macOS 的隐私和安全设置有关。
system_profiler SPApplicationsDataType -xml 命令,查看是否有错误输出或权限提示。A: extract-file-icon 是一个原生 Node.js 模块,问题通常出在编译环节。
windows-build-tools 或独立的 Visual Studio C++ 构建工具、Python。electron-rebuild: 每次更新 Electron 版本或 npm install 后,都需要重新编译原生模块以匹配 Electron 的 V8 引擎。运行以下命令:
npm install --save-dev electron-rebuild
npx electron-rebuildgetWindowsAppIcon 函数中 size 参数的设置。某些应用可能不提供所有尺寸的图标,尝试使用不同尺寸(如 32, 64)可能会解决问题。A: 默认实现扫描的是标准 .desktop 文件目录。
.desktop 文件放在其他位置。你可以在配置中增加额外的扫描目录。XDG_DATA_DIRS 环境变量是否包含所有相关的应用目录。我们的脚本会尝试读取此变量以获得更全面的搜索路径。A: 卡顿通常源于同步的 I/O 操作或一次性渲染大量数据。
extract-file-icon)的打包问题?A: 使用 electron-builder 等打包工具时,需要确保原生模块被正确地为目标平台和架构重新编译。
electron-builder 配置: 在 package.json 的 build 配置中,可以设置 npmRebuild: true,但这有时不够可靠。electron-rebuild。一个可靠的构建脚本可能是这样的:
"scripts": {
"rebuild": "electron-rebuild",
"package": "npm run rebuild && electron-builder"
}