目录

第12章实战篇插件的安装发布卸载

## 前言 前面的章节,我们介绍了如何让应用基于 Electron 的 BrowserView 实现插件化能力,但是我们开发出来的插件是希望被更多人下载、使用的。所以,这个章节会详细介绍和实现插件的整个安装、发布、卸载流程。

插件的发布

为了让插件可以被更多人使用,最初也是最重要的一步是先把插件发到云端中心化存储后,这样工具使用者就可以通过云端来下载插件。

常见的插件发布方法都是将插件进行打包,然后推送到 OSS 资源服务器上,比如 uTools 就是将开发好的插件打成 .upx 文件,然后发布到他们的 OSS 资源服务器上。

还有一种插件的发布方式是依托于 npm 包管理器。插件就是 npm 里面的一个包,可以通过 npm 轻松管理插件。npm 相对于 OSS 的插件包发布模式是有很多优势的。

首先就是可以通过切换源来实现自由化部署。

其次,使用 npm 进行安装,也可以利用 npm 包管理机制减少相同版本依赖包的重复安装,进而减少整体的资源体积,而 OSS 打包需要保证一个基础的插件包所有的依赖项都打包进去。

那么,发布这个插件只需要 cd 到该插件目录下,然后执行:

$ npm publish

这里需要注意的是,确保发布的 npm 源是符合预期的,比如我们需要内网发布,那么请确保发布前,npmregistry 是内网的源。你可以通过 npm config get registry 来提前查看。

接下来,我们将详细介绍基于 npm 的插件管理模式,我们认为这是一种可以通用的插件包管理解决方案。

插件的目录结构说明

前面我们提到了插件是托管在 npm 上来管理的,所以我们有必要定义一下一个基础插件包的目录结构。

一个最基础的 plugin 插件结构大致如下:

plugin
|-- index.html
|-- preload.js
└── package.json

1. package.json

package.json 文件是插件项目中用于描述项目元数据的文件。它和普通 npm 包有一样的结构,包含了项目的各种信息,例如项目名称、版本、描述、依赖项等。如下:

// package.json
{
  "name": "plugin-xxx",
  "version": "1.0.0",
  "description": "插件的描述信息",
  "author": "muwoo",
  "dependencies": {
    // ...
  }
}

除此之外,还扩展了一些插件独有的配置项信息:

// package.json
{
  // ...
  // 插件的中文描述名
  "pluginName": "rubick 插件",
  // 插件的入口文件的路径
  "main": "./index.html",
  // 插件的 logo 图标
  "logo": "https://xxx.png",
  // 插件的类型,可选值:ui、system
  "pluginType": "ui",
  // 插件的关键词,比如截图插件
  // 可以定义关键词为:screenCapture、截图、capture 之类的。
  "features": [
    {
      // 关键词解释
      "explain": "打开插件",
       // 关键词定义
      "cmds": [
        "keyword1",
      ]
    }
  ]
}

上面的代码描述了一个 plugin-xxx 插件的加载入口文件是当前目录下的 index.html,可以通过 keyword1 关键词来打开插件。其中,需要注意的是 pluginType 标志着插件的类型是个 ui 类插件,还有一种类型是 system 系统插件,我们下个章节会详细介绍。

2. index.html

index.html 是我们上个小节介绍的通过 BrwoserView.webContentsloadURL 加载入口文件:

import { BrowserView } from 'electron';

const createView = (plugin, window) => {
  const {
    // plugin 的 入口 html 路径
    indexPath,
    // plugin 的预加载脚本路径
    preload,
  } = plugin;
  // 构造 browserView 对象
  view = new BrowserView({
    webPreferences: {
      // ...
      // 加载 preload.js
      preload, 
    },
  });
  // 挂载 browserView 到 browserWindow 对象上
  window.setBrowserView(view);
  // browserView 中加载插件入口 html
  view.webContents.loadURL(indexPath);
  // ...
}

一个简单的 index.html 文件如下:

// index.html
<!DOCTYPE html>
<html>
<body>
  hello world
</body>
<script>
  // 调用 全局 API
  window.rubick.showNotification('hello world');
  // 调用 插件 API
  window.pluginAPI.sayHi();
</script>
</html>

3. preload.js

这个 preload.js 是插件的预加载脚本文件,在 BrwoserView 实例化的时候,传入到 webPreferences 字段内。可以在此文件内调用 Rubick、 nodejs、 electron 提供的 api。

