Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/autoUpdater.ts

873 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自动更新服务 - 基于 electron-updater + latest.json
*
* 更新检查流程:
* 1. 从阿里云 OSS 拉取 latest.json 获取最新版本号
* 2. 比较版本号,如有更新则将 electron-updater 指向版本化 OSS 路径
* 3. electron-updater 从版本化路径读取 latest-*.yml 完成下载/安装
*
* - autoDownload = false: 用户控制下载时机
* - autoInstallOnAppQuit = true: 下载完成后退出时自动安装
* - Windows: NSIS 安装支持自动更新MSI 安装引导到官网下载安装页
*/
import { app, BrowserWindow, shell, dialog, net } from "electron";
import * as path from "path";
import * as fs from "fs";
import log from "electron-log";
import type {
UpdateState,
UpdateInfo,
UpdateProgress,
} from "@shared/types/updateTypes";
import { readSetting } from "../db";
import { t } from "./i18n";
import {
getWindowsDownloadUrl,
getMacosDownloadUrl,
getLinuxDownloadUrl,
type Platforms,
} from "./updatePlatformUtils";
// ==================== OSS latest.json ====================
const OSS_BASE =
"https://qiming-packages.oss-rg-china-mainland.aliyuncs.com/qimingclaw-electron";
const OSS_STABLE_LATEST_JSON_URL = `${OSS_BASE}/latest/latest.json`;
const OSS_BETA_LATEST_JSON_URL = `${OSS_BASE}/beta/latest.json`;
const OFFICIAL_DOWNLOAD_PAGE_URL = "https://qiming.com/qimingclaw.html";
type UpdateChannel = "stable" | "beta";
const UPDATE_CHANNEL_SETTING_KEY = "update_channel";
/** Squirrel.Mac 在只读卷(如从「下载」直接打开)上无法就地更新时的错误信息特征 */
const READ_ONLY_VOLUME_ERROR_SUBSTR = "read-only volume";
function isReadOnlyVolumeError(err: Error): boolean {
return err?.message?.includes(READ_ONLY_VOLUME_ERROR_SUBSTR) ?? false;
}
interface LatestJson {
version: string;
notes?: string;
pub_date?: string;
platforms?: Record<
string,
{ url: string; signature?: string; size?: number }
>;
/** yml 文件的完整 OSS URL新增字段CI 生成;旧版 CI 无此字段则降级到老逻辑) */
yml?: Record<string, string>;
}
function getUpdateChannel(): UpdateChannel {
const raw = readSetting(UPDATE_CHANNEL_SETTING_KEY);
return raw === "beta" ? "beta" : "stable";
}
function getLatestJsonUrlByChannel(channel: UpdateChannel): string {
return channel === "beta"
? OSS_BETA_LATEST_JSON_URL
: OSS_STABLE_LATEST_JSON_URL;
}
/**
* 从 OSS 拉取 latest.json
*/
function fetchLatestJson(url: string, timeoutMs = 15_000): Promise<LatestJson> {
return new Promise((resolve, reject) => {
// 添加时间戳参数绕过 CDN/浏览器缓存,确保每次都获取最新版本信息
const cacheBustUrl = url.includes("?")
? `${url}&_t=${Date.now()}`
: `${url}?_t=${Date.now()}`;
const request = net.request(cacheBustUrl);
let body = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
request.abort();
reject(new Error(`Timeout after ${timeoutMs}ms fetching ${url}`));
}
}, timeoutMs);
request.on("response", (response) => {
if (response.statusCode !== 200) {
clearTimeout(timer);
settled = true;
reject(new Error(`HTTP ${response.statusCode} fetching ${url}`));
return;
}
response.on("data", (chunk) => {
body += chunk.toString();
});
response.on("end", () => {
clearTimeout(timer);
if (settled) return;
settled = true;
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`Invalid JSON from ${url}`));
}
});
});
request.on("error", (err) => {
clearTimeout(timer);
if (!settled) {
settled = true;
reject(err);
}
});
request.end();
});
}
// ==================== 安装类型检测 ====================
type InstallerType = "nsis" | "msi" | "mac" | "linux" | "dev";
/**
* 检测 Windows 安装类型NSIS vs MSI
*
* NSIS 安装会在应用目录下创建卸载程序文件,按优先级检测:
* 1. 标准命名Uninstall {productName}.exe
* 2. NSIS 通用命名unins000.exe, unins001.exe 等
* 3. 匹配 Uninstall*.exe 或 unins*.exe避免误判其他文件
*
* MSI 安装由 Windows Installer 管理,通常不含卸载程序文件。
*/
function detectInstallerType(): InstallerType {
if (!app.isPackaged) return "dev";
if (process.platform === "darwin") return "mac";
if (process.platform === "linux") return "linux";
if (process.platform === "win32") {
const appDir = path.dirname(app.getPath("exe"));
// 方式1: 标准的 electron-builder NSIS 卸载程序
// 文件名格式: "Uninstall {productName}.exe"
const productName = app.getName();
const standardNsisUninstaller = path.join(
appDir,
`Uninstall ${productName}.exe`,
);
if (fs.existsSync(standardNsisUninstaller)) {
log.info(
`[AutoUpdater] Windows installer type: NSIS (found standard uninstaller: ${standardNsisUninstaller})`,
);
return "nsis";
}
// 方式2和3: 读取目录一次,检查多种 NSIS 卸载程序模式
// 避免重复调用 readdirSync提高性能
let appFiles: string[] | undefined;
try {
appFiles = fs.readdirSync(appDir);
} catch (e) {
log.warn("[AutoUpdater] Failed to read app directory:", e);
}
if (appFiles && appFiles.length > 0) {
// 方式2: NSIS 通用卸载程序模式 (unins000.exe, unins001.exe 等)
const genericNsisUninstaller = appFiles.find((f) =>
/^unins\d{3}\.exe$/i.test(f),
);
if (genericNsisUninstaller) {
log.info(
`[AutoUpdater] Windows installer type: NSIS (found generic NSIS uninstaller: ${genericNsisUninstaller})`,
);
return "nsis";
}
// 方式3: 匹配 Uninstall*.exe 或 unins*.exe 开头的文件
// 严格模式:避免误判如 "uninstaller_helper.exe" 等非卸载程序文件
const anyUninstaller = appFiles.find((f) => {
const lowerName = f.toLowerCase();
return (
// Uninstall 开头 + .exe 结尾(如 Uninstall.exe, Uninstall-1.0.0.exe
(lowerName.startsWith("uninstall") && lowerName.endsWith(".exe")) ||
// unins 开头但不是 uninsNNN.exe 模式的(兼容其他 NSIS 变体)
(lowerName.startsWith("unins") &&
!/^unins\d{3}\.exe$/i.test(f) &&
lowerName.endsWith(".exe"))
);
});
if (anyUninstaller) {
log.info(
`[AutoUpdater] Windows installer type: NSIS (found uninstaller: ${anyUninstaller})`,
);
return "nsis";
}
}
// Fallback: 找不到任何卸载程序文件,判定为 MSI
log.info(
"[AutoUpdater] Windows installer type: MSI (no uninstaller found in app directory)",
);
return "msi";
}
return "nsis"; // 非预期平台 fallback实际 win32/mac/linux 已覆盖)
}
let cachedInstallerType: InstallerType | undefined;
export function getInstallerType(): InstallerType {
if (!cachedInstallerType) {
cachedInstallerType = detectInstallerType();
}
return cachedInstallerType;
}
/**
* 当前安装方式是否支持自动更新
* - NSIS / mac / linux / dev: electron-updater 原生支持dev 模式下载有单独 guard
* - MSI: 不支持,引导到官网下载安装页
*/
export function canAutoUpdate(): boolean {
const type = getInstallerType();
return type !== "msi";
}
// ==================== 更新状态管理 ====================
/**
* 语义化版本比较: a > b 返回 1, a < b 返回 -1, 相等返回 0
*/
function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/, "").split(".").map(Number);
const pb = b.replace(/^v/, "").split(".").map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
/**
* MVP 仅支持 x.y.z 纯数字版本,避免 compareVersions 对 prerelease 得到 NaN
*/
function isNumericSemver(version: string): boolean {
return /^\d+\.\d+\.\d+$/.test(version.trim());
}
let currentState: UpdateState = { status: "idle" };
let getMainWindow: (() => BrowserWindow | null) | null = null;
let cleanupBeforeInstall: (() => void) | null = null;
/**
* 在 quitAndInstall 前调用,通知主进程:
* 1. 设置 isQuitting = true防止窗口 close 事件被拦截到托盘
* 2. 设置 isInstallingUpdate = true让 before-quit 跳过 e.preventDefault()
* 允许 Squirrel.Mac 正常接管退出流程完成安装
*/
let markQuitting: (() => void) | null = null;
function sendStatusToRenderer(): void {
const win = getMainWindow?.();
if (win && !win.isDestroyed()) {
win.webContents.send("update:status", currentState);
}
}
/**
* 显示模态对话框(挂载到主窗口,避免 Linux 标题栏图标显示异常)
*/
function showModal(
options: Electron.MessageBoxOptions,
): Promise<Electron.MessageBoxReturnValue> {
const win = getMainWindow?.();
if (win && !win.isDestroyed()) {
return dialog.showMessageBox(win, options);
}
return dialog.showMessageBox(options);
}
function setState(patch: Partial<UpdateState>): void {
currentState = { ...currentState, ...patch };
sendStatusToRenderer();
}
// ==================== latest.json 更新检查 ====================
let checkInProgress = false;
/**
* 通过 OSS latest.json 检查更新
*
* 流程:
* 1. 拉取 latest.json 获取最新版本号和 signature
* 2. 与本地版本比较
* 3. 如果有更新,设置 electron-updater feedURL 指向版本化 OSS 路径,
* 并调用 autoUpdater.checkForUpdates() 初始化 electron-updater 下载状态
* 4. OSS 不可达时直接报错,不 fallback 到 GitHub
*/
async function checkForUpdatesViaLatestJson(): Promise<UpdateInfo> {
if (checkInProgress) {
log.info("[AutoUpdater] Check already in progress, skipping");
// 返回 alreadyChecking: true让调用方知道检查正在进行避免误报"当前已是最新版本"
return { hasUpdate: false, alreadyChecking: true };
}
checkInProgress = true;
try {
return await doCheckViaLatestJson();
} finally {
checkInProgress = false;
}
}
async function doCheckViaLatestJson(): Promise<UpdateInfo> {
const { autoUpdater } = require("electron-updater");
const updateChannel = getUpdateChannel();
const latestJsonUrl = getLatestJsonUrlByChannel(updateChannel);
log.info(
`[AutoUpdater] Check updates via channel=${updateChannel}, url=${latestJsonUrl}`,
);
setState({
status: "checking",
error: undefined,
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
let latestJson: LatestJson;
try {
latestJson = await fetchLatestJson(latestJsonUrl);
} catch (e: any) {
// 日志保留完整错误信息(含 URL用户提示仅显示 HTTP 状态码
log.error(
`[AutoUpdater] Failed to fetch latest.json from OSS(channel=${updateChannel}): ${e.message}`,
);
const statusMatch = e.message.match(/^HTTP (\d+)/);
const userMsg = statusMatch
? `HTTP ${statusMatch[1]}`
: t("Claw.AutoUpdater.networkFailed");
setState({
status: "error",
error: `${t("Claw.AutoUpdater.getUpdateInfoFailed", updateChannel)}: ${userMsg}`,
canAutoUpdate: canAutoUpdate(),
});
return {
hasUpdate: false,
error: `${t("Claw.AutoUpdater.getUpdateInfoFailed", updateChannel)}: ${userMsg}`,
};
}
if (!isNumericSemver(latestJson.version)) {
const msg = t(
"Claw.AutoUpdater.invalidMetadataVersion",
latestJson.version,
);
log.error(
`[AutoUpdater] Invalid latest.json version for channel=${updateChannel}: ${latestJson.version}`,
);
setState({
status: "error",
error: msg,
canAutoUpdate: canAutoUpdate(),
});
return { hasUpdate: false, error: msg };
}
const hasUpdate = compareVersions(latestJson.version, app.getVersion()) > 0;
if (hasUpdate) {
// 优先从 latest.json 的 yml 字段读取完整 yml URLCI 生成,支持任意 OSS 路径结构)
// 降级老逻辑:客户端自己拼接 electron-v{version} 或 beta-build/prerelease-v{version} 路径
// 注意ymlUrl 是文件 URL.../latest.yml但 setFeedURL 期望目录路径,它会自动拼接 {channel}.yml
const platformKey = process.platform === "win32" ? "win" : process.platform;
const ymlUrl = latestJson.yml?.[platformKey];
// 去掉文件名得到目录路径,供 electron-updater generic provider 自动拼接 {channel}.yml
const ymlDir = ymlUrl ? ymlUrl.replace(/\/[^/]+\.yml$/, "/") : null;
const versionedUrl = ymlDir
? ymlDir
: updateChannel === "beta"
? `${OSS_BASE}/beta-build/prerelease-v${latestJson.version}`
: `${OSS_BASE}/electron-v${latestJson.version}`;
log.info(
`[AutoUpdater] New version ${latestJson.version} found via channel=${updateChannel}, ymlUrl=${ymlUrl ?? "none (using fallback)"}, feedUrl=${versionedUrl}`,
);
autoUpdater.setFeedURL({ provider: "generic", url: versionedUrl });
// 初始化 electron-updater 内部状态,为后续 downloadUpdate() 做准备
await autoUpdater.checkForUpdates();
} else {
setState({
status: "not-available",
canAutoUpdate: canAutoUpdate(),
});
}
return {
hasUpdate,
version: latestJson.version,
releaseNotes: latestJson.notes,
};
}
// ==================== 初始化 ====================
/**
* 初始化自动更新(应在 app.whenReady 后调用)
* @param getWindow 获取主窗口
* @param cleanup 安装更新前的清理回调(停止服务、关闭数据库等)
* @param onMarkQuitting 在调用 quitAndInstall 前调用,用于设置主进程的 isQuitting/isInstallingUpdate 标志,
* 防止窗口 close 被拦截,并让 before-quit 不阻止退出
*/
export function initAutoUpdater(
getWindow: () => BrowserWindow | null,
cleanup?: () => void,
onMarkQuitting?: () => void,
): void {
getMainWindow = getWindow;
cleanupBeforeInstall = cleanup || null;
markQuitting = onMarkQuitting || null;
const installerType = getInstallerType();
log.info(
`[AutoUpdater] Installer type: ${installerType}, canAutoUpdate: ${canAutoUpdate()}`,
);
// MSI 安装只支持检查更新,不支持自动下载/安装
if (installerType === "msi") {
log.info(
"[AutoUpdater] MSI installation detected: auto-download disabled, will redirect to official download page",
);
}
// CJS 兼容导入 electron-updater
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { autoUpdater } = require("electron-updater");
autoUpdater.logger = log;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = canAutoUpdate();
// 开发模式:使用 dev-app-update.yml 配置禁用自动安装Squirrel.Mac 无法匹配 dev bundle ID
if (!app.isPackaged) {
autoUpdater.forceDevUpdateConfig = true;
autoUpdater.autoInstallOnAppQuit = false;
log.info(
"[AutoUpdater] Dev mode: using dev-app-update.yml (autoInstall disabled)",
);
}
// 自定义更新源覆盖(本地测试),直接走 electron-updater 的 generic provider
const customServer = process.env.QIMING_UPDATE_SERVER;
if (customServer) {
log.info(`[AutoUpdater] Using custom update server: ${customServer}`);
autoUpdater.setFeedURL({ provider: "generic", url: customServer });
}
// -------- 事件监听 --------
autoUpdater.on("checking-for-update", () => {
log.info("[AutoUpdater] Checking for update...");
setState({
status: "checking",
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
});
autoUpdater.on("update-available", (info: any) => {
log.info("[AutoUpdater] Update available:", info.version);
setState({
status: "available",
version: info.version,
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
});
autoUpdater.on("update-not-available", (_info: any) => {
log.info("[AutoUpdater] Already up to date");
setState({
status: "not-available",
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
});
autoUpdater.on("download-progress", (progress: UpdateProgress) => {
log.info(
`[AutoUpdater] Download progress: ${progress.percent.toFixed(1)}%`,
);
setState({
status: "downloading",
progress,
canAutoUpdate: true,
});
});
autoUpdater.on("update-downloaded", (info: any) => {
log.info("[AutoUpdater] Update downloaded:", info.version);
setState({
status: "downloaded",
version: info.version,
progress: undefined,
canAutoUpdate: true,
});
});
autoUpdater.on("error", (err: Error) => {
log.error("[AutoUpdater] Error:", err.message);
setState({
status: "error",
error: err.message,
progress: undefined,
canAutoUpdate: canAutoUpdate(),
isReadOnlyVolumeError: isReadOnlyVolumeError(err),
});
});
function isUpdateInProgress(): boolean {
return (
currentState.status === "checking" ||
currentState.status === "available" ||
currentState.status === "downloading" ||
currentState.status === "downloaded"
);
}
// 延迟 10s 启动时检查一次,发现新版本弹窗提示;退出时清除避免在已退出状态下弹窗
const STARTUP_CHECK_DELAY_MS = 10_000;
const startupCheckTimerId = setTimeout(async () => {
log.info("[AutoUpdater] Initial startup check");
try {
if (isUpdateInProgress()) {
log.info(
"[AutoUpdater] Startup: update already in progress (status=%s), skipping",
currentState.status,
);
return;
}
const result = await checkForUpdatesViaLatestJson();
if (result.hasUpdate && result.version) {
log.info(`[AutoUpdater] Startup: found new version v${result.version}`);
showStartupUpdateDialog(result.version);
}
} catch (e: any) {
log.warn("[AutoUpdater] Startup check failed:", e.message);
}
}, STARTUP_CHECK_DELAY_MS);
app.once("before-quit", () => {
clearTimeout(startupCheckTimerId);
});
}
/**
* 启动时发现新版本,仅推送状态到渲染进程,由 header tag 展示更新入口
*/
async function showStartupUpdateDialog(version: string): Promise<void> {
log.info(`[AutoUpdater] Startup: v${version} available, notifying renderer`);
setState({
status: "available",
version,
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
}
/**
* 通用更新对话框流程(供托盘菜单等外部调用)
* 检查更新 → 有更新则弹窗 → 下载 → 二次确认 → 安装
*/
export async function showUpdateDialogFlow(): Promise<void> {
try {
const result = await checkForUpdates();
if (!result.hasUpdate) {
showModal({
type: "info",
title: t("Claw.AutoUpdater.checking"),
message: t("Claw.AutoUpdater.alreadyLatest"),
});
return;
}
const version = result.version ?? "unknown";
const state = getUpdateState();
if (state.canAutoUpdate === false) {
const { response } = await showModal({
type: "info",
title: t("Claw.AutoUpdater.newVersionFound"),
message: t("Claw.AutoUpdater.versionFound", version),
detail: t("Claw.AutoUpdater.unsupportedInstall"),
buttons: [
t("Claw.AutoUpdater.downloadPage"),
t("Claw.AutoUpdater.later"),
],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
openReleasesPage();
}
return;
}
const { response } = await showModal({
type: "info",
title: t("Claw.AutoUpdater.newVersionFound"),
message: t("Claw.AutoUpdater.versionFound", version),
detail: t("Claw.AutoUpdater.downloadInstallNow"),
buttons: [t("Claw.AutoUpdater.downloadNow"), t("Claw.AutoUpdater.later")],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) return;
const dlResult = await downloadUpdate();
if (!dlResult.success) {
if (dlResult.error)
showModal({
type: "error",
title: t("Claw.AutoUpdater.downloadFailed"),
message: dlResult.error,
});
return;
}
const { response: installResponse } = await showModal({
type: "info",
title: t("Claw.AutoUpdater.updateDownloaded"),
message: t("Claw.AutoUpdater.updateReady"),
detail: t("Claw.AutoUpdater.installNow"),
buttons: [
t("Claw.AutoUpdater.installNow"),
t("Claw.AutoUpdater.installOnExit"),
],
defaultId: 0,
cancelId: 1,
});
if (installResponse === 0) {
installUpdate();
}
} catch (e: any) {
log.error("[AutoUpdater] Update dialog flow error:", e.message);
showModal({
type: "error",
title: t("Claw.AutoUpdater.checkFailed"),
message: e.message || t("Claw.AutoUpdater.later"),
});
}
}
// ==================== 公开 API ====================
/**
* 手动检查更新(通过 latest.json
*/
export async function checkForUpdates(): Promise<UpdateInfo> {
// 自定义更新源(本地测试用)直接走 electron-updater适用于 stable 通道
// beta 通道始终走 doCheckViaLatestJson确保 feedURL 指向 beta-build 路径
if (process.env.QIMING_UPDATE_SERVER && getUpdateChannel() === "stable") {
try {
const { autoUpdater } = require("electron-updater");
setState({
status: "checking",
error: undefined,
isReadOnlyVolumeError: undefined,
canAutoUpdate: canAutoUpdate(),
});
const result = await autoUpdater.checkForUpdates();
if (result?.updateInfo) {
const hasUpdate =
compareVersions(result.updateInfo.version, app.getVersion()) > 0;
return {
hasUpdate,
version: result.updateInfo.version,
releaseDate: result.updateInfo.releaseDate,
releaseNotes:
typeof result.updateInfo.releaseNotes === "string"
? result.updateInfo.releaseNotes
: undefined,
};
}
return { hasUpdate: false };
} catch (err: any) {
log.error("[AutoUpdater] checkForUpdates error:", err.message);
setState({
status: "error",
error: err.message,
canAutoUpdate: canAutoUpdate(),
});
return { hasUpdate: false, error: err.message };
}
}
return checkForUpdatesViaLatestJson();
}
/**
* 下载更新
*/
export async function downloadUpdate(): Promise<{
success: boolean;
error?: string;
}> {
// Dev 模式下 Squirrel.Mac 无法处理更新包bundle ID 不匹配),只允许检查更新
if (!app.isPackaged) {
const errMsg = t("Claw.AutoUpdater.devModeUnsupported");
log.info(
"[AutoUpdater] downloadUpdate skipped: dev/unpackaged build cannot apply auto-update",
);
return {
success: false,
error: errMsg,
};
}
// MSI 安装不支持自动更新,引导到官网下载安装页
if (getInstallerType() === "msi") {
log.info(
"[AutoUpdater] MSI installation: redirecting to official download page for manual download",
);
openReleasesPage();
return {
success: false,
error: t("Claw.AutoUpdater.unsupportedInstall"),
};
}
try {
// 立即设置 downloading 状态,让渲染进程马上显示 loading
setState({
status: "downloading",
progress: undefined,
canAutoUpdate: true,
});
const { autoUpdater } = require("electron-updater");
await autoUpdater.downloadUpdate();
return { success: true };
} catch (err: any) {
log.error("[AutoUpdater] downloadUpdate error:", err.message);
setState({
status: "error",
error: err.message,
canAutoUpdate: canAutoUpdate(),
isReadOnlyVolumeError: isReadOnlyVolumeError(err),
});
return { success: false, error: err.message };
}
}
/**
* 退出并安装更新
*/
export function installUpdate(): { success: boolean; error?: string } {
if (!app.isPackaged) {
const errMsg = t("Claw.AutoUpdater.installDevUnsupported");
log.info(
"[AutoUpdater] installUpdate skipped: dev/unpackaged build cannot install update",
);
return {
success: false,
error: errMsg,
};
}
if (getInstallerType() === "msi") {
openReleasesPage();
return {
success: false,
error: t("Claw.AutoUpdater.unsupportedInstall"),
};
}
try {
// 关键:在 quitAndInstall 前设置退出标志(所有平台通用)
// 原因1quitAndInstall 会触发 app.quit(),进而触发窗口 close 事件;
// 若 isQuitting 未设置close 会被拦截到托盘app 无法正常退出
// 原因2通知 before-quit handler 跳过 e.preventDefault()
// 让各平台的安装器macOS Squirrel.Mac / Windows NSIS / Linux AppImage
// 正常接管退出流程完成安装;否则 e.preventDefault() 会阻止安装器触发
if (markQuitting) {
log.info("[AutoUpdater] Marking app as quitting for update install...");
markQuitting();
}
// 先停止所有服务避免残留进程cleanup 是同步触发的异步操作)
if (cleanupBeforeInstall) {
log.info("[AutoUpdater] Running cleanup before install...");
cleanupBeforeInstall();
}
const { autoUpdater } = require("electron-updater");
autoUpdater.quitAndInstall(false, true);
return { success: true };
} catch (err: any) {
log.error("[AutoUpdater] installUpdate error:", err.message);
return { success: false, error: err.message };
}
}
/**
* 获取当前更新状态
*/
export function getUpdateState(): UpdateState {
return { ...currentState, canAutoUpdate: canAutoUpdate() };
}
/**
* 打开下载页:
* - Windows: 统一打开官网下载安装页(避免 MSI/EXE 安装路径不一致带来的升级问题)
* - macOS/Linux: 从 OSS latest.json 获取当前平台对应下载链接
* - macOS: 根据架构选择 arm64/x64 .zip
* - Linux: 根据架构选择 arm64/x64 AppImage
* OSS 不可达时弹窗提示错误,不 fallback 到 GitHub
*/
export async function openReleasesPage(): Promise<void> {
if (process.platform === "win32") {
log.info(
`[AutoUpdater] Opening official download page on Windows: ${OFFICIAL_DOWNLOAD_PAGE_URL}`,
);
await shell.openExternal(OFFICIAL_DOWNLOAD_PAGE_URL);
return;
}
let url: string;
let platformName: string;
const updateChannel = getUpdateChannel();
const latestJsonUrl = getLatestJsonUrlByChannel(updateChannel);
try {
log.info(
`[AutoUpdater] Resolve download URL via channel=${updateChannel}, url=${latestJsonUrl}`,
);
const latest = await fetchLatestJson(latestJsonUrl);
const platforms: Platforms | undefined = latest.platforms;
if (process.platform === "darwin") {
url = getMacosDownloadUrl(platforms);
platformName = `macOS (${process.arch})`;
} else {
url = getLinuxDownloadUrl(platforms);
platformName = `Linux (${process.arch})`;
}
if (!url) {
throw new Error(`No download package found for ${platformName}`);
}
log.info(
`[AutoUpdater] Opening ${platformName} download URL from OSS(channel=${updateChannel}): ${url}`,
);
await shell.openExternal(url);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
log.error(
`[AutoUpdater] Failed to get download URL from OSS(channel=${updateChannel}): ${msg}`,
);
// 弹窗提示用户,不 fallback 到 GitHub
await showModal({
type: "error",
title: t("Claw.AutoUpdater.getDownloadLinkFailed"),
message: t("Claw.AutoUpdater.getDownloadLinkFailedDetail", msg),
});
}
}