electron实现软件(热)更新(附带示例源码)

热更新指的是:electron 程序已经开启,在不关闭的情况下执行更新,需要我们把远程的app.asar文件下载到本地执行替换,然而 在electron应用程序开启状态是无法直接下载app.asar文件的,下载会检查出app.asar文件被占用,所以我们需要在本地将app.asar文件反编译,编译出一个app文件夹,里面有项目所需的所有源码文件,这时通过vue或react打包的渲染进程代码是经过压缩的,但是主进程代码会直接暴露,所以刚好,我们可以将主进程代码做压缩混淆,然后生成一个app.zip压缩包,上传至服务器,然后下载这个压缩包,解压,将其编译成app.asar文件替换到resources目录中,从而实现electron软件的热更新。

  1. 主进程(nodejs)侧代码:
const {app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const fs = require("fs");
const http = require("http");
const asar = require('asar');
const AdmZip = require('adm-zip');
const fsExtra = require('fs-extra');

const mainData = require("./mainData");
const txtConsole = require("./txtConsole");

//当前环境
const production = mainData?.production;

//Electron 安装根目录
const rootPath = production === 'dev' ? path.resolve('./public') : path.dirname(app.getPath('exe'));
mainData.rootPath = rootPath;

// 软件更新配置信息(目前需要手动修改~~~)
const winUpdateConfig = {
    currentVersion: null, //当前版本
    updateVersionFilePath: 'http://103.117.121.53:8002/latest', //远程版本信息路径
    updateFilePath: 'http://103.117.121.53:8002/app.zip', //远程包路径
    localUpdateVersionFilePath: production === 'dev' ? `${rootPath}/latest` : `${rootPath}/resources/latest`, //本地版本信息路径
    localUpdateFilePath: production === 'dev' ? rootPath : `${rootPath}/resources`, //本地包路径
    updateSteps: [
        {id: 1, desc: '开始下载并解压更新文件,请勿重启!', active: 'active'},
        {id: 2, desc: '下载并解压完成, 开始覆盖安装!', active: 'wait'},
        {id: 3, desc: '更新完毕, 即将重启,请稍候!(第3步完成后也可以手动重启)', active: 'wait'},
    ], //更新步骤  active:正在进行  wait:等待   success:执行成功  error: 执行失败
};

let versionInfo = ''; //获取最新版本信息
let locallatest = ''; //本地版本号

function appInit() {
    txtConsole.log('初始化');

    try {
        locallatest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');

        locallatest = JSON.parse(locallatest);

        //设置当前版本信息
        winUpdateConfig.currentVersion = locallatest?.version;

        txtConsole.log('已设置当前版本信息', locallatest?.version);

        //删除日志
        txtConsole.clearLog();
    } catch (err) {
        txtConsole.log(err);
    }
}

//创建主窗口
function createWindow() {
    const mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            // preload: path.join(__dirname, 'preload.js'),
            nodeIntegration: true,
            contextIsolation: false,
            webSecurity: false, //禁用同源策略
        }
    });

    mainWindow.loadFile('index.html').then();

    // 打开开发者工具 控制台
    if (mainData.winControl === 'dev') {
        mainWindow.webContents.openDevTools();
    }

    // 检查更新
    ipcMain.on('window-version', function (event) {
        try {
            txtConsole.log('检查更新', versionInfo);

            if (!versionInfo) {
                !event?.sender?.isDestroyed() &&
                event?.sender?.send('window-version-err-msg', '更新文件读取失败');
                return;
            }

            const v = {...(versionInfo || {})};

            //最新版本号
            let firNewVersion = versionInfo?.version?.split('.')?.[0]; //第一位
            let secNewVersion = versionInfo?.version?.split('.')?.[1]; //第二位
            let thiNewVersion = versionInfo?.version?.split('.')?.[2]; //第三位

            //当前版本号
            let firOldVersion = versionInfo?.currentVersion?.split('.')?.[0]; //第一位
            let secOldVersion = versionInfo?.currentVersion?.split('.')?.[1]; //第二位
            let thiOldVersion = versionInfo?.currentVersion?.split('.')?.[2]; //第三位

            //按位比较是否需要更新
            if (Number(firNewVersion || 10000) > Number(firOldVersion || 10000)) {
                v['versionVisible'] = true;
            }
            else if (Number(secNewVersion || 10000) > Number(secOldVersion || 10000)) {
                v['versionVisible'] = true;
            }
            else if (Number(thiNewVersion || 10000) > Number(thiOldVersion || 10000)) {
                v['versionVisible'] = true;
            }
            else {
                v['versionVisible'] = false;
                v['currentVersion'] = versionInfo?.version;
            }

            if (!v['versionVisible']) {
                let latest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');

                latest = JSON.parse(latest);

                latest.version = versionInfo?.version || latest?.version;
                latest.currentVersion = versionInfo?.version || latest?.version;

                fs.writeFileSync(winUpdateConfig.localUpdateVersionFilePath, JSON.stringify(latest));

                txtConsole.log('hot: ', latest.version);
            }

            txtConsole.log('versionVisible=> ', v['versionVisible']);

            !event?.sender?.isDestroyed() && event?.sender?.send('window-version-msg', v);
        } catch (err) {
            !event?.sender?.isDestroyed() &&
            event?.sender?.send('window-version-err-msg', '更新文件读取失败');

            txtConsole.log('检查更新err:', err);
        }
    });

    // 下载更新文件
    ipcMain.on('window-download-newfile', function (event) {
        txtConsole.log('开始下载并解压更新文件  热更新');

        event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);

        const file = fs.createWriteStream(
            path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'),
        );

        let downloadedBytes = 0;
        let totalBytes = 0;

        http.get(winUpdateConfig.updateFilePath, (response) => {
            totalBytes = parseInt(response?.headers['content-length'], 10);
            let prevTimestamp = Date.now();

            response?.on('data', (chunk) => {
                downloadedBytes += chunk.length;

                const timestamp = Date.now();
                const timeDiff = timestamp - prevTimestamp;

                // 每1.5秒钟更新一次进度
                if (timeDiff >= 1500) {
                    const progress = ((downloadedBytes / totalBytes) * 100).toFixed(2);
                    txtConsole.log(`下载进度:${progress}% `, totalBytes);
                    prevTimestamp = timestamp;

                    event?.sender?.send('window-download-progress-msg', Math.min(Number(progress), 80));
                }
            });

            response?.pipe(file);
        }).on('error', (err) => {
            txtConsole.log(`下载错误: ${err.message}`);

            event?.sender?.send('window-download-newfile-err-msg', '更新文件下载失败');
        });

        file?.on('finish', function () {
            event.sender.send('window-download-progress-msg', 90);

            winUpdateConfig.updateSteps[0]['active'] = 'success';
            winUpdateConfig.updateSteps[1]['active'] = 'active';

            event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);

            // 文件已经完全写入磁盘,开始解压
            try {
                const zip = new AdmZip(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'), void 0);

                zip.extractAllTo(winUpdateConfig.localUpdateFilePath, true, void 0, void 0);
            } catch (err) {
                txtConsole.log('解压异常 error: ', err);

                !event?.sender?.isDestroyed() &&
                event?.sender?.send('window-download-newfile-err-msg', '解压异常');

                return;
            }

            winUpdateConfig.updateSteps[1]['active'] = 'success';
            winUpdateConfig.updateSteps[2]['active'] = 'active';

            event?.sender?.send('window-download-newfile-msg', winUpdateConfig.updateSteps);

            event?.sender?.send('window-download-progress-msg', 95);

            const sourceDir = path.join(winUpdateConfig.localUpdateFilePath, 'apps');
            const destPath = path.join(winUpdateConfig.localUpdateFilePath, 'app.asar');

            asar.createPackage(sourceDir, destPath).then(() => {
                if (fs.existsSync(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'))) {
                    fs.unlinkSync(path.resolve(winUpdateConfig.localUpdateFilePath, 'app.zip'));
                }

                txtConsole.log('更新完毕');
                event.sender.send('window-download-progress-msg', 100);

                winUpdateConfig.updateSteps[2]['active'] = 'success';
                event?.sender?.send(
                    'window-download-newfile-msg',
                    winUpdateConfig.updateSteps,
                    'success',
                );

                //设置当前版本信息
                try {
                    let latest = fs.readFileSync(winUpdateConfig.localUpdateVersionFilePath, 'utf-8');

                    latest = JSON.parse(latest);

                    latest.version = versionInfo.version;

                    fs.writeFileSync(winUpdateConfig.localUpdateVersionFilePath, JSON.stringify(latest));

                    txtConsole.log('更新后已设置当前版本信息', latest?.version);

                    //删除apps文件夹  防止执行文件夹内的代码
                    deleteFolderRecursive(sourceDir);
                } catch (err) {
                    txtConsole.log(err);
                }
            }).catch((err) => {
                txtConsole.log('创建asar文件失败: ', err);

                event.sender.send('window-download-newfile-err-msg', 'asar文件创建失败');
            });
        });

        file?.on('error', function (err) {
            txtConsole.log('更新asar=>Error: ', err);
            event.sender.send('window-download-newfile-err-msg', err);
        });
    });
}

