基于Github Actions完成Electron自动打包、发布及更新

5,153 阅读10分钟

本文已参与「新人创作礼活动」,一起开启掘金创作之路。

大家好,我是爱吃鱼的桶哥。最近我一直在研究如何使用Github ActionsElectron进行自动打包和发布,之所以选择Github Actions来进行自动打包,也是因为它简单、高效、免费且可以同时打包多端应用,接来下我会一步一步教大家搭建一个属于自己的Electron打包发布平台。

我这次使用的技术栈是ReactElectron。通过React搭建基础应用,然后使用Electron来开发桌面端应用。如果你还不会用React,或者不了解Electron,可以去网上搜一下相关的教程,本篇文章不会从零开始教大家怎么去用React+Electron来开发应用,只是教大家搭建一个基础的开发+打包更新环境。

说到搭建基础开发环境,一般我们都会用React官方提供的create-react-app,当然你要是喜欢用Vite或者Webpack自己搭建一套环境,也是可以的,这个随个人的喜好。Vue的开发者也可以使用官方提供的Vue-Cli搭建,大致的流程都是一样的。

基础框架搭建

首先使用create-react-app初始化一个React应用,然后我们需要安装electron,以及后续需要用于打包和更新的包electron-builderelectron-updater,执行以下命令进行安装。

// 初始化应用
1. create-react-app electron-builder-demo
// 进入到应用中
2. cd electron-builder-demo
// 安装electron、electron-builder
3. npm i electron electron-builder -D
// 安装electron-updater
4. npm i electron-updater -S
// 安装electron-is-dev
5. npm i electron-is-dev -S

安装完上述内容后,基本的React环境就搭建好了,现在我们来配置一下Electron的环境。首先在package.json中有一个main字段,这个字段就是确定应用的启动文件是哪个,我们写上main.js,然后我们在项目根路径创建一个main.js文件,在里面写electron相关的代码。由于Electron12以上版本为了安全推荐我们不要在渲染层注入node相关的能力,因此我们使用preload来加载一个preload.js,通过这个第三方文件来为渲染层赋予node相关的能力,所以我们还需要在根路径下创建一个preload.jsmain.js的具体代码如下:

// main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const isDev = require('electron-is-dev');

// 初始化主窗口
let mainWindow = null;

