{T}

实现应用快速检索

## 前言 Electron 虽然本身依托于 Chromium + Nodejs 具有天然的跨平台能力,但是对于一些和操作系统相关的应用检索能力还是需要自己来分别处理。主要是因为不同类型的操作系统安装应用获取的方式和返回的内容并不相同。

考虑到大多数用户都是 WindowsMacOS 操作系统,本小节,我们将通过介绍在 MacOS 以及 Windows 平台下,如何来实现系统级别的应用检索。

如果你对 Linux 的实现方式感兴趣,可以直接参考我们的源码实现:https://github.com/rubickCenter/rubick/blob/master/src/core/app-search/linux.ts

MacOS 实现

先来看一下实现的效果:

1. 获取 macOS 下安装了哪些应用

要在 macOS 下获取安装的应用,其实可以直接使用系统配置概要工具(system_profiler)来实现。

system_profiler 是 macOS 系统中的命令行工具,用于获取系统的各种硬件和软件配置信息。它能够提供关于电脑硬件、网络、软件以及许多其他系统组件的详尽信息。

一些常用的参数如下:

  • -xml:以 XML 格式输出信息。
  • -detailLevel:控制信息的详细程度,可设置为 basicminifull
  • -listDataTypes:列出可用的数据类型。
  • -help:显示帮助信息,列出可用参数和选项。
  • -timeout:设置超时时间,限制信息的收集时间。
  • -nospin:在收集信息时禁用硬盘旋转。

比如,获取系统中安装了哪些应用,就可以直接使用命令行:

shell
$ /usr/sbin/system_profiler -xml -detailLevel mini SPApplicationsDataType

其中,使用了 -xml 参数来指示输出以 XML 格式呈现,-detailLevel mini 表示只显示最小级别的详细信息,SPApplicationsDataType 则是参数,用于指示 system_profiler 获取并显示关于已安装应用程序的数据类型。

注意,最终输出的格式是一个 XML 的内容:

XML
<dict>
  <key>_name</key>
  <string>钉钉</string>
  <key>arch_kind</key>
  <string>arch_arm_i64</string>
  <key>lastModified</key>
  <date>2023-11-28T01:44:02Z</date>
  <key>obtained_from</key>
  <string>mac_app_store</string>
  <key>path</key>
  <string>/Applications/DingTalk.app</string>
  <key>signed_by</key>
  <array>
    <string>Apple Mac OS Application Signing</string>
    <string>Apple Worldwide Developer Relations Certification Authority</string>
    <string>Apple Root CA</string>
  </array>
  <key>version</key>
  <string>7.1.6</string>
</dict>

写到这里,你或许就明白了如何获取到 macOS 中已安装的应用了:

  1. 使用 node shell 脚本调用 system_profiler 命令;
  2. 获取到 XML 数据后,通过 plist 解析 XML;
  3. 返回解析后的数据。

对应到具体的代码实现:

js
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);
  });
  // 出错后抛出
  profileInstalledApps.on('error', (err) => {
    reject(err);
  });
}

对应 rubick 具体源码位置:https://github.com/rubickCenter/rubick/blob/master/src/core/app-search/get-mac-app/getApps.ts

2. 获取安装应用的图标

第一步我们只是实现了获取当前 macOS 中安装了哪些应用,但是需要注意的是通过 system_profiler 命令获取到的应用列表并不包含应用的图标信息,因此我们需要自己再解析出应用的图标。

我们知道,对于 macOS 中的应用,其图标都是 xxx.icnsxxx.tiff 格式的。.tiff.icns 类似,接下来的内容,我们将以 .icns 图标作为示例,解释如何获取应用的图标。

对于 Electron 而言,是不支持直接展示 .icons 图标的。最好是可以转成 .png 或者 base64 的格式。但这里有 2 个问题需要解决:

  1. 如何找到 .icons 文件在哪里?
  2. 如何转换 .icons 图标成 .png 图标?

首先,先说第一个问题,对于 macOS 下的应用,它的应用 Contens 目录下总会有一个 Info.plist 文件,Info.plist 是 macOS 上的一个特殊文件,用于存储应用程序的配置和元数据。这个文件采用 XML 格式,包含了应用程序的各种信息,例如应用名称、版本号、图标文件名、支持的文件类型、所需权限等等。

钉钉 举例,钉钉 的文件目录是:

bash
/Applications/DingTalk.app/Contents

它包含的结构如下:

里面有个 Info.plist 文件,我们可以简单打开看看它的一些信息:

XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
 <!--...-->
 <key>CFBundleExecutable</key>
 <string>DingTalk</string>
 <key>CFBundleIconFile</key>
 <string>AppIcon</string>
 <!--...-->