//检查更新
function checkUpdate(callback) {
    txtConsole.log('检查更新');

    http.get(winUpdateConfig.updateVersionFilePath, (res) => {
        res.on('data', (chunk) => {
            versionInfo += chunk;
        });

        res.on('end', () => {
            try {
                if (versionInfo && versionInfo?.indexOf('404 Not Found') < 0) {
                    versionInfo = JSON.parse(versionInfo);

                    winUpdateConfig.updateFilePath = versionInfo.updateFilePath;

                    //热更最新信息
                    let asarVersionInfo = {
                        newVersionDesc: versionInfo.newVersionDesc,
                        currentVersion: winUpdateConfig.currentVersion,
                    };

                    versionInfo.currentVersion = winUpdateConfig.currentVersion;

                    let writeNewVersonInfo;

                    //不存在则创建latest文件
                    if (!fs.existsSync(winUpdateConfig.localUpdateVersionFilePath)) {
                        writeNewVersonInfo = versionInfo;

                        txtConsole.log('latest文件重新创建成功');
                    }
                    else {
                        let currentVersion = fs.readFileSync(
                            winUpdateConfig.localUpdateVersionFilePath,
                            'utf8',
                        );

                        currentVersion = JSON.parse(currentVersion);

                        currentVersion['updateFilePath'] = '';

                        //只覆盖热更版本信息
                        writeNewVersonInfo = {...currentVersion, ...asarVersionInfo};
                    }

                    //将整理好的配置文件信息写入
                    fs.writeFileSync(
                        winUpdateConfig.localUpdateVersionFilePath,
                        JSON.stringify(writeNewVersonInfo),
                    );

                    // txtConsole.log('已将新的更新配置文件信息写入:', JSON.stringify(writeNewVersonInfo));
                    txtConsole.log(
                        `更新检查完毕:最新版本:${versionInfo.version}, 当前版本:${asarVersionInfo.currentVersion}`,
                    );
                    txtConsole.log('-------------------------------------------------------');

                    callback?.(null, versionInfo);
                }
                else {
                    txtConsole.log('更新配置文件读取失败');

                    callback?.('更新配置文件读取失败');
                }
            } catch (err) {
                txtConsole.log('更新配置文件覆写失败');

                callback?.('更新配置文件覆写失败');
            }
        });
    }).on('error', (error) => {
        txtConsole.log(`更新配置文件下载失败: ${error.message}`);

        callback?.('更新配置文件下载失败');
    });
}

