mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-23 23:57:22 +08:00
feat(update): 重构自动更新系统,使用 electron-updater 替代手动下载
- CI 构建 macOS 拆分为 x64/arm64 分别构建,合并 latest-mac.yml - 主进程使用 electron-updater 管理检查、下载、安装全流程 - 渲染进程 UpdateModal 改为响应式同步主进程更新状态 - IPC 通道统一为 app-update:* 系列 - 窗口拦截外部链接在系统浏览器打开 - 新增 5 语言更新相关国际化文案
This commit is contained in:
+284
-89
@@ -1,101 +1,296 @@
|
||||
import axios from 'axios';
|
||||
import { spawn } from 'child_process';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import electronUpdater, {
|
||||
type ProgressInfo,
|
||||
type UpdateDownloadedEvent,
|
||||
type UpdateInfo
|
||||
} from 'electron-updater';
|
||||
|
||||
export function setupUpdateHandlers(_mainWindow: BrowserWindow) {
|
||||
ipcMain.on('start-download', async (event, url: string) => {
|
||||
import {
|
||||
APP_UPDATE_RELEASE_URL,
|
||||
APP_UPDATE_STATUS,
|
||||
type AppUpdateState,
|
||||
createDefaultAppUpdateState
|
||||
} from '../../shared/appUpdate';
|
||||
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
type CheckUpdateOptions = {
|
||||
manual?: boolean;
|
||||
};
|
||||
|
||||
let updateState: AppUpdateState = createDefaultAppUpdateState(app.getVersion());
|
||||
let isInitialized = false;
|
||||
let checkForUpdatesPromise: Promise<AppUpdateState> | null = null;
|
||||
let downloadUpdatePromise: Promise<AppUpdateState> | null = null;
|
||||
|
||||
const isAutoUpdateSupported = (): boolean => {
|
||||
// if (!app.isPackaged) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return Boolean(process.env.APPIMAGE);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeReleaseNotes = (releaseNotes: UpdateInfo['releaseNotes']): string => {
|
||||
if (typeof releaseNotes === 'string') {
|
||||
return releaseNotes;
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseNotes)) {
|
||||
return releaseNotes
|
||||
.map((item) => {
|
||||
const version = item.version ? `## ${item.version}` : '';
|
||||
return [version, item.note].filter(Boolean).join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const broadcastUpdateState = () => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('app-update:state', updateState);
|
||||
}
|
||||
};
|
||||
|
||||
const setUpdateState = (partial: Partial<AppUpdateState>) => {
|
||||
updateState = {
|
||||
...updateState,
|
||||
...partial
|
||||
};
|
||||
broadcastUpdateState();
|
||||
};
|
||||
|
||||
const resetUpdateState = () => {
|
||||
updateState = {
|
||||
...createDefaultAppUpdateState(app.getVersion()),
|
||||
supported: isAutoUpdateSupported()
|
||||
};
|
||||
};
|
||||
|
||||
const getUnsupportedMessage = () => {
|
||||
if (!app.isPackaged) {
|
||||
return '当前环境为开发模式,自动更新仅在打包后的应用内可用';
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return '当前 Linux 安装方式不支持自动更新,请前往官网下载安装包更新';
|
||||
}
|
||||
|
||||
return '当前环境不支持自动更新,请前往官网下载安装包更新';
|
||||
};
|
||||
|
||||
const applyUpdateInfo = (
|
||||
status: AppUpdateState['status'],
|
||||
info?: Pick<UpdateInfo, 'version' | 'releaseDate' | 'releaseNotes'>
|
||||
) => {
|
||||
setUpdateState({
|
||||
status,
|
||||
availableVersion: info?.version ?? null,
|
||||
releaseDate: info?.releaseDate ?? null,
|
||||
releaseNotes: info ? normalizeReleaseNotes(info.releaseNotes) : '',
|
||||
releasePageUrl: APP_UPDATE_RELEASE_URL,
|
||||
errorMessage: null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
const checkForUpdates = async (options: CheckUpdateOptions = {}): Promise<AppUpdateState> => {
|
||||
if (!updateState.supported) {
|
||||
const errorMessage = options.manual ? getUnsupportedMessage() : null;
|
||||
setUpdateState({
|
||||
status: options.manual ? APP_UPDATE_STATUS.error : APP_UPDATE_STATUS.idle,
|
||||
errorMessage
|
||||
});
|
||||
return updateState;
|
||||
}
|
||||
|
||||
if (
|
||||
updateState.status === APP_UPDATE_STATUS.available ||
|
||||
updateState.status === APP_UPDATE_STATUS.downloading ||
|
||||
updateState.status === APP_UPDATE_STATUS.downloaded
|
||||
) {
|
||||
return updateState;
|
||||
}
|
||||
|
||||
if (checkForUpdatesPromise) {
|
||||
return await checkForUpdatesPromise;
|
||||
}
|
||||
|
||||
checkForUpdatesPromise = (async () => {
|
||||
try {
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream',
|
||||
onDownloadProgress: (progressEvent: { loaded: number; total?: number }) => {
|
||||
if (!progressEvent.total) return;
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
const downloaded = (progressEvent.loaded / 1024 / 1024).toFixed(2);
|
||||
const total = (progressEvent.total / 1024 / 1024).toFixed(2);
|
||||
event.sender.send('download-progress', percent, `已下载 ${downloaded}MB / ${total}MB`);
|
||||
}
|
||||
});
|
||||
|
||||
const fileName = url.split('/').pop() || 'update.exe';
|
||||
const downloadPath = path.join(app.getPath('downloads'), fileName);
|
||||
|
||||
// 创建写入流
|
||||
const writer = fs.createWriteStream(downloadPath);
|
||||
|
||||
// 将响应流写入文件
|
||||
response.data.pipe(writer);
|
||||
|
||||
// 处理写入完成
|
||||
writer.on('finish', () => {
|
||||
event.sender.send('download-complete', true, downloadPath);
|
||||
});
|
||||
|
||||
// 处理写入错误
|
||||
writer.on('error', (error) => {
|
||||
console.error('Write file error:', error);
|
||||
event.sender.send('download-complete', false, '');
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.checking,
|
||||
errorMessage: null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
await autoUpdater.checkForUpdates();
|
||||
return updateState;
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
event.sender.send('download-complete', false, '');
|
||||
const errorMessage = error instanceof Error ? error.message : '检查更新失败';
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.error,
|
||||
errorMessage,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
return updateState;
|
||||
} finally {
|
||||
checkForUpdatesPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return await checkForUpdatesPromise;
|
||||
};
|
||||
|
||||
const downloadUpdate = async (): Promise<AppUpdateState> => {
|
||||
if (!updateState.supported) {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.error,
|
||||
errorMessage: getUnsupportedMessage()
|
||||
});
|
||||
return updateState;
|
||||
}
|
||||
|
||||
if (updateState.status === APP_UPDATE_STATUS.downloaded) {
|
||||
return updateState;
|
||||
}
|
||||
|
||||
if (!hasDownloadableUpdate()) {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.error,
|
||||
errorMessage: '当前没有可下载的更新'
|
||||
});
|
||||
return updateState;
|
||||
}
|
||||
|
||||
if (downloadUpdatePromise) {
|
||||
return await downloadUpdatePromise;
|
||||
}
|
||||
|
||||
downloadUpdatePromise = (async () => {
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
return updateState;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '下载更新失败';
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.error,
|
||||
errorMessage
|
||||
});
|
||||
return updateState;
|
||||
} finally {
|
||||
downloadUpdatePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return await downloadUpdatePromise;
|
||||
};
|
||||
|
||||
const hasDownloadableUpdate = () => {
|
||||
return updateState.status === APP_UPDATE_STATUS.available;
|
||||
};
|
||||
|
||||
const openReleasePage = async (): Promise<boolean> => {
|
||||
await shell.openExternal(updateState.releasePageUrl || APP_UPDATE_RELEASE_URL);
|
||||
return true;
|
||||
};
|
||||
|
||||
export function setupUpdateHandlers(mainWindow: BrowserWindow) {
|
||||
if (isInitialized) {
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
mainWindow.webContents.send('app-update:state', updateState);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
resetUpdateState();
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.checking,
|
||||
errorMessage: null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('install-update', (_event, filePath: string) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('Installation file not found:', filePath);
|
||||
return;
|
||||
}
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
applyUpdateInfo(APP_UPDATE_STATUS.available, info);
|
||||
});
|
||||
|
||||
const { platform } = process;
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.notAvailable,
|
||||
availableVersion: null,
|
||||
releaseNotes: '',
|
||||
releaseDate: null,
|
||||
errorMessage: null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// 先启动安装程序,再退出应用
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
// 使用spawn替代exec,并使用detached选项确保子进程独立运行
|
||||
const child = spawn(filePath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
} else if (platform === 'darwin') {
|
||||
// 挂载 DMG 文件
|
||||
const child = spawn('open', [filePath], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
} else if (platform === 'linux') {
|
||||
const ext = path.extname(filePath);
|
||||
if (ext === '.AppImage') {
|
||||
// 先添加执行权限
|
||||
fs.chmodSync(filePath, '755');
|
||||
const child = spawn(filePath, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
} else if (ext === '.deb') {
|
||||
const child = spawn('pkexec', ['dpkg', '-i', filePath], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
}
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.downloading,
|
||||
downloadProgress: progress.percent,
|
||||
downloadedBytes: progress.transferred,
|
||||
totalBytes: progress.total,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
errorMessage: null
|
||||
});
|
||||
});
|
||||
|
||||
// 给安装程序一点时间启动
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('启动安装程序失败:', error);
|
||||
// 尽管出错,仍然尝试退出应用
|
||||
app.quit();
|
||||
}
|
||||
autoUpdater.on('update-downloaded', (info: UpdateDownloadedEvent) => {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.downloaded,
|
||||
availableVersion: info.version,
|
||||
releaseNotes: normalizeReleaseNotes(info.releaseNotes),
|
||||
releaseDate: info.releaseDate,
|
||||
downloadProgress: 100,
|
||||
downloadedBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0),
|
||||
totalBytes: info.files.reduce((total, file) => total + (file.size ?? 0), 0),
|
||||
bytesPerSecond: 0,
|
||||
errorMessage: null
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
setUpdateState({
|
||||
status: APP_UPDATE_STATUS.error,
|
||||
errorMessage: error?.message ?? '自动更新失败'
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update:get-state', async () => {
|
||||
return updateState;
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update:check', async (_event, options?: CheckUpdateOptions) => {
|
||||
return await checkForUpdates(options);
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update:download', async () => {
|
||||
return await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update:quit-and-install', async () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('app-update:open-release-page', async () => {
|
||||
return await openReleasePage();
|
||||
});
|
||||
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
mainWindow.webContents.send('app-update:state', updateState);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -317,6 +317,42 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
|
||||
// 创建窗口
|
||||
const mainWindow = new BrowserWindow(options);
|
||||
|
||||
const appOrigin = (() => {
|
||||
if (!is.dev || !process.env.ELECTRON_RENDERER_URL) return null;
|
||||
try {
|
||||
return new URL(process.env.ELECTRON_RENDERER_URL).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const shouldOpenInBrowser = (targetUrl: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(targetUrl);
|
||||
if (parsedUrl.protocol === 'mailto:' || parsedUrl.protocol === 'tel:') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (appOrigin && parsedUrl.origin === appOrigin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const openInSystemBrowser = (targetUrl: string) => {
|
||||
shell.openExternal(targetUrl).catch((error) => {
|
||||
console.error('打开外部链接失败:', targetUrl, error);
|
||||
});
|
||||
};
|
||||
|
||||
// 移除菜单
|
||||
mainWindow.removeMenu();
|
||||
|
||||
@@ -380,8 +416,16 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
|
||||
}, 100);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('will-navigate', (event, targetUrl) => {
|
||||
if (!shouldOpenInBrowser(targetUrl)) return;
|
||||
event.preventDefault();
|
||||
openInSystemBrowser(targetUrl);
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
if (shouldOpenInBrowser(details.url)) {
|
||||
openInSystemBrowser(details.url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user