</dict>
</plist>

这里,我们选取了部分信息作为展示,可以看到有一个 CFBundleIconFile 字段标记的图标名称,对于 钉钉 来说,那么就是 AppIcon.icns

找到图标名称后,接下来就是需要对图标格式进行转换,在 macOS 中,我们可以使用 sips 命令来处理图像。

sips 是 macOS 系统中的一个命令行工具,用于对图像进行处理和操作。该命令的全称是 "Scriptable Image Processing System",它允许用户通过命令行对图像文件进行各种操作,包括格式转换、大小调整、颜色管理等。

这个工具在命令行中使用,通过提供参数和选项来执行不同的操作。例如:

shell
$ sips -s format png input.jpg --out output.png

这样的命令可以将 input.jpg 文件转换为 PNG 格式并输出为 output.png。有了上面的这些知识,那么我们可以轻松地把 .icns 图标转成 .png 图标:

shell
$ sips -s format png '${iconFile}' --out '${pngFileOutput}' --resampleHeightWidth 64 64
  • -s format png 表示指定输出格式为 PNG。
  • ${iconFile} 是一个变量,它包含图标文件的路径。
  • --out '${pngFileOutput}' 表示输出路径为 ${pngFileOutput} 这个变量中指定的路径。
  • --resampleHeightWidth 64 64 表示对图像进行重采样,将其调整为高度和宽度均为 64 像素的尺寸。

这里我们使用了 resampleHeightWidth 参数重新调整了图像尺寸,因为在搜索列表中,我们只需要用到这个尺寸的图标就好了。

接下来,一起看看完整代码:

js
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;

对应 rubick 具体源码位置:https://github.com/rubickCenter/rubick/blob/master/src/core/app-search/get-mac-app/app2png.ts

Windows 实现

1. 获取 Windows 下安装了哪些应用

1.1 通过注册表来获取

windows 下,要获取当前系统安装了哪些应用,有一种比较直接的方法,就是通过注册表的卸载目录来查看:

可以看到,注册表 Uninstall 中展示出了 DisplayIconDisplayName 字段,分别用于表示应用的 icon 路径和展示名称。windows 下,注册表卸载应用的目录一般是:

bash
# 用户级别
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall
HKCU\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall
# 系统级别
HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall
HKLM\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall

因此,我们可以通过 get-installed-apps 来实现对注册表内容的读操作:

js
import {getInstalledApps} from 'get-installed-apps'

getInstalledApps().then(apps => {
  console.log(apps)
})

可以获取到的格式大致如下:

js
[
  {
    appIdentifier: '8775235d-add8-5501-961c-495d96145e7e',
    DisplayName: 'rubick 4.0.10',
    appName: 'rubick 4.0.10',
    UninstallString:
      '"C:\Program Files\rubick2\Uninstall rubick.exe" /allusers',
    QuietUninstallString:
      '"C:\Program Files\rubick2\Uninstall rubick.exe" /allusers /S',
    DisplayVersion: '4.0.10',
    appVersion: '4.0.10',
    DisplayIcon: 'C:\Program Files\rubick2\rubick.exe,0',
  },
  {
    appIdentifier: 'Git_is1',
    InstallLocation: 'C:\Program Files\Git\',
    DisplayName: 'Git',
    appName: 'Git',
    DisplayIcon:
      'C:\Program Files\Git\mingw64\share\git\git-for-windows.ico',
    UninstallString: '"C:\Program Files\Git\unins000.exe"',
    QuietUninstallString: '"C:\Program Files\Git\unins000.exe" /SILENT',
    DisplayVersion: '2.40.1',
    appVersion: '2.40.1',
    Publisher: 'The Git Development Community',
    appPublisher: 'The Git Development Community',
    URLInfoAbout: 'https://gitforwindows.org/',
    HelpLink: 'https://github.com/git-for-windows/git/wiki/Contact',
  },
];

这里有几个问题:

  1. 注册表获取的内容格式并不是完全统一的,有的不存在 DisplayIcon,有的内容和格式不正确。
  2. 大多数应用程序会将它们的卸载条目添加到 HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall\${appName},但并非所有应用程序都会这样做。
  3. 一些卸载不干净的软件可能也会被包含,比如一些曾经安装过的程序,然后被手动删除了,但是没有删除注册表里面的内容。

1.2 通过快捷方式获取

我们知道,在 Windows 中,软件一般都会有一些快捷方式,这些快捷方式都存储在一些特定的目录下,比如:

bash
C:\ProgramData\Microsoft\Windows\Start Menu\Programs
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Accessories