// 创建主应用
const createWindow = () => {
    mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: false,
            contextIsolation: true,
            preload: path.join(__dirname, 'preload.js'),
        },
    });
    
    // 文件加载路径,不同环境下加载不同的路径
    const url = isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, './build/index.html')}`;
    
    // 如果是开发环境下,我们就打开控制台,当然你也可以注释掉
    if (isDev) {
        mainWindow.webContents.openDevTools();
    }
    
    // 加载应用
    mainWindow.loadURL(url);
    
    // 应用被关闭时,释放主窗口
    mainWindow.on('closed', () => {
        mainWindow = null;
    });
    
    return mainWindow;
};

// 当应用已经加载完成后,创建主窗口
app.on('ready', createWindow);

// 当所有窗口关闭后,应用退出
app.on('window-all-closed', () => {
    app.quit();
});

preload.js是后续主应用和渲染层做通信以此完成应用自动更新使用的,目前先创建一个空文件即可。

以上内容就是一个基础的Electron代码了,接下来我们需要让这个代码跑起来,那我们该怎么做呢?

我们再次打开package.json文件,在scripts下新增一条命令:

"dev": "electron ."

同时我们需要执行npm startnpm run dev,就可以看到React启动了,默认打开浏览器窗口3000,并且Electron也启动了,但是页面会是白的,因为React先启动了,而Electron加载的是React渲染的页面,会有一个延迟效果,咱们只需要在控制台里面按一下ctrk+f5,刷新一下页面,就可以看到最基础的应用是什么样的了。

每次启动都需要执行两个命令,那我们有没有办法简化一下,只执行一个命令就让ReactElectron同时跑起来呢?答案当然是有的,我们只需要安装以下这些包,然后修改一下dev的运行命令即可,代码如下:

// 安装concurrently,用于同时执行多个命令
1. npm i concurrently -D
// 安装wait-on,用于等待某个命令执行
2. npm i wait-on -D
// 安装cross-env 用于跨平台环境变量设置
3. npm i cross-env -D

安装完上述的包后,我们修改一下dev的运行命令:

"dev": "concurrently \"wait-on http://localhost:3000 && electron .\" \"cross-env BROWSER=none npm start\""

上述命令中的双引号需要进行转译,因此需要加上反斜杠。

修改完dev的执行命令后,我们重新执行一次npm run dev,就可以看到应用能够正常的启动,并且不会在浏览器中打开一个新窗口,当React应用启动完成后,Electron应用才会加载,这样就不会出现我们一开始的白屏了。接下来我再讲一下如何打包Electron应用。

打包Electron

在前面的内容中我们已经把打包需要用到的包安装了,它就是electron-builder。使用它做最简单的打包,不需要任何的配置。我们在package.json下再新增两条命令:

"pack": "electron-builder --dir",
"dist": "electron-builder -p always"

当我们执行npm run pack时,electron-builder会帮我们把应用进行简单的打包,只生成一个不需要安装的执行文件;而当我们执行npm run dist时,electron-builder会帮我们生成对应需要安装才能执行的文件,并且还会生成一个latest.yml文件,这个文件是后续用于自动升级的,这里先不说。

如果大家跟着我一步步走到现在,那么一个最简单的electron打包应用就完成了,但是我们要的可不仅仅只是一个这么简单的demo。因为使用默认的打包方案还是有很多问题的。

首先应用的icon是使用默认的,我们想要改成我们自己想要的icon;其次打包后的应用安装路径是默认的,我们也希望可以进行修改;最后默认的打包方案会时打包出来的安装包体积较大,我们也是需要优化的,因此我们要进行自定义的打包配置,具体的配置内容大家可以看一下官方文档,这里我就直接把配置项列出来,并会加上相关的注释,在package.json文件中添加如下内容,具体配置如下:

"build": {
    "appId": "electronBuilderDemo", // 应用的唯一id
    "productName": "electron-builder-demo", // 应用的名称
    "files": [  // 打包后需要包含的文件内容
        "build/**/*",
        "node_modules/**/*",
        "package.json",
        "main.js",
        "preload.js",
    ],
    "directories": {  // 目录
        "output": "dist",  // 打包出来的文件目录
        "buildResources": "assets"  // 打包的资源
    },
    "extends": null, 
    "mac": {  // mac下打包相关配置项
        "category": "public.app-category.productivity",
        "artifactName": "${productName}-${version}-${arch}.${ext}",
        "target": [
            "dmg",
            "zip"
        ]
    },
    "dmg": {  // Mac下打包成dmg文件相关配置内容
        "background": "assets/appdmg.png",
        "icon": "assets/icon.icns",
        "iconSize": 100,
        "contents": [  // 安装时展示的内容
            {
                "x": 380,
                "y": 280,
                "type": "link",
                "path": "/Applications"
            },
            {
                "x": 110,
                "y": 280,
                "type": "file"
            }
        ],
        "window": {
            "width": 500,
            "height": 500,
        }
    },
    "win": {  // windows下打包相关配置项
        "target": [
            "msi",
            "nsis",
            "zip",
        ],
        "verifyUpdateCodeSignature": false,  // 这里如果不设置为false,后续更新的时候会验证签名,所以需要设置为false
        "icon": "assets/icon.ico",
        "artifactName": "${productName}-Web-Setup-${version}.${ext}"
    },
    "nsis": {  // 打包成nsis时相关配置项
        "allowToChangeInstallationDirectory": false,
        "oneClick": false,
        "perMachine": false,
    },
    "asar": false
}

在上述的打包配置中,我只写了简单的注释,大家可以按照我的这个配置内容来,如果需要更加自定义的配置,可以参考electron-builder的官方文档,里面包含了各种详细的配置信息。

进过上述的配置后,我们可以进行打包了,然后我们再执行一次npm run dist,这时候打包出来的文件大小已经比默认打包时小了很多,而且我们也设置了自定义的应用icon,具体的代码是"icon": "assest/icon.ico"

既然打包已经成功了,那么接下来就需要做到自动发布及更新了。

自动更新

说到更新,其实electron官方也给我们提供了它的更新方案,但是着实不是太好用,并且electron-builder官方也提供了相关的更新方案,也就是electron-updater,接下来我们需要修改一下main.js,让我们的应用能够在检测到新版本时自动完成应用的更新。

我们打开main.js,在这个文件中加入如下代码:

...
const { autoUpdater } = require('electron-updater');
...

... other code

// 定义返回给渲染层的相关提示文案
const message = {
    error: '检查更新出错',
    checking: '正在检查更新……',
    updateAva: '检测到新版本,正在下载……',
    updateNotAva: '现在使用的就是最新版本,不用更新',
};

// 这里是为了在本地做应用升级测试使用
if (isDev) {
    autoUpdater.updateConfigPath = path.join(__dirname, 'dev-app-update.yml');
}

// 主进程跟渲染进程通信
const sendUpdateMessage = (text) => {
    // 发送消息给渲染进程
    mainWindow.webContents.send('message', text);
};

// 设置自动下载为false,也就是说不开始自动下载
autoUpdater.autoDownload = false;
// 检测下载错误
autoUpdater.on('error', (error) => {
    sendUpdateMessage(`${message.error}:${error}`);
});
// 检测是否需要更新
autoUpdater.on('checking-for-update', () => {
    sendUpdateMessage(message.checking);
});
// 检测到可以更新时
autoUpdater.on('update-available', () => {
    // 这里我们可以做一个提示,让用户自己选择是否进行更新
    dialog.showMessageBox({
        type: 'info',
        title: '应用有新的更新',
        message: '发现新版本,是否现在更新?',
        buttons: ['是', '否']
    }).then(({ response }) => {
        if (response === 0) {
            // 下载更新
            autoUpdater.downloadUpdate();
            sendUpdateMessage(message.updateAva);
        }
    });
    
    // 也可以默认直接更新,二选一即可
    // autoUpdater.downloadUpdate();
    // sendUpdateMessage(message.updateAva);
});
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
    // 这里可以做静默处理,不给渲染进程发通知,或者通知渲染进程当前已是最新版本,不需要更新
    sendUpdateMessage(message.updateNotAva);
});
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
    // 直接把当前的下载进度发送给渲染进程即可,有渲染层自己选择如何做展示
    mainWindow.webContents.send('downloadProgress', progress);
});
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', () => {
    // 给用户一个提示,然后重启应用;或者直接重启也可以,只是这样会显得很突兀
    dialog.showMessageBox({
        title: '安装更新',
        message: '更新下载完毕,应用将重启并进行安装'
    }).then(() => {
        // 退出并安装应用
        setImmediate(() => autoUpdater.quitAndInstall());
    });
});
// 我们需要主动触发一次更新检查
ipcMain.on('checkForUpdate', () => {
    // 当我们收到渲染进程传来的消息,主进程就就进行一次更新检查
    autoUpdater.checkForUpdates();
});
// 当前引用的版本告知给渲染层
ipcMain.on('checkAppVersion', () => {
    mainWindow.webContents.send('version', app.getVersion());
});

写到这里,我们只完成了主进程的更新代码,而主进程的更新是需要通知到渲染层才能展示给用户看到的。前面我们也说了,electron12以后的版本不推荐直接在渲染层获取electron上的方法进行通信,那我们该如何在主进程和渲染进程之间进行通信以便展示我们的更新内容呢?

还记得我们前面预留的preload.js吧!,前面我们也简单介绍了一下该文件,这个文件其实起到的一个作用就是作为一个中间桥梁,方便我们在渲染层使用electron相关的能力,同时又不会把node相关的操作能力暴露给渲染层,大大加强了我们应用的安全性,那么preload.js具体该怎么操作呢?下面的代码会详细的介绍。

const { contextBridge, ipcRenderer } = require('electron');

const ipc = {
    render: {
        // 主进程发出的通知
        send: [],
        // 渲染进程发出的通知
        receive: [],
    },
};

// 通过contextBridge将electron注入到渲染进程的window上面,我们只需要访问window.electron,即可访问到相关的内容
contextBridge.exposeInMainWorld('electron', {
    ipcRenderer,
    ipcRender: {
        // 主进程发送通知给渲染进程
        send: (channel, data) => {
            const validChannels = ipc.render.send;
            if (validChannels.includes(channel)) {
                ipcRenderer.send(channel, data);
            }
        },
        // 渲染进程监听到主进程发来的通知,执行相关的操作
        receive: (channel, func) => {
            const validChannels = ipc.render.receive;
            if (validChannels.includes(channel)) {
                ipcRenderer.on(`${channel}`, (event, ...args) => func(...args));
            }
        }
    }
});

由于我们无法直接在渲染进程中调用electronipcRenderer进行通信,所以我们只能借助preload.js这个第三方文件。

最后我们再来说一下在渲染进程中如何进行更新操作。

React应用中,有一个app.js,在这个文件中,咱们可以做electron应用的自动更新检测,只需要在页面加载完毕后,渲染进程给主进程主动发送一个通知,主进程就会自动去检测当前版本以及线上版本是否相同,如果不同就会给渲染进程通知,告诉渲染进程有新版本可以进行更新,具体代码如下:

import React, { useState, useEffect } from 'react';
const { ipcRender } = window.electron;

const App = () => {
    // 页面上的提示信息
    const [text, setText] = useState('');
    // 当前应用版本信息
    const [version, setVersion] = useState('0.0.0');
    // 当前下载进度
    const [progress, setProgress] = useState(0);
    
    useEffect(() => {
        // 给主进程发通知,让主进程告诉我们当前应用的版本是多少
        ipcRender.send('checkAppVersion');
        // 接收主进程发来的通知,检测当前应用版本
        ipcRender.receive("version", (version) => {
            setVersion(version);
        });
        
        // 给主进程发通知,检测当前应用是否需要更新
        ipcRender.send('checkForUpdate');
        // 接收主进程发来的通知,告诉用户当前应用是否需要更新
        ipcRender.receive('message', data => {
            setText(data);
        });
        // 如果当前应用有新版本需要下载,则监听主进程发来的下载进度
        ipcRender.receive('downloadProgress', data => {
            const progress = parseInt(data.percent, 10);
            setProgress(progress);
        });
    }, []);
    
    return (
        <div>
            <p>current app version: {version}</p>
            <p>{text}</p>
            {progress ? <p>下载进度:{progress}%</p> : null}
        </div>
    )
};

export default App;

上述的代码中,我们给主进程发通知,并且接收主进程发来的通知,由于前面说了我们无法直接发送,需要通过preload.js来进行一次转发,因此我们还需要修改一下preload.js以便我们的代码能够正常的运行。

// preload.js
const ipc = {
    render: {
        // 主进程发出的通知
        send: ['checkForUpdate', 'checkAppVersion'],
        // 渲染进程发出的通知
        receive: ['version', 'downloadProgress'],
    },
};

只有在ipc对象中存在的通知,主进程才会接收到。

写到这里,其实我们还差一步,那就是部署到服务器才能做应用的版本检测及更新,那么我们需要有一个自己的服务器吗?答案是否定的,当前你如果有自己的服务器也可以,只需要将打包后的文件上传到自己的服务器,并修改一下package.json中的相关配置,即可通过自己的服务器进行更新下载,但是我们这里选择使用GitHub Actions来进行自动更新和打包。

我们打开package.json,在"build"配置中继续添加如下的配置信息:

"build": {
    ...other code
    "publish": [
        {
            "provider": "github",
            "owner": "你的GitHub用户名",
            "repo": "你的GitHub上的项目名称"
        }
    ]
}

通过publish设置,我们告诉electron-updater我们的应用更新的服务器是什么,当然这里也可以不用GitHub,还有很多其它的选择,具体的选择范围,大家可以参考electron-updater的官方文档。

自动打包

自动更新讲完了,但是目前为止还不能做到自动更新,因为应用还没有打包发到服务器上面去,自动更新其实是拉取远程的包,并判断本地应用的版本跟远程应用的版本是否一致,如果不一致才需要走自动更新的流程。前面我也说了我们需要用到GitHub Actions来做自动打包,那么GitHub Actions该怎么使用呢?大家可以看一下阮一峰老师的GitHub Actions 入门教程,里面有大概的介绍,我这里就不做过多的赘述,按照阮一峰老师的教程把基础的配置都做好,我们只需要添加对应的文件即可,在根路径下需要创建一个.github文件夹,然后需要在里面继续创建workflows文件夹,最后在workflows文件夹中创建一个执行文件build.yml,也可以叫其它任意名字,只要后缀是yml即可。具体的代码如下:

// build.yml
name: Build

on:
    push:
        branches:
          - master

jobs:
    release:
        name: build and release electron app
        runs-on: ${{ matrix.os }}
        
    strategy:
        fail-fast: false
        matrix:
          os: [windows-latest, macos-latest, ubuntu-latest]
            
    steps:
        - name: Check out git repository
          uses: actions/checkout@v3.0.0
        
        - name: Install Node.js
          uses: actions/setup-node@v3.0.0
          with:
            node-version: "16"
            
        - name: Install Dependencies
          run: npm install
          
        - name: Build Electron App
          run: npm run dist
          env:
            GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
        
        - name: Cleanup Artifacts for Windows
          if: matrix.os == 'windows-latest'
          run: |
            npx rimraf "dist/!(*.exe)"
        
        - name: Cleanup Artifacts for MacOS
          if: matrix.os == 'macos-latest'
          run: |
            npx rimraf "dist/!(*.dmg)"
            
        - name: upload artifacts
          uses: actions/upload-artifact@v3.0.0
          with:
            name: ${{ matrix.os }}
            path: dist
            
        - name: release
          uses: softprops/action-gh-release@v0.1.14
          if: startsWith(github.ref, 'refs/tags/')
          with:
            files: "dist/**"
          env:
            GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

当我们把上述文件创建后,需要到GitHub上面创建一个项目,然后把我们这个项目的远程仓库指向我们刚刚在GitHub创建的项目即可。然后就可以执行代码的提交了,当代码提交到GitHub后,因为我们添加了自动的执行文件build.yml,当GitHub检测到在workflows中带有yml的文件后,就会自动执行里面的命令,从而帮助我们进行项目的打包。

需要注意一点的是,我们需要把代码提交到master分支,GitHub才会自动帮我们进行打包,或者将上述代码中的branches下的master换成你自己的分支名称,当匹配到对应的分支名称后,就会自动进行打包。

打包成功后,我们需要在GitHub中这个项目的右侧找到Releases,点击进入,我们会看到一个预发布的按钮,点击它进入到发布页面,添加上当前发布版本的更新内容,然后点击底部的确定按钮,最终应用就完成了自动的打包和发布。

WX20220506-224353@2x.png

打包并完成发布后,在GitHub上看到的就是这样子的,如上图所示。我这里面包含了Windows端Mac端Linux端的安装包,因此生成的安装包比较多,如果你的应用不需要这么多端的打包,只需要在build.yml中删除对应的平台即可。

我们可以测试一下自动更新是否生效了。首先在本地安装一下当前版本的应用,然后再把package.json里面的版本号升级一下,并重新提交到GitHub,当应用重新打包完成后,我们打开本地的应用时,就会自动检测到远程有新的版本,并会提示我们有新的版本是否需要进行安装。

当然我们也不可能总是这么做测试,频繁的提交并且自动打包也是很耗时的事,那我们有没有好的办法能够测试本地应用自动更新呢?答案当然是有的,还记得我们在前面留了一个dev-app-update.yml文件吧,这个文件里面的内容其实就是远程的更新内容,我们只需要在这个文件中写入以下内容即可:

provider: github
owner: 你的GitHub用户名
repo: 你的GitHub中的项目名称

然后我们只需要再次执行npm run dev,并且保证本地package.json中的版本低于GitHub线上的版本,当应用启动后,就会自动检测远程的版本了,并执行前面说的自动更新流程了。

关于自动更新,还需要说明的一点就是,Windows端的自动更新可以不需要证书,但是Mac端的自动更新如果没有证书就一定无法完成,至于Linux端的自动更新是否需要证书,这个就需要Linux端的用户自行验证了,我这边没有Linux的系统,因此无法验证,有验证过的小伙伴可以在下面的评论区给我留言。

至此,一个基本的GitHub Actions + Electron builder自动打包 + Electron updater自动更新流程就完成了,当然我这只是一个比较简单的打包流程,但是已经完全足够真实的应用打包发布使用了。其实我们这里还可以加更多的内容,例如:typescriptvite等等,只要掌握了打包的基本套路,不管再加什么内容,也都是一样的模式。

当然GitHub Actions其实还能做很多有意思的事,它本身就是一个自动化的CICD,大家如果对CICD感兴趣的也可以自行搜索学习。

结尾

这是我在掘金写的第一篇文章,之前一直都在用Electron开发项目,只是版本比较老,因此没有进行更新。现在Electron已经更新到了18版本,是时候需要重新再学习一下了,后面我会继续学习并更新更多Electron相关的文章。感谢大家的支持和点赞,如果有写的不对或不好的地方,请大家多多评论和指教。

完整的代码在我的GitHub上面,具体地址:electron-demo,求star,求赞!!!

如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,谢谢大家

参考文献

GitHub Actions 入门教程

electron require is not define

Electron应用使用electron-builder配合electron-updater实现自动更新

Electron+React+七牛云 实战跨平台桌面应用 - 第12章 该课程版本较老,可以参考打包内容