工程化

安全性

在这里讨论安全性,我们主要分为两种,一种是加载应用内资源,一种是加载远程资源。

对于加载应用内资源代码我们是可控的,需要注意的安全问题和通常的网站差不多,防范XSS攻击等。

而很多情况下,在我们的应用中可能需要加载远程站点(合作网站、用户发送的链接,由第三方开发的小程序等),对于这些很多场景下不可控的代码资源,如果直接给他们所有的访问权限,后果将不可控,因此我们着重讨论加载远程资源可能的问题以及解决方法。

用户发送的链接

用户发送的内容并不可控,在这里我们需要多重拦截做保障。

后端维护站点黑名单

由后端维护一个站点黑名单,用户发送的每个链接在打开前都请求一次接口,判断到如果是色情网站钓鱼网站等不合法站点,直接提醒用户不要打开。

通过系统浏览器打开

可以通过shell.openExternal通过浏览器打开第三方站点

1
2
3
4
// @@code-renderer:runner
// @@code-props: { height: '120px', hideRight: true }
const { shell } = require('electron')
shell.openExternal('https://github.com')

但是使用这个方法需要注意两点:

1.openExternal不只能打开http链接

openExternal方法是是通过系统的默认应用打开给定的外部协议URL,比如说打开下面这段url会打开邮件应用写邮件,非http协议的链接只要有对应的处理程序都会打开。

1
2
3
4
// @@code-renderer:runner
// @@code-props: { height: '120px', hideRight: true }
const { shell } = require('electron')
shell.openExternal('mailto:somebody@gmail.com?subject=I love you')

使用此API时需要注意先判断URL地址的协议。

2.打开http链接仍应先判断风险

即使是http协议,通过浏览器打开前,最好也能先判断是否有风险,否则相当于将网站的安全性判断责任转嫁给了浏览器,用户仍然有不小心打开钓鱼网站的风险。

通过webPreferences限制API访问

如果需要在Electron应用中打开无法确定安全性的链接,我们可以对打开该链接的窗口设置更为严格的webPreferences选项以限制其能访问到的api和内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({webPreferences: {
// 是否集成node环境
nodeIntegration: false,
// 在沙盒中运行渲染进程
sandbox: true,
// 是否启用remote模块
enableRemoteModule: false,
// 启用同源策略
webSecurity: true,
// 是否允许运行http协议加载的内容
allowRunningInsecureContent: false,
// 在独立JavaScript环境中运行Electron API和指定的preload脚本
contextIsolation: true,
// 是否允许使用原生的window.open()
nativeWindowOpen: false,
// 是否启用webview标签
webviewTag: false,
}})
win.loadURL('https://github.com')

更详细的参数设置可以参考文档

上面的选项进行了十分严格的限制,无node环境,不允许window.open,不允许加载非https链接,在独立JS环境运行等等,安全性是够了,但是带来的问题就是,如果遇到业务需要使用API怎么办?

比如说由第三方开发的小程序,可能会需要获取一些Node或Electron的api,但是又要确保安全性。

这种情况下就需要代理一些Node和Electron的api了

代理API

这里我们主要讨论两种方式。

对API做完全的封装

将方法在preload中封装好(禁用node集成不影响preload),然后挂载到Window上供其调用,比如在
中通过下面的代码可以提供API读取文件内容,但不允许写入文件等操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fs = require('fs')
const JSBridge = {
// 只提供读取方法,不提供写入
readFile: (path) => {
return new Promise((resolve,reject)=>{
// 做一些路径规则判断,是否允许访问
if(!/regexp-to-test-path/.test(path)) reject(new Error(`not allowed path ${path}`))
fs.readFile(path, (err, data)=>{
if(err) reject(err)
resolve(data)
})
})
}
}
window.JSBridge = JSBridge

通过remote-require事件做代理和放行

监听app的remote-require事件,根据模块名称做过滤和放行,也可以返回封装好的代理模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
const readOnlyFsProxy = require(/* ... */)

const allowedModules = new Set(['crypto'])
const proxiedModules = new Map(['fs', readOnlyFsProxy])

app.on('remote-require', (event, webContents, moduleName) => {
if (proxiedModules.has(moduleName)) {
event.returnValue = proxiedModules.get(moduleName)
}
if (!allowedModules.has(moduleName)) {
event.preventDefault()
}
})

限制导航

在浏览器要开始导航时会触发will-navigate事件,我们可以在事件回调中判断url是否为站内链接,如果非站内链接可以调用event.preventDefault()阻止这次导航。

1
2
3
4
5
6
7
8
9
10
11
12

const { BrowserWindow } = require('electron')

const browserWindow = new BrowserWindow()
browserWindow.webContents.on('will-navigate', (event, url) => {
// 判断链接域名
if(!/google.com?/.test(new URL(url).origin)){
event.preventDefault()
}
})

browserWindow.loadURL('https://google.com')

执行上面的代码,会发现打开的窗口中可以进行搜索,但是无法进入搜索出的第三方页面。

限制新窗口的创建

新窗口的创建主要有几种形式:

  1. 站点资源,可能会通过window.open(), a标签的target=_blank等方式打开新窗口;
  2. 用户通过Command/Ctrl+鼠标单击,右键菜单等方式打开新窗口;

通过监听new-window事件可以判断url和打开方式,然后通过event.preventDefault()来阻止创建新窗口

下面的代码阻止了command+click和点击a标签target=_blank的方式打开新窗口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

const { BrowserWindow, dialog } = require('electron')

const browserWindow = new BrowserWindow()
browserWindow.webContents.on('new-window', (event, url, frameName, disposition) => {
// disposition字段的相关解释:
// new-window : open调用
// background-tab: command+click
// foreground-tab: 右键点击新标签打开或点击a标签target _blank打开
// electron文档: 文档:https://www.electronjs.org/docs/api/web-contents#webcontents
// github源码: github源码: https://github.com/electron/electron/blob/72a089262e31054eabd342294ccdc4c414425c99/shell/browser/api/electron_api_web_contents.cc
// chrome 源码: https://chromium.googlesource.com/chromium/src/+/66.0.3359.158/ui/base/mojo/window_open_disposition_struct_traits.h
if (disposition === 'background-tab' || disposition === 'foreground-tab') {
dialog.showMessageBox({message: `not allowed. url: ${url}, disposition: ${disposition}`})
event.preventDefault()
}
})