这两个目录通常包含了系统默认安装的程序的快捷方式,以及用户安装的一些应用程序的快捷方式。用户可以通过这个目录中的程序快捷方式在开始菜单中直接找到并运行他们安装的程序。

还有:

bash
C:\Users\xxx\AppData\Roaming\Microsoft\Windows\Start Menu\Programs

这个路径位于特定用户的个人文件夹中(例如,C:\Users\xxx),而不是系统级别的路径。

所以,我们可以通过获取应用程序快捷方式的做法,获取到当前系统中安装的软件:

js
import { shell } from 'electron';
// app 列表
const appList = [];
const appData = path.join(os.homedir(), './AppData/Roaming');

// 系统快捷方式
const systemShortCut = path.resolve('C:\ProgramData\Microsoft\Windows\Start Menu\Programs');
const originShortCut = path.resolve('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Accessories');
// 用户级别快捷方式
const userShortCut = path.join(appData, 'Microsoft\Windows\Start Menu\Programs');

function fileDisplay(filePath) {
  //根据文件路径读取文件,返回文件列表
  fs.readdir(filePath, function (err, files) {
    if (err) {
      console.warn(err);
    } else {
      files.forEach(function (filename) {
        const filedir = path.join(filePath, filename);
        fs.stat(filedir, function (eror, stats) {
          if (eror) {
            console.warn('获取文件stats失败');
          } else {
            // 是文件
            const isFile = stats.isFile();
            // 是文件夹
            const isDir = stats.isDirectory(); 
            if (isFile) {
              // 找到 appName
              const appName = filename.split('.')[0];
              const keyWords = [appName];
              let appDetail = {};
              try {
                // 通过 shell.readShortcutLink 获取 快捷方式 信息
                appDetail = shell.readShortcutLink(filedir);
              } catch (e) {
                //
              }
              // 如果获取不到简介信息,则丢弃
              if (
                !appDetail.target ||
                appDetail.target.toLowerCase().indexOf('unin') >= 0
              )
                return;
   
              const appInfo = {
                desc: appDetail.target,
                type: 'app',
                name: appName,
                // ...
              };
              appList.push(appInfo);
            }
            if (isDir) {
              // 递归,如果是文件夹,就继续遍历该文件夹下面的文件
              fileDisplay(filedir); 
            }
          }
        });
      });
    }
  });
}
[systemShortCut, originShortCut, userShortCut].forEach(appPath => {
  fileDisplay(appPath);
});

这里就是简单的文件读操作,不断地写入到 appList 数组中。不过需要注意的是找到的都是一些快捷键的路径,并不代表真实的软件安装路径,所以为了获取到软件的真实安装路径,需要使用 Electron 的 shell 模块来对快捷方式进行解析:

js
shell.readShortcutLink(shortcutPath)
// => output 
// {
//    target: 'C://xxx/xxx/appName.exe'
//    // ...
// }

完整代码可以查看 rubick 中的实现:https://github.com/rubickCenter/rubick/blob/master/src/core/app-search/win.ts

2. 获取安装应用的图标

要想获取到 Windows 应用安装的图标,需要用到一个 C++ 原生库:extract-file-icon。这个库可以把一些 .exe 文件的 app 中的图标提取出来:

js
const fileIcon = require("extract-file-icon");
// path 代表的就是 .exe 应用程序的路径
const icon = fileIcon('path', 32);

因此,我们可以在软件初次启动的时候将应用图标获取到,并写入到一个缓存目录,后续启动直接从缓存目录中加载图标即可:

js
import os from 'os';
// 创建一个 icon 临时目录
const icondir = path.join(os.tmpdir(), 'ProcessIcon');

const getico = (app) => {
  try {
    const fileIcon = require('extract-file-icon');
    const buffer = fileIcon(app.desc, 32);
    const iconpath = path.join(icondir, `${app.name}.png`);
     
    fs.exists(iconpath, (exists) => {
      // 如果临时目录中不存在 icon,则提取
      if (!exists) {
        fs.writeFile(iconpath, buffer, 'base64', () => {});
      }
    });
  } catch (e) {
    console.log(e, app.desc);
  }
};

总结

Electron 跨平台能力主要是指其渲染进程依托于 Chromium 来磨平 UI 渲染层的差异,以及其提供的一些兼容性 Native APIs。一旦涉及到操作系统层面的交互,因为操作系统底层设计的不同,必然需要我们来仔细处理兼容性方案。

另外,本小节所有的实现都是针对没有 C++ 经验的小伙伴而言来实现的,更加方便小伙伴阅读和修改。如果你是个富有 C++ 编程经验的人,欢迎提交 C++ 侧的实现!