结合 后端接口 实现自动更新
简介
通过使用 electron-builder 与和 electron-updater,结合 后端接口 可以实现自动更新。
步骤 1: 创建对应后端接口
步骤 2: 配置 electron-builder.yml 文件
js
publish:
- provider: generic
url: 'your .exe file download_url 目录'
updaterCacheDirName: 'file name'
步骤 3: 配置 upload.ts 文件
js
iimport { app, BrowserWindow, ipcMain } from 'electron'
import { autoUpdater } from 'electron-updater'
import log from 'electron-log'
import axios from 'axios'
// 全局变量,用于存储主窗口引用
let mainWindow: BrowserWindow | null = null
// 版本信息接口地址
const VERSION_API_URL = 'your-version-api-url'
// 版本信息接口返回的数据结构
interface VersionInfo {
current_version: string
description: string
download_url: string
release_date: string
update_available: boolean
}
/**
* 设置主窗口引用
* @param window 主窗口实例
*/
export function setMainWindow(window: BrowserWindow | null) {
mainWindow = window
}
/**
* 发送日志消息到渲染进程
* @param type 消息类型
* @param msg 消息内容
*/
function sendLogMessage(type: 'info' | 'success' | 'warning' | 'error', msg: string) {
if (mainWindow) {
mainWindow.webContents.send('log-message', { type, msg })
}
}
/**
* 设置自动更新
*/
export function setupAutoUpdater() {
// 配置autoUpdater
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
/**
* 获取更新内容文本
* @param updataUrl 基础URL
* @returns 更新内容文本
*/
async function fetchUpdateContent(updataUrl: string): Promise<string> {
try {
sendLogMessage('info', '正在获取更新内容: ' + updataUrl)
const response = await axios.get(updataUrl)
const content = response.data
sendLogMessage('info', '成功获取更新内容')
return content.trim()
} catch (error) {
sendLogMessage('error', '获取更新内容失败: ' + error.message)
return ''
}
}
// 检查更新
ipcMain.handle('check-for-updates', async () => {
try {
sendLogMessage('info', '正在请求版本信息: ' + VERSION_API_URL)
const response = await axios.post(
VERSION_API_URL,
{
type: 'win10'
},
{
method: 'post',
headers: {
'Content-Type': 'application/json'
}
}
)
sendLogMessage('info', '服务器响应状态: ' + response.status + ' ' + response.statusText)
const versionInfo: VersionInfo = response.data
const currentVersion = app.getVersion()
sendLogMessage('info', '当前版本: ' + currentVersion)
// 获取更新内容
let updateContent = ''
if (versionInfo.update_available) {
const updataUrl = versionInfo.release_date
updateContent = await fetchUpdateContent(updataUrl)
}
// 构造与electron-updater兼容的返回格式
const updateInfo = {
version: versionInfo.current_version,
releaseNotes: versionInfo.description ? [versionInfo.description] : [],
releaseDate: versionInfo.release_date,
updateAvailable: versionInfo.update_available,
updateContent: updateContent
}
if (versionInfo.update_available) {
// 配置autoUpdater的下载URL
if (versionInfo.download_url) {
const baseUrl = versionInfo.download_url.substring(
0,
versionInfo.download_url.lastIndexOf('/')
)
sendLogMessage('info', '设置下载基础URL: ' + baseUrl)
// 测试latest.yml是否可访问
try {
const testYmlUrl = `${baseUrl}/latest.yml`
sendLogMessage('info', '测试latest.yml URL: ' + testYmlUrl)
axios
.head(testYmlUrl, { method: 'HEAD' })
.then((response) => {
if (response.status === 200) {
sendLogMessage('info', 'latest.yml可访问,状态码: ' + response.status)
} else {
sendLogMessage('warning', 'latest.yml不可访问,状态码: ' + response.status)
}
})
.catch((err) => {
sendLogMessage('error', '测试latest.yml失败: ' + err.message)
})
} catch (testError) {
sendLogMessage('error', '测试latest.yml出错: ' + testError.message)
}
autoUpdater.setFeedURL({
provider: 'generic',
url: baseUrl
})
} else {
// 使用默认下载URL
autoUpdater.setFeedURL({
provider: 'generic',
url: 'your latest.yml file download_url 目录'
})
}
// 通知渲染进程有可用更新
if (mainWindow) {
mainWindow.webContents.send('update-available', updateInfo)
}
}
return { updateInfo }
} catch (error) {
sendLogMessage('error', '检查更新失败: ' + error.message)
return { error: error.message }
}
})
// 添加获取应用版本号的处理程序
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
// 添加下载更新处理程序
ipcMain.handle('download-update', async () => {
try {
// 清除之前的所有监听器,避免重复
autoUpdater.removeAllListeners()
// 记录当前的下载URL配置
sendLogMessage('info', '开始准备下载更新...')
// 设置下载进度监听
autoUpdater.on('download-progress', (progressObj) => {
sendLogMessage('info', '下载进度: ' + progressObj.percent + '%')
if (mainWindow) {
mainWindow.webContents.send('download-progress', { percent: progressObj.percent })
}
})
// 设置下载完成监听
autoUpdater.on('update-downloaded', (info) => {
sendLogMessage('success', '更新下载完成: ' + info.version)
if (mainWindow) {
mainWindow.webContents.send('update-downloaded', {
version: info.version,
releaseDate: info.releaseDate
})
}
})
// 添加错误监听
autoUpdater.on('error', (error) => {
sendLogMessage('error', '更新下载错误: ' + error.message)
// 将错误信息发送到渲染进程
if (mainWindow) {
mainWindow.webContents.send('update-error', { message: error.message })
}
})
// 添加日志监听
autoUpdater.logger = log
autoUpdater.logger.transports.file.level = 'debug'
// 开始下载
sendLogMessage('info', '开始下载更新...')
// 尝试手动检查更新文件
try {
const url = 'your latest.yml download_url 目录'
const latestYmlUrl = `${url}/latest.yml`
sendLogMessage('info', '尝试获取latest.yml: ' + latestYmlUrl)
const response = await axios.get(latestYmlUrl)
if (response.status === 200) {
const ymlContent = await response.data
sendLogMessage('info', `latest.yml内容前100字符: ${ymlContent.substring(0, 100)}...`)
// 验证yml内容是否包含必要的字段
if (!ymlContent.includes('path:') || !ymlContent.includes('sha512:')) {
sendLogMessage('error', 'latest.yml格式不正确,缺少必要字段')
throw new Error('latest.yml格式不正确,缺少必要字段')
}
} else {
sendLogMessage('warning', `无法获取latest.yml,状态码: ${response.status}`)
throw new Error(`无法获取latest.yml,状态码: ${response.status}`)
}
} catch (checkError) {
sendLogMessage('error', `检查latest.yml失败: ${checkError.message}`)
return { error: checkError.message }
}
// 确保autoUpdater配置正确
autoUpdater.setFeedURL({
provider: 'generic',
url: 'your latest.yml download_url 目录'
})
// 添加这一行:先检查更新
sendLogMessage('info', '检查更新...')
await autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate().catch((err) => {
sendLogMessage('error', `下载更新失败: ${err.message}`)
if (mainWindow) {
mainWindow.webContents.send('update-error', { message: err.message })
}
throw err
})
return { success: true }
} catch (error) {
sendLogMessage('error', '下载更新失败: ' + error.message)
return { error: error.message }
}
})
// 添加安装并重启应用处理程序
ipcMain.on('install-and-restart', () => {
autoUpdater.quitAndInstall(false, true)
})
}
步骤 4: 配置 index.ts 文件
js
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { setupUpdate } from "./update";
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
const iconPath = join(__dirname, "../../resources/icon.png");
mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
icon: iconPath,
...(process.platform === "linux" ? { iconPath } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
},
frame: false,
transparent: true,
});
mainWindow.on('ready-to-show', () => {
setMainWindow(mainWindow)
})
// 获取应用更新
export function initWindows(): void {
setupAutoUpdater()
};
}
步骤 5: 配置 package.json 文件
js
"scripts": {
"build": "electron-vite build",
"build:win": "npm run build && electron-builder --win --config --publish always",
}
步骤 6: 打包后会在本地release目录下生成一个新的版本目录, 将其中的.exe 文件和latest.yml 文件通过脚本打包上传到服务器或者腾讯云储存桶.
js
const fs = require("fs");
const path = require("path");
const archiver = require("archiver");
const axios = require("axios");
const FormData = require("form-data");
const http = require("http");
const https = require("https");
const COS = require("cos-nodejs-sdk-v5");
// 配置
const RELEASE_DIR = "./release";
const UPDATE_SERVER_URL = "your update server url";
const TEMP_ZIP_PATH = "./update-files.zip";
// 腾讯云COS配置
const COS_CONFIG = {
SecretId: "", // SecretId
SecretKey: "", // SecretKey
Region: "", // 存储桶地区
Bucket: "", // 存储桶名称
};
const cos = new COS({
SecretId: COS_CONFIG.SecretId,
SecretKey: COS_CONFIG.SecretKey,
});
// 创建自定义的 HTTP Agent 配置
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
maxSockets: 5,
maxFreeSockets: 2,
timeout: 300000, // 5分钟超时
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
maxSockets: 5,
maxFreeSockets: 2,
timeout: 300000, // 5分钟超时
});
/**
* 获取最新版本的文件夹
* @returns {string} 最新版本的文件夹路径
*/
const getLatestVersionFolder = () => {
// 读取release目录下的所有文件夹
const versionFolders = fs
.readdirSync(RELEASE_DIR)
.filter((folder) => {
const folderPath = path.join(RELEASE_DIR, folder);
return (
fs.statSync(folderPath).isDirectory() && /^\d+\.\d+\.\d+$/.test(folder)
);
})
.sort((a, b) => {
// 按版本号排序(假设版本号格式为x.y.z)
const versionA = a.split(".").map(Number);
const versionB = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (versionA[i] !== versionB[i]) {
return versionB[i] - versionA[i]; // 降序排列,最新版本在前
}
}
return 0;
});
if (versionFolders.length === 0) {
throw new Error("没有找到任何版本文件夹");
}
const latestVersion = versionFolders[0];
console.info(`找到最新版本: ${latestVersion}`);
return path.join(RELEASE_DIR, latestVersion);
};
/**
* 创建包含.exe和latest.yml文件的压缩包
* @param {string} sourceFolder 源文件夹路径
* @param {string} targetZip 目标压缩包路径
* @returns {Promise<void>}
*/
const createUpdatePackage = (sourceFolder, targetZip) => {
return new Promise((resolve, reject) => {
console.info(`从 ${sourceFolder} 创建更新包...`);
// 检查文件是否存在
const exeFiles = fs
.readdirSync(sourceFolder)
.filter((file) => file.endsWith(".exe") && !file.endsWith(".blockmap"));
if (exeFiles.length === 0) {
return reject(new Error("没有找到.exe安装文件"));
}
const latestYmlPath = path.join(sourceFolder, "latest.yml");
if (!fs.existsSync(latestYmlPath)) {
return reject(new Error("没有找到latest.yml文件"));
}
// 检查根目录下的updata.txt文件
const updataTxtPath = path.join(process.cwd(), "updata.txt");
if (!fs.existsSync(updataTxtPath)) {
console.warn("警告: 没有找到updata.txt文件,将跳过该文件");
}
// 创建压缩包
const output = fs.createWriteStream(targetZip);
const archive = archiver("zip", {
zlib: { level: 9 }, // 最高压缩级别
});
output.on("close", () => {
console.info(
`压缩完成,文件大小: ${(archive.pointer() / 1024 / 1024).toFixed(2)} MB`
);
resolve();
});
archive.on("error", (err) => reject(err));
archive.pipe(output);
// 添加.exe文件
exeFiles.forEach((exeFile) => {
const exeFilePath = path.join(sourceFolder, exeFile);
archive.file(exeFilePath, { name: exeFile });
console.info(`添加文件: ${exeFile}`);
});
// 添加latest.yml文件
archive.file(latestYmlPath, { name: "latest.yml" });
console.info("添加文件: latest.yml");
// 添加updata.txt文件(如果存在)
if (fs.existsSync(updataTxtPath)) {
archive.file(updataTxtPath, { name: "updata.txt" });
console.info("添加文件: updata.txt");
}
archive.finalize();
});
};
/**
* 上传文件到更新服务器(带重试机制)
* @param {string} filePath 要上传的文件路径
* @param {number} maxRetries 最大重试次数
* @returns {Promise<boolean>} 上传是否成功
*/
const uploadToServer = async (filePath, maxRetries = 3) => {
console.info(`开始上传 ${filePath} 到 ${UPDATE_SERVER_URL}...`);
const fileStats = fs.statSync(filePath);
const fileSizeInMB = (fileStats.size / 1024 / 1024).toFixed(2);
console.info(`文件大小: ${fileSizeInMB} MB`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.info(`尝试上传 (${attempt}/${maxRetries})...`);
try {
const formData = new FormData();
const fileStream = fs.createReadStream(filePath);
// 监听文件流错误
fileStream.on("error", (err) => {
console.error("文件流错误:", err.message);
});
formData.append("file", fileStream);
formData.append("type", "win10");
// 配置请求选项
const config = {
headers: {
...formData.getHeaders(),
Connection: "close",
"User-Agent": "Upload-Script/1.0",
},
timeout: 120000, // 2分钟超时
maxContentLength: Infinity,
maxBodyLength: Infinity,
validateStatus: function (status) {
return status >= 200 && status < 300;
},
// 上传进度监控
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
if (percentCompleted % 10 === 0) {
console.info(`上传进度: ${percentCompleted}%`);
}
}
},
};
const response = await axios.post(UPDATE_SERVER_URL, formData, config);
// 完善响应处理逻辑
console.info("服务器响应状态:", response.status);
console.info("服务器响应数据:", response.data);
// 检查响应格式和成功状态
if (response.data && typeof response.data === "object") {
if (response.data.success === true) {
console.info("✅ 上传成功!");
if (response.data.message) {
console.info("📝 服务器消息:", response.data.message);
}
return true;
} else {
// 处理 success: false 的情况
const errorMessage =
response.data.message ||
response.data.error ||
"服务器返回失败状态";
console.error("❌ 上传失败:", errorMessage);
if (attempt === maxRetries) {
return false;
}
}
} else {
// 处理响应格式不正确的情况
console.error("❌ 服务器响应格式异常:", response.data);
if (attempt === maxRetries) {
return false;
}
}
// 重试前等待
if (attempt < maxRetries) {
console.info(`等待 ${attempt * 2} 秒后重试...`);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
} catch (error) {
console.error(
`❌ 上传出错 (尝试 ${attempt}/${maxRetries}):`,
error.message
);
// 详细错误信息
if (error.code) {
console.error("错误代码:", error.code);
}
if (error.response) {
console.error(
"服务器响应状态:",
error.response.status,
error.response.statusText
);
if (error.response.data) {
console.error("服务器响应数据:", error.response.data);
// 尝试解析错误响应中的消息
if (
typeof error.response.data === "object" &&
error.response.data.message
) {
console.error("服务器错误消息:", error.response.data.message);
}
}
} else if (error.request) {
console.error("请求配置:", {
url: error.config?.url,
method: error.config?.method,
timeout: error.config?.timeout,
});
console.error("网络错误: 无法连接到服务器或请求超时");
} else {
console.error("请求设置错误:", error.message);
}
// 如果是最后一次尝试,返回失败
if (attempt === maxRetries) {
console.error("🔄 已达到最大重试次数,上传失败");
return false;
}
// 等待后重试,等待时间递增
const waitTime = attempt * 5000;
console.info(`等待 ${waitTime / 1000} 秒后重试...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
return false;
};
/**
* 上传文件到腾讯云COS(带重试机制)
* @param {string} filePath 要上传的文件路径
* @param {string} key COS中的文件路径
* @param {number} maxRetries 最大重试次数
* @returns {Promise<boolean>} 上传是否成功
*/
const uploadToCOS = async (filePath, key, maxRetries = 3) => {
console.info(`开始上传 ${filePath} 到腾讯云COS...`);
const fileStats = fs.statSync(filePath);
const fileSizeInMB = (fileStats.size / 1024 / 1024).toFixed(2);
console.info(`文件大小: ${fileSizeInMB} MB`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.info(`尝试上传到COS (${attempt}/${maxRetries})...`);
try {
const result = await new Promise((resolve, reject) => {
cos.putObject(
{
Bucket: COS_CONFIG.Bucket,
Region: COS_CONFIG.Region,
Key: key,
Body: fs.createReadStream(filePath),
ContentLength: fileStats.size,
onProgress: (progressData) => {
const percentCompleted = Math.round(
(progressData.loaded * 100) / progressData.total
);
if (percentCompleted % 10 === 0) {
console.info(`COS上传进度: ${percentCompleted}%`);
}
},
},
(err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
}
);
});
if (result.statusCode === 200) {
console.info("✅ 腾讯云COS上传成功!");
console.info(
"📝 COS文件地址:",
`https://${COS_CONFIG.Bucket}.cos.${COS_CONFIG.Region}.myqcloud.com/${key}`
);
return true;
} else {
console.error("❌ 腾讯云COS上传失败!");
return false;
}
} catch (error) {
console.error(
`❌ COS上传出错 (尝试 ${attempt}/${maxRetries}):`,
error.message
);
if (error.code) {
console.error("COS错误代码:", error.code);
}
// 如果是最后一次尝试,返回失败
if (attempt === maxRetries) {
console.error("🔄 COS已达到最大重试次数,上传失败");
return false;
}
// 等待后重试,等待时间递增
const waitTime = attempt * 3000;
console.info(`等待 ${waitTime / 1000} 秒后重试COS上传...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
return false;
};
/**
* 上传.exe文件到腾讯云COS
* @param {string} sourceFolder 源文件夹路径
* @returns {Promise<boolean>} 上传是否成功
*/
const uploadExeFilesToCOS = async (sourceFolder) => {
console.info("🔍 查找.exe文件...");
// 获取所有.exe文件
const exeFiles = fs
.readdirSync(sourceFolder)
.filter((file) => file.endsWith(".exe") && !file.endsWith(".blockmap"));
if (exeFiles.length === 0) {
console.error("❌ 没有找到.exe文件");
return false;
}
const latestYmlPath = path.join(sourceFolder, "latest.yml");
if (!fs.existsSync(latestYmlPath)) {
console.error("❌ 没有找到latest.yml文件");
return false;
}
console.info(`找到 ${exeFiles.length} 个.exe文件:`, exeFiles);
// 上传每个.exe文件
for (const exeFile of exeFiles) {
const exeFilePath = path.join(sourceFolder, exeFile);
const cosKey = `win10/${exeFile}`; // COS中的文件路径
console.info(`📤 上传 ${exeFile} 到腾讯云COS...`);
const uploadSuccess = await uploadToCOS(exeFilePath, cosKey);
if (!uploadSuccess) {
console.error(`❌ ${exeFile} 上传失败`);
return false;
}
console.info(`✅ ${exeFile} 上传成功`);
}
// 上传latest.yml文件
console.info("📤 上传 latest.yml 到腾讯云COS...");
const ymlUploadSuccess = await uploadToCOS(latestYmlPath, "win10/latest.yml");
if (!ymlUploadSuccess) {
console.error("❌ latest.yml 上传失败");
return false;
}
console.info("✅ latest.yml 上传成功");
// 检查并上传updata.txt文件(如果存在)
const updataTxtPath = path.join(process.cwd(), "updata.txt");
if (fs.existsSync(updataTxtPath)) {
console.info("📤 上传 updata.txt 到腾讯云COS...");
const txtUploadSuccess = await uploadToCOS(
updataTxtPath,
"win10/updata.txt"
);
if (txtUploadSuccess) {
console.info("✅ updata.txt 上传成功");
} else {
console.warn("⚠️ updata.txt 上传失败,但不影响主要功能");
}
}
return true;
};
/**
* 清理临时文件
*/
const cleanup = () => {
if (fs.existsSync(TEMP_ZIP_PATH)) {
fs.unlinkSync(TEMP_ZIP_PATH);
console.info(`已删除临时文件: ${TEMP_ZIP_PATH}`);
}
};
/**
* 主函数
*/
const main = async () => {
try {
console.info("🚀 开始构建和上传更新包...");
// 清理之前的临时文件
cleanup();
// 获取最新版本文件夹
const latestVersionFolder = getLatestVersionFolder();
// 创建更新包
console.info("📦 创建更新包...");
await createUpdatePackage(latestVersionFolder, TEMP_ZIP_PATH);
// 上传到服务器
console.info("⬆️ 上传到服务器...");
const uploadSuccess = await uploadToServer(TEMP_ZIP_PATH);
if (uploadSuccess) {
console.info("✅ 更新文件已成功上传到服务器");
// 上传到腾讯云COS
console.info("☁️ 上传到腾讯云COS...");
const cosUploadSuccess = await uploadExeFilesToCOS(latestVersionFolder);
if (cosUploadSuccess) {
console.info("✅ .exe文件已成功上传到腾讯云COS");
cleanup();
console.info("🗑️ 已清理临时文件");
process.exit(0);
} else {
console.error("❌ .exe文件上传到腾讯云COS失败");
process.exit(1);
}
} else {
console.error("❌ 更新文件上传失败");
process.exit(1);
}
} catch (error) {
console.error("💥 发生错误:", error.message);
console.error("错误堆栈:", error.stack);
process.exit(1);
} finally {
// 清理临时文件
cleanup();
// 清理连接池
httpAgent.destroy();
httpsAgent.destroy();
}
};
// 处理未捕获的异常
process.on("uncaughtException", (error) => {
console.error("未捕获的异常:", error);
cleanup();
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error("未处理的 Promise 拒绝:", reason);
cleanup();
process.exit(1);
});
// 执行主函数
main();
总结
总体思路就是:
- 创建对应的后端上传文件接口, 获取文件接口.
- 配置 electron-builder.yml 文件
- 配置 upload.ts 文件,监听用户的更新请求,下载更新,安装更新
- 配置 index.ts 文件,监听用户的更新请求,获取应用更新
- 配置 package.json 文件,打包命令,发布命令
- 打包后将release目录下的文件上传到服务器
贡献者
unthapy