browserWindow.loadURL('https://google.com')

尝试一下:修改上面的代码,看看不同的disposition分别适用什么场景

崩溃收集

开发electron项目时,经常会遇到APP崩溃的情况,如果APP端没有对应的日志记录,那开发就无法掌握APP的崩溃情况了,也不好做分析。

目前我们选用的是开源项目Sentry,它用来记录crash日志,它也有统计模块,也能私有化部署,基本上开箱就能用

sentry

情况模拟

Renderer进程主动push错误信息

1
2
3
4
5


window.$EB.ipcRenderer.send('renderer.error', {
message: 'renderer.error',
})

触发的sentry 错误日志(可能需要翻墙)

2. Renderer进程被动触发错误信息

1
2
3


throw new Error('Error triggered in renderer process')

这不会触发sentry信息

Renderer进程crash了

1
2

window.$EB.crash()

触发的sentry crash日志(可能需要翻墙)

开发

快速新建一个Electron App

新建一个工作目录:

1
mkdir my-app && cd my-app

初始化package.json

1
yarn init

安装依赖

1
yarn add electron -D

新建index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello World</title>
<style>
html, body {
background-color: antiquewhite;
}
</style>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>

新建index.js

1
2
3
4
5
6
7
const { app, BrowserWindow } = require('electron')
const path = require('path')

app.on('ready', function(){
const window = new BrowserWindow()
window.loadFile(path.resolve(__dirname, 'src', 'index.html'))
})

在package.json中添加启动命令

1
2
3
"scripts": {
"start": "electron ."
},

执行启动命令

1
yarn start

显示效果如下

quick start electron app

这样,一个十分简单的Electron App就完成了。

正式开始

刚刚的项目虽然已经能够启动来,但还有些简单,对于需求和业务复杂的项目来说还有很多不足。

比如,主进程和渲染进程改如何调试?我想用最新的ES特性该怎么做?想用TypeScript该怎么配置?想用React,Vue,Angular来写UI呢?

接下来我们将一一解决这些问题。

使用Webpack来编译我们的代码

首先调整一下目录结构:

1
2
3
4
5
6
7
8
.
├── app
│ └── index.js 主进程入口文件
├── build 构建相关的脚本和配置
│ └── webpack.config.js
├── output 编译结果输出目录
└── src
└── index.html 渲染进程入口文件

我们这里选择webpack作为构建工具。

Electron本身的TypeScript类型声明文件很齐全,开发体验不错,因此这里选择TypeScript作为主要开发语言。

首先安装必要的依赖:

1
yarn add -D webpack webpack-cli webpack-merge typescript awesome-typescript-loader

在build目录添加webpack.config.base.js,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const path = require('path');

module.exports = {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
alias: {
'app': path.resolve(__dirname, '../app'),
'src': path.resolve(__dirname, '../src'),
},
},
module: {
rules: [{
test: /\.tsx?$/,
exclude: /node_modules/,
use: [{
loader: 'awesome-typescript-loader',
}],
}],
},
output: {
path: path.join(__dirname, '..', 'output', 'main'),
filename: '[name].js',
},
// https://webpack.js.org/configuration/node/
// 避免webpack配置导致的__dirname和__filename和实际输出文件的不一致
node: {
__dirname: false,
__filename: false,
},
// 启用source-map
devtool: 'source-map',
plugins: [
]
};

这里是基础的webpack配置,将应用于主进程和preload的代码编译。

接下来在build目录添加webpack.config.main.js作为主进程的webpack配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const webpack = require('webpack')
const { merge } = require('webpack-merge')

const baseConfig = require('./webpack.config.base')

module.exports = merge(baseConfig, {
mode: process.env.NODE_ENV,
// https://webpack.js.org/configuration/target/
// webpack可以针对多种环境或目标进行编译,包括electron-main和electron-preload。
target: 'electron-main',
entry: {
index: './app/index.ts',
},
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
],
})

添加tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"jsx": "react",
"esModuleInterop": true,
"sourceMap": true,
"noImplicitAny": true,
"strict": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"baseUrl": ".",
"paths": {
"app/*": ["app/*"],
"src/*": ["src/*"],
},
},
"include": ["app/**/*"],
"exclude": ["node_modules", "packages", "public", "mock"]
}

删除app/index.js,添加app/index.ts,现在我们就可以使用TypeScript来编写electron应用了。

1
2
3
4
5
6
7
import { app, BrowserWindow } from 'electron'
import path from 'path'

app.on('ready', function(){
const window = new BrowserWindow()
window.loadFile(path.resolve(__dirname, '..', '..', 'src', 'index.html'))
})

在package.json中添加命令

1
"dev:main": "NODE_ENV=development webpack --config ./build/webpack.config.main.js --watch"

main 字段改为输出文件的路径:

1
"main": "output/main/index.js",

执行

1
yarn dev:main

发现output目录下得到了编译后的index.js文件和sourcemap文件,接下来运行

1
yarn start

即可启动electron app

调试主进程

使用chrome调试

首先可以通过electron本身提供的inspect项和chrome进行调试

修改package.json中的start命令

1
"start": "electron . --inspect-brk=5858",

运行后打开chrome,输入 chrome://inspect进入inspect页面,可以看到

点击 Configure 输入 localhost:5858,点击Done,即可在下面的Remote Target列表中看到我们的Electron应用,点击inspect便可以开始调试。

文档参考

使用vscode调试

尽管chrome的开发者工具用来调试已经相当好用,但还是抵挡不了将debugger集成到编辑器中的诱惑:在编辑器中源代码直接打断点调试实在是太方便了。

vscode中配置十分简单,打开debug面板,点击添加配置,launch.json配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/app/index.ts",
"cwd": "${workspaceFolder}",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"outFiles": [
"${workspaceRoot}/output/main/*.js"
],
"args" : ["."]
}
]
}

