目录

第13章实战篇系统插件的加载和取色插件的开发

## 前言 在 Rubick 插件系统中,插件主要分为两大类,一类是 UI 类插件,这类插件是通过 BrwoserView 进行加载,因为通过 BrowserView 加载,所以通常会有视图 UI 界面,插件销毁后,不会驻留在内存中。还有一类是系统类插件,这类插件是会常驻在 Rubick 运行时中的,可以是一段 JavaScript 代码片段,会伴随着 Rubick 特定运行状态钩子而启动运行。

在章节《实战篇:插件的安装、发布、卸载》中,我们介绍了插件安装的方法;在《实战篇:如何支持工具插件化》中,我们介绍了如何加载安装好的 UI 类插件。

接下来,我们将接着介绍如何加载安装好的系统类插件,然后再教大家编写一个简单的 UI 类插件和系统类插件。

系统类插件的目录结构说明

前面我们介绍了 UI 类插件的目录结构,接下来,我们再看一下最简单的系统类插件目录结构:

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

1. package.json

和之前 UI 类插件字段是一样的,区别是需要额外提供一个 entry 字段来标记入口 js,以及 pluginType 字段为 system 来标记是一个系统类插件:

{
  "pluginType": "system",
  "entry": "index.js"
}

2. index.js

系统插件的入口 js 文件,通过自定义一些系统插件钩子函数,来实现代码片段注入的能力:

// index.js
module.exports = () => {
  return {
    // 定义 beforeReady 钩子函数
    beforeReady() {
      // ...
    }
    // 定义 onReady 钩子函数
    onReady(ctx) {
      // ...
    },
    // 定义 onRunning 钩子函数
    onRunning(ctx) {
      // ...
    },
    // 定义 onQuit 钩子函数
    onQuit() {
      // ...
    }
 }
}

加载系统类插件

我们知道,Electron 运行时主要是通过 app 对象来完成对应用生命周期的监听和处理,我们想要实现系统插件,就是希望可以注入到这些生命周期中运行的代码段。因此,我们可以把 app 的启动类比成 Vue 的生命周期那样设置几个关键阶段:

image.png

  1. beforeReady:Electron App 启动前的准备工作,执行在 Electron 钩子函数 app.on('ready') 之前。这里会加载执行系统插件的 onBeforeReady 钩子函数。
  2. onReady:Electron app.on('ready') 函数执行期,会做一些更新检测、创建系统菜单、创建主窗口等操作。这里会加载执行系统插件的 onReady 钩子函数。
  3. onRunning:这里会处理 Electron 的 app.on('second-instance') 钩子函数和 app.on('activate') 钩子函数。这里会加载执行系统插件的 onRuning 钩子函数。
  4. onQuit:这里会处理 Electron 的 app.on('window-all-closed') 钩子函数和 app.on('will-quit') 钩子函数。这里会加载执行系统插件的 onQuit 钩子函数。

所以,我们需要对入口 main/index.js 文件做一下改造:

import electron, { app, protocol } from 'electron';

class App {
  constructor() {
    // 注册协议
    protocol.registerSchemesAsPrivileged([
      { scheme: 'app', privileges: { secure: true, standard: true } },
    ]);
    // 处理多应用实例
    const gotTheLock = app.requestSingleInstanceLock();
    if (!gotTheLock) {
      app.quit();
    } else {
      // 注册系统插件
      this.systemPlugins = registerSystemPlugin();
      // 注册生命周期
      this.beforeReady();
      this.onReady();
      this.onRunning();
      this.onQuit();
    }
  }
  beforeReady() {
    // ...
    // 触发 onBeforeReady
    this.systemPlugins.triggerBeforeReadyHooks()
  }

  createWindow() {
    this.windowCreator.init();
  }
  
  onReady() {
    const readyFunction = async () => {
      // ...
      // 触发 onReady
      this.systemPlugins.triggerReadyHooks();
    };
    if (!app.isReady()) {
      app.on('ready', readyFunction);
    } else {
      readyFunction();
    }
  }

  onRunning() {
    app.on('second-instance', (event, commandLine, workingDirectory) => {
      // ...
      if (win) {
        if (win.isMinimized()) {
          win.restore();
        }
        win.focus();
      }
    });
    app.on('activate', () => {
      // ...
    });
    // 触发 onRunning
    this.systemPlugins.triggerOnRunningHooks();
  }