//删除更新文件
function deleteFolderRecursive(folderPath) {
    if (fs.existsSync(folderPath)) {
        fs.readdirSync(folderPath).forEach((file) => {
            const curPath = path.join(folderPath, file);

            if (fs.lstatSync(curPath).isDirectory()) {
                // 递归删除子文件夹
                deleteFolderRecursive(curPath);
            }
            else {
                // 删除文件
                fs.unlinkSync(curPath);
            }
        });

        // 删除子文件夹后删除文件夹本身
        fs.rmdirSync(folderPath);
    }
}


app.whenReady().then(() => {
    //初始化
    appInit();

    //检查更新
    checkUpdate(async (check, versionInfo = {}) => {
        if (check) {
            txtConsole.log('检查更新执行失败');
        }
        else {
            txtConsole.log('检查更新执行成功');
        }

        createWindow();

        app.on('activate', function () {
            if (BrowserWindow.getAllWindows().length === 0) createWindow()
        })
    });
})

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


  1. 渲染进程侧代码(以原生为例):
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
    <title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
<button onclick="onCheckUpdate()">检查更新</button>
<button onclick="onUpdateVersion()">测试更新</button>

<div class="updateInfo"></div>
<div class="descInfo"></div>

<script>
    const {ipcRenderer} = require("electron");

    const onVersion = {
        updateDsec: [],
        process: 0,
    };

    //检查更新
    function onCheckUpdate() {
        ipcRenderer?.send('window-version');
        ipcRenderer?.once('window-version-msg', (_, bool) => {
            document.querySelector('.updateInfo').innerHTML = JSON.stringify(bool);
        });
        ipcRenderer?.once('window-version-err-msg', (_, err) => {
            document.querySelector('.updateInfo').innerHTML = err;
        });
    }

    //测试更新
    function onUpdateVersion() {
        onVersion.updateDsec = [];

        ipcRenderer?.send('window-download-newfile');

        //监听下载版本信息
        ipcRenderer?.on('window-download-newfile-msg', (event, updateDsec, status) => {
            if (!event.handel) {
                event.handel = true;

                onVersion.isStartUpdate = true;
                onVersion.updateDsec = updateDsec;
                document.querySelector('.descInfo').innerHTML = updateDsec.map(item => `<span style="color:${item.active === 'success' ? 'green' : 'orangered'}">${item.desc}</span>`).join('</br>');

                if (status === 'success') {
                    ipcRenderer?.send('window-restart-app');
                }

                onVersion.visible = true;
            }
        });

        //监听更新包下载进度
        ipcRenderer?.on('window-download-progress-msg', (event, process) => {
            if (!event.handel) {
                event.handel = true;
                document.querySelector('.updateInfo').innerHTML = String("完成进度:" + process + '%');
            }
        });

        //监听下载版本错误信息
        ipcRenderer?.once('window-download-newfile-err-msg', (event, res) => {
            console.log(res)
        });
    }