打开app/index.ts,在想要调试的代码左边点击打上断点,在debug面板点击start debugging,就会发现代码执行到断点处停住,接下来就可以自由在vscode中调试了。

文档参考

使用webstorm调试

和vscode类似,在webstorm中也是通过添加配置来进行调试,点击添加配置,选择Node.js,填写如下:

文档参考

调试渲染进程

打开控制台

修改app/index.ts中的代码:

1
2
3
4
5
6
7
app.on('ready', function(){
const window = new BrowserWindow()
window.loadFile(path.resolve(__dirname, '..', '..', 'src', 'index.html'))
if(process.env.NODE_ENV === 'development'){
window.webContents.openDevTools()
}
})

在开发环境下,就会自动打开chrome调试工具了

添加preload

app文件夹下添加preload.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { ipcRenderer } from 'electron';

async function getAppInfo() {
return await ipcRenderer.invoke('app/get_basic_info')
}

const JSBridge = {
getAppInfo,
};

window.JSBridge = JSBridge;

export type JSBridgeType = typeof JSBridge;

并在app中添加对ipc的处理:

1
2
3
4
5
6
7
8
app.on('will-finish-launching', function(){
ipcMain.handle('app/get_basic_info', function handleAppGetBasicInfo(){
return {
version: app.getVersion(),
name: app.name,
}
})
})

由于window上没有JSBridge属性,ts会报错,此时我们在根目录添加一个global.d.ts:

1
2
3
4
5
6
7
import { JSBridgeType } from "app/preload";

declare global{
interface Window{
JSBridge: JSBridgeType
}
}

接下来在build下添加webpack.config.preload.js,将preload也输出到output目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const webpack = require("webpack");
const { merge } = require("webpack-merge");

const baseConfig = require("./webpack.config.base");

module.exports = merge(baseConfig, {
mode: process.env.NODE_ENV,
target: "electron-preload",
entry: {
preload: "./app/preload.ts",
},
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.EnvironmentPlugin({
NODE_ENV: "development",
}),
],
});

在package.json中添加一条preload的开发命令:

1
"dev:preload": "NODE_ENV=development webpack --config ./build/webpack.config.preload.js --watch"

启动后就会发现output/main目录下多出一个preload.js及sourcemap

修改app/index.ts,创建BrowserWindow时注入preload.js

1
2
3
4
5
6
7
8
9
const PRELOAD = path.resolve(__dirname, 'preload.js')

app.on('ready', function(){
const window = new BrowserWindow({webPreferences: {preload: PRELOAD}})
window.loadFile(path.resolve(__dirname, '..', '..', 'src', 'index.html'))
if(process.env.NODE_ENV === 'development'){
window.webContents.openDevTools()
}
})

app/index.html中添加按钮获取app信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hello World</title>
<style>
html,
body {
background-color: antiquewhite;
}
</style>
<script>
async function handleGetAppInfo() {
const { version, name } = await window.JSBridge.getAppInfo();
console.log(version,name)
document.querySelector('#version').innerText = version
document.querySelector('#name').innerText = name
}
</script>
</head>
<body>
<h1>Hello World</h1>
<dl>
<dt>version</dt> <dd id="version"></dd>
<dt>name</dt> <dd id="name"></dd>
</dl>
<button onclick="handleGetAppInfo()">get app info</button>
</body>
</html>

启动app,点击按钮,发现能够正常获取到app信息

至此我们基本的开发流程就都实现了,主要包括:

  • 主进程,渲染进程,preload的编译
  • 主进程和渲染进程的调试

看起来是完备了,但开发时就会发现还不够用呢。接下来对这个流程做一些补充

流程完善

启动命令完善

首先安装cross-env和concurrently

1
yarn add -D cross-env concurrently

修改dev:maindev:preload命令并添加dev命令:

1
2
3
"dev:main": "webpack --config ./build/webpack.config.main.js --watch",
"dev:preload": "webpack --config ./build/webpack.config.preload.js --watch",
"dev": "cross-env NODE_ENV=development concurrently \"npm run dev:main\" \"npm run dev:preload\""

cross-env用于兼容各平台下的环境变量设置,concurrently用于同时运行多个webpack编译的watch模式。之后app的编译只需运行yarn dev即可

使用React做渲染进程开发

首先用create-react-app创建一个React应用,在项目根目录下运行

1
yarn create react-app renderer

进入renderer目录运行yarn start,应用默认在2333端口启动,如果报错The react-scripts package provided by Create React App requires a dependency: webpack,可以在renderer目录下添加.env文件:

1
SKIP_PREFLIGHT_CHECK=true

这时候我们将app/index.ts中的window.loadFile ...改为

1
window.loadURL('http://localhost:2333')

重启Electron应用即可看到渲染的窗口加载了本地的React应用

但是这里会有一个问题,在打包时我们会将渲染进程的代码打包进去,窗口加载实际上是通过loadFile的方式进行的,如果本地开发的时候使用本地http地址开发,实际效果和打包出来的效果是会有出入的,因此我们再做一些修改,将开发时生成的文件输出到output/renderer目录下,然后通过loadFile的方式加载它。

那么我们就需要修改cra项目的webpack中对应的output配置和path中的appBuild配置来改变输出目录,然后修改devServer的配置让开发时也输出文件,并且修改socket配置保证热更新能正常使用。

因为cra项目本身没有将配置暴露出来,这里我们做的改动不多,因此选择使用react-app-rewired而非eject弹出配置。

首先安装依赖

1
yarn add -D react-app-rewired react-dev-utils

然后在renderer目录下新建config-overrides.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const path = require('path')
const OUTPUT_PATH = path.resolve(__dirname, '..', 'output', 'renderer')