// proload.js
import { contextBridge } from 'electron';

// 定义 plugin 的 API
const pluginAPI = {
  sayHi() {
    // 这里可以调用 Rubick、 nodejs、 electron 提供的 api
    console.log('hello world');
  },
}

window.pluginAPI = pluginAPI;

插件的安装、更新

既然是基于 npm 的管理模式,那么相信大多数小伙伴都会先想到安装一个插件那就太简单了,直接使用 npm install xxx 即可。但是,我们是一个 Electron 应用,Electron 如何直接执行 npm install 命令呢?回顾一下前面的知识,我们说到过 Electron 是可以通过 node 来调用 Shell 脚本的。

所以,我们可以直接使用 cross-spawn 模块来执行 npm install 命令来安装特定插件模块:

import spawn from 'cross-spawn';
// cmd 代表的是 npm 需要执行的命令,比如 install
// modules 表示的是 npm 需要安装的插件,比如 ['pluginA']
private async execCommand(cmd, modules) {
  return new Promise((resolve, reject) => {
    // 构造 spawn 执行脚本参数
    let args = [cmd].concat(
      cmd !== 'uninstall' && cmd !== 'link'
        ? modules.map((m) => `${m}@latest`)
        : modules
    );
    // 不是 link 模式,指定安装源
    if (cmd !== 'link') {
      args = args
        .concat('--color=always')
        .concat('--save')
        .concat(`--registry=${this.registry}`);
    }
    // 执行 npm 脚本
    const npm = spawn('npm', args, {
      cwd: this.baseDir,
    });

    let output = '';
    npm.stdout
      .on('data', (data: string) => {
        // 获取输出日志
        output += data; 
      })
      .pipe(process.stdout);

    npm.stderr
      .on('data', (data: string) => {
        // 获取报错日志
        output += data; 
      })
      .pipe(process.stderr);

    npm.on('close', (code: number) => {
      if (!code) {
        // 如果没有报错就输出正常日志
        resolve({ code: 0, data: output }); 
      } else {
        // 如果报错就输出报错日志
        reject({ code: code, data: output });
      }
    });
  });
}

接下来,如果需要安装 pluginA 插件,那么只需要执行:execCommand('install', ['pluginA'])

这里有几点需要注意的是:

  1. 需要通过 this.baseDir 指定插件安装的目录,这样方便对插件统一管理。
  2. 需要通过 this.registry 指定插件安装的源,这样方便对插件源做统一管理,也方便切换安装源。

如果你通过上面代码实现,而且你本地也有 node.js 环境,那么一切很美好。但是,这里有一个比较大的问题就在于如果使用你工具箱的用户他自己电脑上没有 node 环境,那么执行 spwan('npm') 的时候,都会报错:

image.png

原因就是因为没有装 node 环境的用户电脑上是没有 npm 的,所以解决方案也很简单,那就是想办法把 npm 集成到用户电脑上,这样就不需要提前装 node 环境了。要集成 npm 也有一个办法,就是通过编程式的方式使用 npm 脚本,举个例子:

import npm from 'npm';

npm.commands.install(module, callback);

如果你直接安装 npm 最新版本,你会发现,npm 并不支持这样使用😂,其实,通过编程式的方式使用 npm 在早期的 npm 版本中是支持,但是后来被移除了,官方解释是:

Although npm can be used programmatically, its API is meant for use by the CLI only, and no guarantees are made regarding its fitness for any other purpose. If you want to use npm to reliably perform some task, the safest thing to do is to invoke the desired npm command with appropriate arguments.

The semantic version of npm refers to the CLI itself, rather than the underlying API. The internal API is not guaranteed to remain stable even when npm’s version indicates no breaking changes have been made according to semver.

总结而言就是:目前 npm 推荐使用 CLI 的方式进行使用,编程式 npm 不稳定,不能保障其运行的可靠性。官网提供了一个使用编程式 npm 的示例:

var npm = require('npm')
npm.load(myConfigObject, function (er) {
  if (er) return handlError(er)
  npm.commands.install(['some', 'args'], function (er, data) {
    if (er) return commandFailed(er)
    // command succeeded, and data might have some info
  })
  npm.registry.log.on('log', function (message) { ... })
})