</script>
</body>
</html>

  1. 轮子(实现压缩混淆反编译):
//生成 反编译app.asar  并生成压缩包
const asar = require('asar');
const path = require('path');
const fs = require('fs');
const fsExtra = require('fs-extra');
const zlib = require('zlib');
const archiver = require('archiver');
const uglify = require('uglify-js');
const moment = require('moment');
const {exec} = require('child_process');
const JavaScriptObfuscator = require('javascript-obfuscator');

const mainData = require('./mainData');

const startTime = moment().unix(); //秒级时间戳

const rootPath = path.resolve(__dirname); // 获取项目根路径
const asarPath = './build/win-ia32-unpacked/resources/app.asar'; // 获取 app.asar 文件路径
const sourceDir = './apps'; // 要压缩的文件夹路径
const asarAppPath = './apps/apps'; // asar反编译文件的存放路径
const buildPath = './build'; //electron 打包后的build文件夹
const destFile = './app.zip'; // 压缩后的文件路径
const publicLogPath = './public/log.txt';
const publicAsarPath = './public/app.asar';

// 配置环境路径为项目根路径
const env = Object.assign({}, process.env, {
    PATH: rootPath + ';' + process.env.PATH,
    npm_config_prefix: 'C:\\Program Files\\nodejs\\npm', // 这里是你的 npm 安装路径
});