module.exports = {
webpack: function(config, env) {
// 修改输出目录
config.output.path = OUTPUT_PATH
// 修改publicPath,否则静态资源文件会引用失败
config.output.publicPath = './'

return config;
},
devServer: function(configFunction) {
return function(proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);

// 将文件输出到硬盘
config.writeToDisk = true
// 修改sock相关配置保证热更新功能正常
config.host = process.env.HOST || '0.0.0.0';
config.sockHost = process.env.WDS_SOCKET_HOST;
config.sockPath = process.env.WDS_SOCKET_PATH; // default: '/sockjs-node'
config.sockPort = process.env.WDS_SOCKET_PORT;

return config;
};
},
paths: function(paths, env) {
// 修改build下的输出目录
paths.appBuild = OUTPUT_PATH
return paths;
},
}

重新启动react项目,发现output/renderer目录下输出了对应的静态文件

然后我们将app/index.ts中的window.loadURL ...注释,添加一行

1
2
// window.loadURL('http://localhost:2333')
window.loadFile(path.resolve(__dirname, '..', '..', 'output', 'renderer', 'index.html'))

重新启动应用,发现加载成功。

给devtools添加plugins

渲染进程使用了react进行开发,为了更方便的调试,我们来给devtools安装浏览器插件。

首先找到chrome插件的位置

  • windows

    1
    %LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions
  • MacOS

    1
    ~/Library/Application Support/Google/Chrome/Default/Extensions
  • Linux,有几个可能的路径

    1
    2
    3
    4
    ~/.config/google-chrome/Default/Extensions/
    ~/.config/google-chrome-beta/Default/Extensions/
    ~/.config/google-chrome-canary/Default/Extensions/
    ~/.config/chromium/Default/Extensions/

打开目录会发现下面都是根据以扩展id命名的文件夹,想要找到扩展对应的id,可以在chrome中打开chrome://extensions/,点击对应扩展的详细信息,即可从url参数上找到id。

React Developer Tools在MacOS上的为例,在app/index.ts中添加:

1
2
3
4
5
6
7
8
9
10
11
12
const EXTENSION_PATH_REACT_DEV_TOOLS = path.join('/Users/wangshuwen/Library/Application Support/Google/Chrome/Default/Extensions/', 'fmkadmapgofadopljbjfkapdkoienihi', '4.8.2_0')

...

app.on('ready', async function () {
const e = await session.defaultSession.loadExtension(EXTENSION_PATH_REACT_DEV_TOOLS)

const window = new BrowserWindow({webPreferences: {preload: PRELOAD}})
window.loadURL('http://localhost:2333')

...
})

由于Electron本身原因,通过session.loadExtension添加的插件目前React Dev Tools在file协议下无法访问文件,需要在http协议下进行调试。

备选方案:改为使用BrowserWindow.addDevToolsExtension方法添加插件,在Electron 9.0.0版本以下生效

1
2
3
4
5
6
app.on('ready', async function () {
BrowserWindow.addDevToolsExtension(EXTENSION_PATH_REACT_DEV_TOOLS)
// const e = await session.defaultSession.loadExtension(EXTENSION_PATH_REACT_DEV_TOOLS)

...
})

但是注意这个方法即将被Electron废弃,待Electron修复session.loadExtension的问题后可以更换掉此方法。

如果觉得手动找插件添加的方式过于麻烦,也可以使用electron-devtools-installer这个库来进行扩展管理。

相关文档

打包

Electron常用的打包工具有这么几个:

electron-packagerelectron-builder是单纯的Electron打包工具,electron-forge类似于一个CLI工具,参与从创建项目到开发和打包的流程。

electron-packager较为轻量,上手使用迅速,适合简单的项目打包。相对而言electron-builder配置更加复杂和全面一些,这里我们选择electron-builder作为打包工具。

基本打包配置

首先我们需要将主进程、preload、渲染进程的代码编译输出到output目录,假定output目录下文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── main
│ ├── index.js // 主进程入口文件
│ ├── index.js.map
│ ├── preload.js // preload文件
│ └── preload.js.map
└── renderer // 渲染进程的静态文件
├── static
├── favicon.ico
├── index.html
├── manifest.json
├── precache-manifest.4afa4365236dd3705833cb35553e2f08.js
├── robots.txt
├── service-worker.js
└── asset-manifest.json

添加build命令

package.json中添加build命令:

1
2
3
4
"build:main": "webpack --config ./build/webpack.config.main.js",
"build:preload": "webpack --config ./build/webpack.config.preload.js",
"build:renderer": "cd ./renderer && npm run build",
"build": "cross-env NODE_ENV=production concurrently \"npm run build:main\" \"npm run build:preload\" \"npm run build:renderer\""

启动 npm run build,可以发现output目录下生成了对应的文件目录。

安装electron-builder

安装必需的依赖项

1
yarn add electron-builder -D

package.json中添加”postinstall”命令,在安装依赖项后可以自动安装electron-builder的依赖项

1
"postinstall": "electron-builder install-app-deps"

添加配置文件

在项目根目录添加electron-builder.yml,electron-builder会默认读取该文件作为配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
appId: 'com.my-app'
productName: 'My App'
copyright: Copyright © 2020 ${author}

directories:
buildResources: resources
output: release/${version}
app: .

buildVersion: 1.0.0
artifactName: ${productName}-${version}-${channel}.${ext}
files:
- output
- resources
asar: true

publish:
- provider: generic
url: https://update.electron-builder.com
channel: latest
releaseInfo:
releaseName: 'A New Playground for Electron!'
releaseNotes: 'Some new features now is available.'

简单分析一下上面的配置文件:

  • appId: 应用id,不同平台有不同的规则
  • directories
    • buildResources: 构建资源文件目录,不会打包到app中,如果需要打包其中的一些文件比如托盘图标,需要在files字段中指定,比如 "files": ["**/*", "build/icon.*"]
    • output: 打包输出目录
    • app: 包含package.json的应用目录,默认会读取 app, www, 或当前工作目录,通常不用指定
  • files: 指定需要复制过去打包的文件,参考文档
  • asar: 是否打包成asar档案文件, 参考文档
  • publish: 发布选项,和更新服务器类型相关, 参考文档

1.4 添加打包命令

在package.json中添加打包命令:

1
2
3
"pack-mac": "electron-builder build --mac",
"pack-win": "electron-builder build --win",
"pack-all": "electron-builder build -mw",

