工程化 安全性 在这里讨论安全性,我们主要分为两种,一种是加载应用内 资源,一种是加载远程 资源。
对于加载应用内资源代码我们是可控的,需要注意的安全问题和通常的网站差不多,防范XSS攻击等。
而很多情况下,在我们的应用中可能需要加载远程站点(合作网站、用户发送的链接,由第三方开发的小程序等),对于这些很多场景下不可控的代码资源,如果直接给他们所有的访问权限,后果将不可控,因此我们着重讨论加载远程资源可能的问题以及解决方法。
用户发送的链接 用户发送的内容并不可控,在这里我们需要多重拦截 做保障。
后端维护站点黑名单 由后端维护一个站点黑名单,用户发送的每个链接在打开前都请求一次接口,判断到如果是色情网站钓鱼网站等不合法站点,直接提醒用户不要打开。
通过系统浏览器打开 可以通过shell.openExternal
通过浏览器打开第三方站点
1 2 3 4 const { shell } = require ('electron' )shell.openExternal('https://github.com' )
但是使用这个方法需要注意两点:
1.openExternal不只能打开http链接
openExternal
方法是是通过系统的默认应用打开给定的外部协议URL,比如说打开下面这段url会打开邮件应用写邮件,非http协议的链接只要有对应的处理程序都会打开。
1 2 3 4 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 : { nodeIntegration: false , sandbox: true , enableRemoteModule: false , webSecurity: true , allowRunningInsecureContent: false , contextIsolation: true , nativeWindowOpen: false , 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' )
执行上面的代码,会发现打开的窗口中可以进行搜索,但是无法进入搜索出的第三方页面。
限制新窗口的创建 新窗口的创建主要有几种形式:
站点资源,可能会通过window.open(), a标签的target=_blank等方式打开新窗口;
用户通过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 ) => { 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日志,它也有统计模块,也能私有化部署,基本上开箱就能用
情况模拟 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了
触发的sentry crash日志(可能需要翻墙)
开发 快速新建一个Electron App 新建一个工作目录:
1 mkdir my-app && cd my-app
初始化package.json
安装依赖
新建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 ." },
执行启动命令
显示效果如下
这样,一个十分简单的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' , }, node: { __dirname: false , __filename: false , }, 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, 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 { "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",
执行
发现output目录下得到了编译后的index.js
文件和sourcemap文件,接下来运行
即可启动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:main
和dev: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 config.output.publicPath = './' return config; }, devServer: function (configFunction ) { return function (proxy, allowedHost ) { const config = configFunction(proxy, allowedHost); config.writeToDisk = true config.host = process.env.HOST || '0.0.0.0' ; config.sockHost = process.env.WDS_SOCKET_HOST; config.sockPath = process.env.WDS_SOCKET_PATH; config.sockPort = process.env.WDS_SOCKET_PORT; return config; }; }, paths: function (paths, env ) { paths.appBuild = OUTPUT_PATH return paths; }, }
重新启动react项目,发现output/renderer
目录下输出了对应的静态文件
然后我们将app/index.ts
中的window.loadURL ...
注释,添加一行
1 2 window .loadFile(path.resolve(__dirname, '..' , '..' , 'output' , 'renderer' , 'index.html' ))
重新启动应用,发现加载成功。
渲染进程使用了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) ... })
但是注意这个方法即将被Electron废弃,待Electron修复session.loadExtension
的问题后可以更换掉此方法。
如果觉得手动找插件添加的方式过于麻烦,也可以使用electron-devtools-installer 这个库来进行扩展管理。
相关文档
打包 Electron常用的打包工具有这么几个:
electron-packager
和electron-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上签名就可以有几种选择:
安装对应的证书到keychain,然后CSC_NAME
指定为证书在keychain中显示的名称,CSC_KEY_PASSWORD
设置为证书密码,打包时就会自动进行签名。
将证书文件放在对应目录,CSC_LINK
设置为对应的路径,CSC_KEY_PASSWORD
设置为证书密码即可。若担心证书和密码都放在项目中不合适,可以去掉项目中的证书密码,修改打包脚本,每次运行打包命令时输入密码并设置到环境变量。
Windows代码签名
在Windows机器上签名,同样的指定CSC_LINK
和CSC_KEY_PASSWORD
即可
在Mac机器上签名,需要将CSC_LINK
和CSC_KEY_PASSWORD
替换为WIN_CSC_LINK
和WIN_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 pluginNameswitch (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
就可以实现,可能会有许多类似区分环境和平台,定制打包产出,打包后上传等需求,在这种时候可能就需要做一些更复杂的工作了。
比如在一个常见的业务场景,需要实现以下功能:
版本更新;
同步更新信息到CHANGELOG;
区分环境打包;
区分平台打包;
打包后自动上传;
针对这些功能做一个流程设计:
输入版本号(不输入则自动patch)后,校验版本号是否正确,更新package.json
输入更新的信息和更新的描述,并同步写入到CHANGELOG
读取用户选择的环境,可以多选,并在后续的打包流程中针对不同环境实现不同的操作(环境变量注入等);
读取用户选择的平台,可以多选,在后续打包中只打包对应的平台;
打包结束后自动上传到服务器(以ftp为例);
接下来根据功能流程设计来划分功能模块:
Inquirer: 读取用户输入项并提供校验;
CommandExecutor: 执行命令行命令的函数;
JsonUpdater: 提供读写操作更新package.json和package-lock.json;
ChangelogUpdater: 更新CHANGELOG
Builder: 使用electron-builder进行打包操作
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" );export const readJSON = (path: string ) => () => readJsonSync(path);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; 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 , }, });
更新服务的设计 上面寥寥几行代码就实现了一个简单的更新功能,但是这个功能在复杂的业务场景中往往没有那么适合,因此我们在这里开始来设计一个贴合常见场景的更新方案。
需要实现的功能
查看更新信息
用户手动检查更新;
应用启动时静默检查更新;
应用在后台定时检查更新;
用户手动下载更新;
下载进度显示;
用户手动退出安装更新;
通过版本号控制强制更新;
日志;
开发时请求本地服务做测试;
更新流程 更新过程的所有状态:
状态
描述
Idle
空闲
Checking
检查中
Available
有可下载更新
Downloading
下载中
Downloaded
下载完成
状态流程如图
接口设计 根据上述功能,对更新服务做一个初步的设计
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 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 ; 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' ))); 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' ){ const result = confirm('Updates available, download instantly?' ) if (result){ downloadUpdate() } } if (status === 'update-downloaded' ){ const result = confirm('Download completed, apply updates?' ) if (result){ applyUpdate() } } }, [status]) return null }
一个基本的自动更新服务就完成了