const Console = {
    log(p1 = '', p2 = '', p3 = '', p4 = '', p5 = '') {
        console.log(`${moment().format('HH:mm:ss')}  |  ${p1}${p2}${p3}${p4}${p5}`);
    },
};


//压缩主进程 main.js 相关代码
function zipMainJS() {
    try {
        const dir = path.resolve(asarAppPath, 'main.js');

        const dirJs = fs.readFileSync(dir, 'utf8');

        //压缩代码 mangle: true,
        const result = uglify.minify(dirJs, {
            mangle: {
                toplevel: true,
            },
        });

        // 混淆代码
        const obfuscationResult = JavaScriptObfuscator.obfuscate(result.code, {
            compact: true,
            controlFlowFlattening: true,
            controlFlowFlatteningThreshold: 0.75,
            numbersToExpressions: true,
            simplify: true,
            shuffleStringArray: true,
            splitStrings: true,
            stringArrayThreshold: 0.75,
        });

        fs.writeFileSync(dir, obfuscationResult.getObfuscatedCode());

        return true;
    } catch (err) {
        Console.log(err);
        return false;
    }
}

//添加开始执行 app.asar反编译逻辑
async function init() {
    //执行app.asar 反编译、压缩、混淆
    Console.log('正在执行app.asar 反编译、压缩、混淆');

    // 将 app.asar 解压缩到指定文件夹中
    asar.extractAll(asarPath, asarAppPath);
    Console.log('正在压缩app文件夹到项目根目录');

    //压缩main.js相关代码
    let zipRes = zipMainJS();

    if (!zipRes) {
        Console.log('!!!压缩main.js主进程代码失败!');
        return;
    }

    Console.log('主进程相关代码压缩完毕');

    //再次生成 app.asar
    asar.createPackage(asarAppPath, asarPath).then(() => {
        Console.log('已再次生成 app.asar 文件(代码压缩后的asar文件)');

        onAppZip();
    });
}

function onAppZip() {
    // 创建一个可写流,将压缩后的文件写入到目标文件中
    const destStream = fs.createWriteStream(destFile);

    // 创建一个 archiver 实例
    const archive = archiver('zip', {
        zlib: {level: zlib.constants.Z_BEST_COMPRESSION},
    });

    // 将可写流传递给 archiver 实例
    archive.pipe(destStream);

    // 将要压缩的文件夹添加到 archiver 实例中
    archive.directory(sourceDir, false, null);

    // 完成压缩并关闭可写流
    archive.finalize();

    // 监听可写流的 'close' 事件,表示压缩完成
    destStream.on('close', () => {
        Console.log(`压缩完毕,压缩包路径:【${path.resolve(__dirname, destFile)}`);

        Console.log('共用时:' + (moment().unix() - startTime) + '秒');
    });
}

try {
    if (mainData?.production === 'dev') {
        throw "请将环境切换为生产环境 mainData.js => 【const production = 'pro';】";
    }

    if (mainData?.winControl === 'dev') {
        throw '请关闭主窗口调试控制台!' + 'winControl';
    }

    fsExtra.removeSync(buildPath);
    Console.log('已删除build文件夹内容');

    fsExtra.removeSync(publicLogPath);
    Console.log('已删除public/log.txt');

    fsExtra.removeSync(publicAsarPath);
    Console.log('已删除public/app.asar');

    fsExtra.removeSync(asarAppPath);
    Console.log('已删除apps');

    //执行 打包命令
    Console.log('正在执行【npm run packager32】命令');
    exec('npm run packager32', env, (error, stdout, stderr) => {
        if (error) {
            Console.log(`执行出错: ${error}`);
            return;
        }

        stderr && Console.log('【npm run packager32】 stderr=>', stderr);

        //生成app.
        init();
    });

} catch (err) {
    Console.log(err);
}

示例Demo: https://github.com/qglovehy/electron-updater.git

  • 12
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值