接下来开始打包,首先运行yarn run build,编译文件到output目录,接下来运行yarn pack-all,开始打包Windows和MacOS的应用。

运行结束后可以发现在release目录下出现了对应的版本文件夹,里面有打包好的安装文件和更新入口文件:

1
2
3
4
5
6
7
8
9
10
11
12
.
└── 1.0.0
├── My App-1.0.0-latest.dmg
├── My App-1.0.0-latest.dmg.blockmap
├── My App-1.0.0-latest.exe
├── My App-1.0.0-latest.exe.blockmap
├── My App-1.0.0-latest.zip
├── builder-effective-config.yaml
├── latest-mac.yml
├── latest.yml
├── mac
└── win-unpacked

代码签名

electron-builder支持MacOS和Windows的签名

但是由于MacOS应用的代码签名必须在MacOS机器上完成,而且MacOS可以进行Windows应用签名,因此建议使用MacOS机器进行打包签名。

MacOS代码签名

electron在进行代码签名的时候会自动检查环境变量中的对应字段,包括:

字段 描述
CSC_LINK .p12或.pfx证书文件的HTTPS链接(或base64编码数据,或file链接,或本地路径)。
CSC_KEY_PASSWORD 证书密码
CSC_NAME (MacOS) login.keychain中的证书名称
CSC_IDENTITY_AUTO_DISCOVERY (MacOS) MacOS上是否自动使用keychain中的身份
CSC_KEYCHAIN (MacOS) keychain名称。如果未指定CSC_LINK则使用。默认为系统默认keychain。
WIN_CSC_LINK (Windows) 类似CSC_LINK,在MacOS上签名Windows应用时使用
WIN_CSC_KEY_PASSWORD (Windows) 类似CSC_KEY_PASSWORD,在MacOS上签名Windows应用时使用

在MacOS上签名就可以有几种选择:

  1. 安装对应的证书到keychain,然后CSC_NAME指定为证书在keychain中显示的名称,CSC_KEY_PASSWORD设置为证书密码,打包时就会自动进行签名。

  2. 将证书文件放在对应目录,CSC_LINK设置为对应的路径,CSC_KEY_PASSWORD设置为证书密码即可。若担心证书和密码都放在项目中不合适,可以去掉项目中的证书密码,修改打包脚本,每次运行打包命令时输入密码并设置到环境变量。

Windows代码签名

  1. 在Windows机器上签名,同样的指定CSC_LINKCSC_KEY_PASSWORD即可
  2. 在Mac机器上签名,需要将CSC_LINKCSC_KEY_PASSWORD替换为WIN_CSC_LINKWIN_CSC_KEY_PASSWORD

如果需要申请Windows代码签名证书,可以参考这篇文档

更详细的平台target配置

electron-builder支持多种类型的安装文件打包

  • Mac平台支持.dmg和.pkg,
  • Windows平台支持nsis, nsisWeb, appx, squirrelWindows

MacOS配置

以MacOS下打包.dmg文件为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

mac:
target:
- dmg
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
entitlements: resources/entitlements.mac.plist
extendInfo:
NSMicrophoneUsageDescription: 请允许访问您的麦克风
NSCameraUsageDescription: 请允许访问您的摄像头

dmg:
background: resources/background.png
iconSize: 128
iconTextSize: 13
window:
width: 300
height: 200
  • category 应用在Mac下的分类,分类参考文档
  • hardenedRuntime 应用是否使用hardenedRuntime进行签名。hardenedRuntime用于管理macOS应用程序的安全保护和资源访问。相关的参考文档
  • entitlements 对应的entitlements.mac.plist文件,该文件用于获取授权的定义。参考文档
  • extendInfo Info.plist的额外条目,主要用于配置一些应用属性。参考文档

比如下面这个entitlements.mac.plist

1
2
3
4
5
6
7
8
9
10
<?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>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

声明了对录音和摄像头权限的申请

Windows配置

以打包nsis文件为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
win:
target:
- target: nsis
arch:
- x64
- ia32
icon: resources/icon.ico

nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
license: resources/eula.txt
deleteAppDataOnUninstall: false
displayLanguageSelector: false
  • oneClick 是否创建一个oneClick安装程序,和通常的windows安装程序不一样,oneClick安装程序点击后会直接安装,不需要一步一步选择选项然后点击确定,但在用户体验来说可能用户觉得不能选择安装目录,用户等选项有些流氓,而且看不到安装进度不知道什么时候安装成功。
  • perMachine 是否显示安装模式,即选择安装给所有用户或者安装给当前用户
  • allowToChangeInstallationDirectory 允许改变安装目录
  • license 最终用户许可协议,支持.txt, .rtf, .html
  • deleteAppDataOnUninstall 卸载时清除数据
  • displayLanguageSelector 是否显示语言选择器,选否则根据系统语言自动判断

多平台的插件打包

找到插件执行文件

在MacOS下通常是*.plugin文件,Windows下为*.dll文件,这里以32.0.0.414版本的flash player为例子,到adobe flash官网下载并安装flash player后,其目录通常在

  • MacOS: /Library/Internet Plug-Ins/PepperFlashPlayer/PepperFlashPlayer.plugin
  • Windows: C:\Windows\SysWOW64\Macromed\Flash\pepflashplayer32_0_0_414.dll 或 C:\Windows\System32\Macromed\Flash\pepflashplayer64_32_0_0_414.dll

添加编译配置

将找到的插件复制到项目的plugins目录下,根据平台区分目录,假设目录如下

1
2
3
4
5
plugins
├── darwin
│ └── PepperFlashPlayer.plugin
└── win32
└── pepflashplayer.dll

接下来在webpack配置中添加配置,启动编译时可以将plugins下对应平台的文件自动复制到output下的plugins目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const CopyPlugin = require('copy-webpack-plugin')

// 根据平台判断插件目录
function getPluginSourceDir() {
if (process.platform === "darwin") {
return path.resolve(__dirname, "..", "plugins", "darwin");
}
if (process.platform === "win32") {
return path.resolve(__dirname, "..", "plugins", "win32");
}
throw new Error(`can not find plugins directory for platform ${process.platform}`)
}