  onQuit() {
    // ...
    app.on('window-all-closed', () => {
      if (process.platform !== 'darwin') {
        app.quit();
      }
    });

    app.on('will-quit', () => {
      // ...
      // 触发 OnQuit
      this.systemPlugins.triggerOnQuitHooks();
    });
  }
}

export default new App();

上面代码也很简单,我们可以详细介绍一下:

  1. 首先通过一个 App 类来定义了四个钩子函数,并通过 constructor 来实现对钩子函数的注册调用。
  2. constructor 中,通过 registerSystemPlugin 函数完成对系统插件的注册,并赋值给了 this.systemPlugins
  3. 监听 app 阶段性钩子函数,并通过 this.systemPlugins.triggerXXXHooks 完成对系统插件钩子函数的触发调用。

接下来,我们以实现 triggerXXXHooks 来作为示例,来说明一个插件入口 js 程序中的 onReady 钩子是如何被执行的:

// registerSystemPlugin.js
import path from 'path';
import fs from 'fs';
// 插件通过 npm 安装下载的路径
import { PLUGIN_INSTALL_DIR } from '@/common/constans/main';

const registerSystemPlugin = () => {
  // ...
  // 通过 pluginType 从所有插件 totalPlugins 中过滤出系统插件
  let systemPlugins = totalPlugins.filter(
    (plugin) => plugin.pluginType === 'system'
  );
  // 处理插件的 entry 路径,由相对路径转为绝对路径 
  systemPlugins = systemPlugins
    .map((plugin) => {
      try {
        const pluginPath = path.resolve(
          PLUGIN_INSTALL_DIR,
          'node_modules',
          plugin.name
        );
        return {
          ...plugin,
          indexPath: path.join(pluginPath, './', plugin.entry),
        };
      } catch (e) {
        return false;
      }
    })
    .filter(Boolean);
  
  // 定义插件的所有钩子函数
  const hooks = {
    onReady: [],
  };

  // 收集所有系统插件的 onReady 钩子函数
  systemPlugins.forEach((plugin) => {
    if (fs.existsSync(plugin.indexPath)) {
      const pluginModule = __non_webpack_require__(plugin.indexPath)();
      hooks.onReady.push(pluginModule.onReady);
    }
  });
  
  // 定义触发所有插件的 onReady 钩子
  const triggerReadyHooks = (ctx) => {
    hooks.onReady.forEach((hook: any) => {
      try {
        hook && hook(ctx);
      } catch (e) {
        console.log(e);
      }
    });
  };

  return {
    triggerReadyHooks,
  };
};

上述代码中,首先通过 pluginType = system 这个条件来过滤出所有的系统插件,因为插件中的 package.json 定义的 entry 入口文件是一个相对路径,所以我们将其处理成了一个绝对路径 indexPath。接下来就是通过 __non_webpack_require__ 函数动态引用入口 index.js 文件,并把其中注册的 onReady 钩子函数派发到 hooks 中。然后包装成一个 triggerReadyHooks 函数在特定时机触发。

__non_webpack_require__ 是 Node.js 中的一个全局变量,它是为了避免 Webpack 对 require 函数的处理而引入的。

在 Webpack 打包的过程中,它会把所有的 require 函数替换成一些用于模块加载的特殊函数,这可能导致某些情况下无法直接使用原生的 Node.js 模块加载方式。为了绕过这种替换,可以使用 __non_webpack_require__ 来调用原生的 require 函数。

当你使用 Webpack 打包的代码中需要引入 Node.js 模块时,可以使用 __non_webpack_require__,这样就可以确保使用原生的 Node.js 的模块加载机制而不受 Webpack 的影响。

插件的开发

前面我们已经分别介绍了关于 UI 类插件系统类插件的最基础的目录结构。但是开发过程中,使用最基础的目录结构肯定是不行的,为了界面样式的统一和美观我们可能会使用一些组件库,为了提升开发效率,我们也可能会使用一些开发框架,比如 VueReact

因此,这里我们将以 Vue 3 + antdv 作为基础依赖,实现一个桌面取色器插件。

1. 初始化工程

我们使用 vueCli 来创建一个插件应用:

$ vue create rubick-plugin-colorpicker

得到的目录结构如下:

.
├── README.md
├── babel.config.js
├── jsconfig.json
├── package.json
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   └── main.js
├── vue.config.js
└── yarn.lock

vueCli 在执行 npm run build 命令构建后,会把 public 目录下的文件拷贝到 dist 目录,所以我们可以在 public 目录下新建一个 preload.js 和一个 package.json,调整后的 public 目录结构如下:

public
├── favicon.ico
├── index.html
├── package.json
└── preload.js

然后我们给 package.json 文件加入以下内容:

{
  "name": "rubick-plugin-colorpicker",
  "pluginName": "取色器",
  "version": "1.0.0",
  "description": "取色器",
  "main": "index.html",
  "preload": "preload.js",
  "logo": "https://pic1.zhimg.com/80/v2-5f1810a71af6eefcd77edbbf07ea1cc7_720w.png",
  "pluginType": "ui",
  "features": [
    {
      "code": "colorpicker",
      "explain": "取色器",
      "cmds": [
        "colorpicker",
        "qs",
        "取色"
      ]
    }
  ]
}

因为插件是以文件方式引用资源,所以需要调整一下 vue.confg.js 设置 publicPath 的路径:

// vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  // ...
  publicPath: process.env.NODE_ENV === "production" ? "" : "/",
})

我们开始构建这个项目:

$ npm run build

构建完成后,看一下 dist 目录结构:

dist
├── css
│   └── app.2cf79ad6.css
├── favicon.ico
├── index.html
├── js
│   ├── app.42798930.js
│   ├── app.42798930.js.map
│   ├── chunk-vendors.4114fffc.js
│   └── chunk-vendors.4114fffc.js.map
├── package.json
└── preload.js

这个就是我们后面需要发布的 npm 插件包。

2. 调试插件

因为我们的插件是基于 npm 的,所以可以通过 npm link 的方式进行插件调试,在 dist 目录下,执行:

$ npm link

然后在 rubick 中通过路径的方式安装这个插件:

image.png

安装的本质就是 npm link rubick-plugin-colorpicker。安装完成后,我们就可以通过搜索框输入关键词:colorpickerqs取色 来进行唤起插件。

image.png

但这样有一个问题就是每次无法热更新,每次修改代码都需要进行 npm run build 后才能显示结果,因此,需要在插件的 package.json 中添加一个 development 字段来标记开发环境的前端入口地址:

// public/package.json
{
  "development": "http://localhost:8080",
}

这样,每次更新代码,也会热更新插件页面。

3. 功能完善

插件页面已经搭建好了,接下来要实现的就是屏幕取色的能力,这里我们可以直接使用 electron-color-picker 这个开源库,接下来,需要在 public 目录下安装这个依赖库:

$ npm install electron-color-picker

然后在 preload.js 中使用这个库:

const {
  getColorHexRGB,
  darwinGetScreenPermissionGranted,
  darwinRequestScreenPermissionPopup
} = require('electron-color-picker');
const os = require("os");

const isDarwin = os.platform === 'darwin';

window.colorpicker = async () => {
  try {
    window.rubick.hideMainWindow();
    // 如果是 macOS 需要检测屏幕录制权限
    if (isDarwin) {
      const permission = await darwinGetScreenPermissionGranted();
      if (!permission) {
        return darwinRequestScreenPermissionPopup();
      }
    }
    
    // 调用 color picker 来取色
    const result = await getColorHexRGB();
    // 取色成功写入剪贴板,然后并展示系统通知
    if (result) {
      window.rubick.copyText(result);
      window.rubick.showNotification(`${result}, 取色成功!已复制剪切板`);
    }
  } catch (e) {
    console.log(e);
  }
}

然后我们在前端页面中加一个取色按钮,点击后,触发取色功能:

<!-- App.vue -->
<template>
  <button @click="window.colorpicker">取色</button>
</template>

点击按钮后,就会出现取色的功能:

image.png

接下来,如果你想发布你的插件,那么只需要在构建完成后,在 dist 目录下执行:

$ npm publish

这样就可以完成对插件的发布能力。

取色插件完整代码

总结

本小节,我们详细介绍了关于系统插件的加载实现过程,以及基于 vueCli 完成了一个屏幕取色插件的开发。细心的同学可能留意到这是个 UI 类型的插件。后面,我们将通过 《实现超级面板插件》 的实战章节,讲解如何开发一个好用的系统插件。