然后去翻阅 npm 的发布记录,大约在 npm v7 以后,编程式 npm 就开始有各种各样的问题,后续就开始被移除了。所以,我们选择内置的 npm 版本是 v6.14.7。然后,修改我们插件安装的代码:

private async execCommand(cmd, modules) {
  return new Promise((resolve, reject) => {
    // 构造安装包 string
    const module =
      cmd !== 'uninstall' && cmd !== 'link'
        ? modules.map((m) => `${m}@latest`)
        : modules;
    // npm config 设置    
    const config = {
      // 安装目录
      prefix: this.baseDir,
      // --save
      save: true,
      // 启用缓存目录
      cache: path.join(this.baseDir, 'cache'),
    };
    if (cmd !== 'link') {
      config.registry = this.registry;
    }
    // 初始化 npm 配置项
    npm.load(config, function (err) {
      // 调用 cmd 脚本
      npm.commands[cmd](module, function (er, data) {
        if (!err) {
          console.log(data);
          resolve({ code: -1, data });
        } else {
          reject({ code: -1, data: err });
        }
      });

      npm.on('log', function (message) {
        // log installation progress
        console.log(message);
      });
    });
  });
}

这样,我们便实现了插件的安装功能,而且不依赖于用户系统的 npm 命令。

插件的更新、卸载

有了上面的知识接下来,就可以基于 npm 顺便实现插件的更新、卸载能力了。

1. 更新

要更新一个 npm 包,也可以通过 npm install xxx@latest 命令来实现安装最新插件,但前提是需要检测插件是否需要更新:

async upgrade(name) {
  // 找到 node_modules 的 package.json 文件
  const packageJSON = JSON.parse(
    fs.readFileSync(`${this.baseDir}/package.json`, 'utf-8')
  );
  // npm 源上的插件
  const registryUrl = `${this.registry}${name}`;

  try {
    // 获取当前安装插件的版本
    const installedVersion = packageJSON.dependencies[name].replace('^', '');
    // 获取 npm 源上最新的版本号
    const { data } = await axios.get(registryUrl, { timeout: 2000 });
    const latestVersion = data['dist-tags'].latest;
    // 版本号比较,落后了就更新
    if (latestVersion > installedVersion) {
      this.execCommand('install', [name])
    }
  } catch (e) {
    // ...
  }
}

这里,我们通过比对 npm 源上的插件版本和本地的插件版本来实现检测插件是否有新版的功能,如果有,则再通过 npm install xxx@latest 的方式安装最新版。

2. 卸载

卸载插件也是直接调用 npm uninstall xxx 脚本来实现:

async uninstall(adapters, options) {
  const installCmd = options.isDev ? 'unlink' : 'uninstall';
  // 卸载插件
  await this.execCommand(installCmd, adapters);
}

代码很简单,就是调用 this.execCommand 函数来执行 npm 脚本。

需要注意的是,不管是插件的安装还是卸载,都需要注意本地调试环境,因为我们是基于 npm 来实现的插件管理机制,所以调试插件亦可以通过 npm link 的方式来调试本地插件。所以插件在安装和卸载时,都会进行调试环境的区分。

完整的插件管理代码见:https://github.com/rubickCenter/rubick/blob/master/src/core/plugin-handler/index.ts

总结

本小节我们介绍了如何基于 npm 来实现插件的安装、发布、更新、卸载。因为依托于 npm 使得我们管理插件非常方便,而且可以通过切换不同源来实现环境隔离。

但是,前面的章节,我们介绍了这些插件通常是通过 BrowserView 来加载在渲染进程中的,它们就像一个个浏览器窗口,当关闭这些窗口时,为了减少内存占用渲染进程中的插件就会被销毁。

const removeView = (window: BrowserWindow) => {
  if (!view) return;
  // 窗口中删除 BrowserView 对象
  window.removeBrowserView(view);
  window.setBrowserView(null);
  // 销毁 BrowserView 的 webContents 避免过多内存占用
  view.webContents?.destroy();
  view = undefined;
};

因此,如果你想开发一些需要一直活跃在主进程中的插件,跟随主进程启动、退出,比如:备忘录插件有一个定时提醒功能,这就需要关闭插件后,到了时间依旧可以触发提醒。

对于以上功能的实现,我们称之为 “系统插件”,接下来,我们再详细介绍一下系统插件的实现方式。