module.exports = {
...
plugins: [
new CopyPlugin({
patterns: [
{
from: getPluginSourceDir(),
to: path.resolve(__dirname, '..', 'output', 'plugins'),
},
],
}),
]
}

加载插件

加载插件需要在app中通过调用对应的api,并且在创建BrowserWindow时将webPreferences选项中的plugins设置为true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let pluginName
switch (process.platform) {
case 'win32':
pluginName = 'pepflashplayer.dll'
break
case 'darwin':
pluginName = 'PepperFlashPlayer.plugin'
break
}
app.commandLine.appendSwitch('ppapi-flash-path', path.join(__dirname, '..','plugins', pluginName as string))

...

const window = new BrowserWindow({webPreferences: {preload: PRELOAD, plugins: true}})

加载一个包含flash的url

1
window.loadURL(EXAMPLE_FLASH_URL)

重新启动应用,发现flash可以正常运行了

加载系统安装的 Pepper Flash 插件

这种方式只适用于flash插件,上面的根据平台获取插件,加载指定目录等操作都不需要了,而是直接加载系统已经安装的flash插件,这也意味着如果系统没有安装flash插件就无法使用

1
2
3
4
5
app.commandLine.appendSwitch('ppapi-flash-path', app.getPath('pepperFlashSystemPlugin'))

...

const window = new BrowserWindow({webPreferences: {preload: PRELOAD, plugins: true}})

创建多平台多环境打包的命令行工具

在实际业务中,打包往往不是单纯的electron-builder build就可以实现,可能会有许多类似区分环境和平台,定制打包产出,打包后上传等需求,在这种时候可能就需要做一些更复杂的工作了。

比如在一个常见的业务场景,需要实现以下功能:

  1. 版本更新;
  2. 同步更新信息到CHANGELOG;
  3. 区分环境打包;
  4. 区分平台打包;
  5. 打包后自动上传;

针对这些功能做一个流程设计:

  1. 输入版本号(不输入则自动patch)后,校验版本号是否正确,更新package.json
  2. 输入更新的信息和更新的描述,并同步写入到CHANGELOG
  3. 读取用户选择的环境,可以多选,并在后续的打包流程中针对不同环境实现不同的操作(环境变量注入等);
  4. 读取用户选择的平台,可以多选,在后续打包中只打包对应的平台;
  5. 打包结束后自动上传到服务器(以ftp为例);

接下来根据功能流程设计来划分功能模块:

  1. Inquirer: 读取用户输入项并提供校验;
  2. CommandExecutor: 执行命令行命令的函数;
  3. JsonUpdater: 提供读写操作更新package.json和package-lock.json;
  4. ChangelogUpdater: 更新CHANGELOG
  5. Builder: 使用electron-builder进行打包操作
  6. FileUploader: 通过ftp进行文件上传

接下来实现这些功能模块:

build/packaging-cli/inquirer.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import * as inquirer from "inquirer";

const envs = ["test", "prod"] as const;
const platforms = ["win", "mac"] as const;

export type envType = typeof envs[number]
export type platformType = typeof platforms[number]

const options = [
{ type: "input", name: "version", message: `版本号?` },
{ type: "input", name: "releaseName", message: `更新标题`, default: "更新" },
{ type: "editor", name: "releaseNotes", message: `更新描述:` },
{
type: "list",
name: "env",
message: "环境?",
choices: envs.map((e) => ({ name: e, value: e })),
},
{
type: "list",
name: "platforms",
message: "平台?",
choices: [
{ name: "all", value: platforms },
...platforms.map((p) => ({ name: p, value: [p] })),
],
},
];

interface QueryResult {
env: envType;
platforms: platformType[];
version: string;
releaseName: string;
releaseNotes: string;
}

export async function query() {
const result = await inquirer.prompt<QueryResult>(options);
console.log(result);
return result;
}

build/packaging-cli/command-executor.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { spawn } from 'child_process'

export function execCommand(command: string, args: string[]) {
return new Promise((resolve, reject) => {
const ls = spawn(command, args, { stdio: 'inherit' })

ls.on('error', error => {
console.error(error.message)
})

ls.on('close', code => {
console.log(`[${command} ${args.join(' ')}]` + `exited with code ${code}`)
code === 0 ? resolve() : reject(code)
})
})
}

build/packaging-cli/json-updater.ts

1
2
3
4
5
6
7
8
9
10
11
import * as path from "path";
import { readJsonSync, writeJSONSync } from "fs-extra";

export const PACKAGE_JSON_PATH = path.resolve(__dirname, "..", "..", "package.json");
export const PACKAGE_JSON_LOCK_PATH = path.resolve(__dirname, "..", "..", "package-lock.json");

// 读取json内容
export const readJSON = (path: string) => () => readJsonSync(path);

// 覆写json变量
export const writeJSON = (path: string) => (vars: any) => writeJSONSync(path, vars);

build/packaging-cli/changelog-updater.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import * as fs from 'fs'
import * as path from 'path'
import dayjs from 'dayjs'

interface ChangeLog {
version: string
releaseName: string
releaseNotes: string
}

const CHANGE_LOG_PATH = path.resolve(__dirname, '..', '..', 'CHANGELOG.md')

export function updateChangeLog(cl: ChangeLog) {
const { version, releaseName, releaseNotes } = cl
const content = `## 版本:${version}

- ${dayjs().format('YYYY-MM-DD hh:mm:ss')}

${releaseName}

\`\`\`
${releaseNotes}
\`\`\`
`

fs.appendFileSync(CHANGE_LOG_PATH, content)
}

build/packaging-cli/builder.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
import { envType, platformType } from "./inquirer";
import { execCommand } from "./command-executor";

export async function build(env: envType, platforms: platformType[]) {
process.env.ENV = env;
await execCommand(`npm`, ["run", "build"]);

let buildArgs = ["build"];
if (platforms.includes("win")) buildArgs.push("--win");
if (platforms.includes("mac")) buildArgs.push("--mac");

await execCommand(`electron-builder`, buildArgs);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import FTPClient from "ftp";
import * as path from "path";
import * as fs from "fs";

const clientConfig = {
host: "host.to.your.server",
port: 60021,
user: "username",
password: "password",
};

const Client = new FTPClient();

function connectClient() {
return new Promise((resolve, reject) => {
Client.on("ready", resolve);
Client.on("error", reject);
Client.connect(clientConfig);
});
}

function putFile(file: string, dest: string) {
return new Promise((resolve, reject) => {
Client.put(file, dest, (err) => (err ? reject(err) : resolve()));
});
}

export async function uploadDir(dir: string, dest: string) {
await connectClient();

const task: [string, string][] = fs
.readdirSync(dir)
.map((f) => path.resolve(dir, f))
.filter((f) => fs.statSync(f).isFile())
.map((f) => ([f, `${dest}/${path.basename(f)}`]));

await Promise.all(task.map(([src, dest]) => putFile(src, dest)));

Client.end();
}

最后是packaging-cli脚本的入口文件,统领整个打包流程;

build/packaging-cli/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { query } from "./inquirer";
import { readJSON, PACKAGE_JSON_PATH } from "./json-updater";
import { execCommand } from "./command-executor";
import { updateChangeLog } from "./changelog-updater";
import { build } from "./builder";
import * as path from "path";
import { uploadDir } from "./file-uploader";

const RELEASE_DIR = path.resolve(__dirname, "..", "..", "release");

async function startPackaging() {
let { version, env, platforms, releaseName, releaseNotes } = await query();

// 更新版本号
await execCommand("npm", ["version", version ? version : "patch"]);
version = readJSON(PACKAGE_JSON_PATH)().version;
// 更新CHANGELOG
updateChangeLog({ version, releaseName, releaseNotes });
// 开始打包
await build(env, platforms);
// 上传文件
await uploadDir(path.resolve(RELEASE_DIR, version), "test-app-temp")
}

startPackaging();

在package.json中添加命令

1
"pack": "ts-node ./build/packaging-cli/index.ts",

运行yarn run pack即可开始打包

自动更新

前面打包流程基于Electron-Builder,因此以下的更新讨论也是基于其提供的electron-updater

关于electron-updater

适用场景

electron-updater只适用于以下类型的应用包:

  • MacOS: DMG
  • Windows: NSIS
  • Linux: AppImage

提供的API

文档

API方法 功能
checkForUpdates() 检查更新
checkForUpdatesAndNotify() 检查更新,有更新则提示
downloadUpdate(cancellationToken) 下载更新
getFeedURL() 获取更新服务链接
setFeedURL(options) 设置更新服务链接
quitAndInstall(isSilent, isForceRunAfter) 退出应用并安装更新

提供的事件

文档

事件 触发
error 更新错误
checking-for-update 检查更新中
update-available 有可用更新
update-not-available 没有可用更新
download-progress 下载更新中
update-downloaded 更新下载完成

一个简单的更新示例

在主进程监听检查更新事件

1
2
3
4
5
6
import { autoUpdater } from 'electron-updater'
import { ipcMain } from 'electron'

ipcMain.on('CHECK_FOR_UPDATE', function(){
autoUpdater.checkForUpdatesAndNotify()
})

在渲染进程点击按钮发送ipc事件检查更新(以React为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react';
import { ipcRenderer } from 'electron'
import './App.css';

function App() {
return (
<div className="App">
<button onClick={ipcRenderer.send('CHECK_FOR_UPDATE')}>检查更新</button>
</div>
);
}

export default App;

注意创建BrowserWindow时需要设置webPreferences属性

1
2
3
4
5
6
const window = new BrowserWindow({
webPreferences: {
webSecurity: false,
nodeIntegration: true,
},
});

更新服务的设计

上面寥寥几行代码就实现了一个简单的更新功能,但是这个功能在复杂的业务场景中往往没有那么适合,因此我们在这里开始来设计一个贴合常见场景的更新方案。

需要实现的功能

  1. 查看更新信息
  2. 用户手动检查更新;
  3. 应用启动时静默检查更新;
  4. 应用在后台定时检查更新;
  5. 用户手动下载更新;
  6. 下载进度显示;
  7. 用户手动退出安装更新;
  8. 通过版本号控制强制更新;
  9. 日志;
  10. 开发时请求本地服务做测试;

更新流程

更新过程的所有状态:

状态 描述
Idle 空闲
Checking 检查中
Available 有可下载更新
Downloading 下载中
Downloaded 下载完成

状态流程如图

auto update workflow

接口设计

根据上述功能,对更新服务做一个初步的设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/updater.ts
import { autoUpdater, UpdateInfo } from 'electron-updater'

interface CheckResult{
// 是否有更新
available: boolean
// 更新内容
updateInfo: UpdateInfo
}

interface ProgressInfo {
total: number
delta: number
transferred: number
percent: number
bytesPerSecond: number
}

// 下载进度回调
type DownloadProgressCallback = (p: ProgressInfo) => void
// 下载结束回调
type DownloadedCallback = () => void

abstract class AppUpdateService {
// 检查更新
public abstract checkUpdate(): CheckResult
// 下载更新
public abstract downloadUpdate(params: {onDownloadProgress: DownloadProgressCallback, onDownloaded: DownloadedCallback }): void
// 应用更新
public abstract applyUpdate(): void
}

但是由于ipc通信的限制,无法传递回调函数,因此我们在这里考虑将更新服务的业务功能封装都移到渲染进程,主进程只提供基本的初始化服务和接口方法的封装。

app/updater.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { autoUpdater } from "electron-updater";
import logger from "electron-log";
import { BrowserWindow, ipcMain, app } from 'electron';

function checkUpdate() {
return autoUpdater.checkForUpdates();
}

function downloadUpdate() {
return autoUpdater.downloadUpdate();
}

function applyUpdate() {
return autoUpdater.quitAndInstall();
}

function sendToAllBrowserWindows(channel: string, ...args: unknown[]) {
const browserWindows = BrowserWindow.getAllWindows()
browserWindows.forEach(bw=>bw.webContents.send(channel, ...args))
}

function init() {
// 日志
logger.transports.file.level = "info";
autoUpdater.logger = logger;

// 禁用自动下载
autoUpdater.autoDownload = false;
// 启用退出app时自动安装更新
autoUpdater.autoInstallOnAppQuit = true;

// 监听事件并发送到渲染进程
const events = [
"error",
"checking-for-update",
"update-available",
"update-not-available",
"download-progress",
"update-downloaded",
]
events.forEach((eventName) => autoUpdater.on(eventName, sendToAllBrowserWindows.bind(null, 'APP_UPDATER/STATUS_CHANGE')));

// 通过接收渲染进程发送的ipc调用方法
ipcMain.on('APP_UPDATER/CHECK_UPDATE', checkUpdate)
ipcMain.on('APP_UPDATER/DOWNLOAD_UPDATE', downloadUpdate)
ipcMain.on('APP_UPDATER/APPLY_UPDATE', applyUpdate)
}

app.once('will-finish-launching', init)

export const AppUpdater = {
checkUpdate,
downloadUpdate,
applyUpdate,
}

在渲染进程,我们首先创建一个自定义hooks来实现接收更新状态变更并通过createContext来实现组件状态共享。
renderer/src/Hooks/useAppUpdate.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import React, { useState, useEffect, useContext, createContext } from "react";
import { ipcRenderer } from "electron";

export function useAppUpdate() {
const [status, setStatus] = useState(null);
const [updateInfo, setUpdateInfo] = useState(null);
const [updateProgressInfo, setUpdateProgressInfo] = useState(null);
const [error, setError] = useState(null);

const checkUpdate = () => ipcRenderer.send('APP_UPDATER/CHECK_UPDATE')
const downloadUpdate = () => ipcRenderer.send('APP_UPDATER/DOWNLOAD_UPDATE')
const applyUpdate = () => ipcRenderer.send('APP_UPDATER/APPLY_UPDATE')

useEffect(() => {
ipcRenderer.on("APP_UPDATER/STATUS_CHANGE", (event, updateEventName, ...args) => {
console.log(`updater#${updateEventName}: `, ...args);

setStatus(updateEventName);

switch (updateEventName) {
case "error":
setError(args[0]);
break;
case "checking-for-update":
break;
case "update-available":
setUpdateInfo(args[0]);
break;
case "update-not-available":
break;
case "download-progress":
setUpdateProgressInfo(args[0]);
break;
case "update-downloaded":
setUpdateInfo(args[0]);
break;

default:
break;
}
}
);
}, []);

return {
status, updateInfo, updateProgressInfo, error,
checkUpdate, downloadUpdate, applyUpdate,
};
}

const UpdaterContext = createContext();

export const UpdaterProvider = ({ children }) => {
const state = useAppUpdate();
return <UpdaterContext.Provider value={state}>{children}</UpdaterContext.Provider>;
};

export function useUpdaterContext(){
const store = useContext(UpdaterContext)
return store
}

新建一个AboutPanel组件,在组件中显示更新信息下载进度,已经更新按钮等
renderer/src/Components/AboutPanel/index.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import React, {useMemo} from 'react'
import { useUpdaterContext } from '../../Hooks/useAppUpdate'

export function AboutPanel() {
const {
status, updateInfo, updateProgressInfo, error,
checkUpdate, downloadUpdate, applyUpdate,
} = useUpdaterContext()

const Button = useMemo(()=>{
if(status === 'update-available'){
return <button onClick={downloadUpdate}>Download Updates</button>
}
if(status === 'download-progress'){
return <button>Downloading...</button>
}
if(status === 'update-downloaded'){
return <button onClick={applyUpdate}>Apply Updates</button>
}
return <button onClick={checkUpdate}>Check for Updates</button>
}, [status])

const Info = useMemo(()=>{
if(status === 'error'){
console.log('error',error)
return <>
<p style={{color: 'lightpink'}}>{error?.name}</p>
<p style={{color: 'lightpink'}}>{error?.message}</p>
<p style={{color: 'lightpink'}}>{error?.stack}</p>
</>
}
if(status === 'checking-for-update'){
return <p>Checking...</p>
}
if(status ==='update-not-available'){
return <p>No Updates Available</p>
}
if(updateInfo){
const {version, releaseName, releaseNotes, releaseDate} = updateInfo
return <>
<p>version: {version}</p>
<p>date: {releaseDate}</p>
<p>name: {releaseName}</p>
<p>notes: {releaseNotes}</p>
</>
}
}, [status, updateInfo, error])

return <div>
{Info}

{
status === 'download-progress' && Boolean(updateProgressInfo) &&
<div style={{backgroundColor: 'grey', width: 300, height: 20, margin: '12px auto'}}>
<div style={{backgroundColor: 'cornflowerblue', height: 20, width: 300 * updateProgressInfo.percent / 100}}></div>
</div>
}

{Button}
</div>
}

新建一个UpdateChecker组件,在这个组件中做静默检查、定时检查和更新提示
renderer/src/Components/UpdateChecker/index.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React from 'react'
import { useAppUpdate } from '../../Hooks/useAppUpdate'
import { useEffect } from 'react'

export function UpdateChecker(){
const {checkUpdate, downloadUpdate, applyUpdate, updateInfo, status, updateProgressInfo} = useAppUpdate()

useEffect(()=>{
let timeout
function scheduleCheckUpdate(){
if(!['checking-for-update', 'update-available', 'download-progress', 'update-downloaded'].includes(status)){
checkUpdate()
}
timeout = setTimeout(() => {
scheduleCheckUpdate()
}, 1000 * 60 *60);
}
scheduleCheckUpdate()

return () => clearTimeout(timeout)
}, [])

useEffect(() => {
if(status === 'update-available'){
// eslint-disable-next-line no-restricted-globals
const result = confirm('Updates available, download instantly?')
if(result){
downloadUpdate()
}
}
if(status === 'update-downloaded'){
// eslint-disable-next-line no-restricted-globals
const result = confirm('Download completed, apply updates?')
if(result){
applyUpdate()
}
}
}, [status])

return null
}

一个基本的自动更新服务就完成了