From 9ddb0f15ff9de47f16420cc89789b0b6831dc0ba Mon Sep 17 00:00:00 2001 From: Memory <134070804+Memory2314@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:50:16 +0800 Subject: [PATCH] feat: add download progress and sha256 integrity verification for updates --- src/main/resolve/autoUpdater.ts | 25 +++++- src/main/utils/chromeRequest.ts | 11 ++- src/preload/index.ts | 3 +- .../src/components/updater/updater-modal.tsx | 79 ++++++++++++------- src/renderer/src/locales/en-US.json | 1 + src/renderer/src/locales/fa-IR.json | 1 + src/renderer/src/locales/ru-RU.json | 1 + src/renderer/src/locales/zh-CN.json | 1 + src/renderer/src/locales/zh-TW.json | 1 + 9 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/main/resolve/autoUpdater.ts b/src/main/resolve/autoUpdater.ts index 8104e69..85ff3d0 100644 --- a/src/main/resolve/autoUpdater.ts +++ b/src/main/resolve/autoUpdater.ts @@ -4,8 +4,10 @@ import { existsSync } from 'fs' import os from 'os' import { exec, execSync, spawn } from 'child_process' import { promisify } from 'util' +import { createHash } from 'crypto' import { app, shell } from 'electron' import i18next from 'i18next' +import { mainWindow } from '../window' import { appLogger } from '../utils/logger' import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs' import { getControledMihomoConfig } from '../config' @@ -84,6 +86,15 @@ export async function downloadAndInstallUpdate(version: string): Promise { } try { if (!existsSync(path.join(dataDir(), file))) { + const sha256Res = await chromeRequest.get(`${baseUrl}${file}.sha256`, { + proxy: { + protocol: 'http', + host: '127.0.0.1', + port: mixedPort + }, + responseType: 'text' + }) + const expectedHash = (sha256Res.data as string).trim().split(/\s+/)[0] const res = await chromeRequest.get(`${baseUrl}${file}`, { responseType: 'arraybuffer', timeout: 0, @@ -94,9 +105,21 @@ export async function downloadAndInstallUpdate(version: string): Promise { }, headers: { 'Content-Type': 'application/octet-stream' + }, + onProgress: (loaded, total) => { + mainWindow?.webContents.send('updateDownloadProgress', { + status: 'downloading', + percent: Math.round((loaded / total) * 100) + }) } }) - await writeFile(path.join(dataDir(), file), res.data as string | Buffer) + mainWindow?.webContents.send('updateDownloadProgress', { status: 'verifying' }) + const fileBuffer = Buffer.from(res.data as ArrayBuffer) + const actualHash = createHash('sha256').update(fileBuffer).digest('hex') + if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) { + throw new Error(`File integrity check failed: expected ${expectedHash}, got ${actualHash}`) + } + await writeFile(path.join(dataDir(), file), fileBuffer) } if (file.endsWith('.exe')) { try { diff --git a/src/main/utils/chromeRequest.ts b/src/main/utils/chromeRequest.ts index 47dde71..431b00b 100644 --- a/src/main/utils/chromeRequest.ts +++ b/src/main/utils/chromeRequest.ts @@ -15,6 +15,7 @@ export interface RequestOptions { responseType?: 'text' | 'json' | 'arraybuffer' followRedirect?: boolean maxRedirects?: number + onProgress?: (loaded: number, total: number) => void } export interface Response { @@ -60,7 +61,8 @@ export async function request( timeout = 30000, responseType = 'text', followRedirect = true, - maxRedirects = 20 + maxRedirects = 20, + onProgress } = options return new Promise((resolve, reject) => { @@ -124,8 +126,15 @@ export async function request( responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1] } + const totalSize = parseInt(responseHeaders['content-length'] || '0', 10) + let loadedSize = 0 + res.on('data', (chunk: Buffer) => { chunks.push(chunk) + if (onProgress && totalSize > 0) { + loadedSize += chunk.length + onProgress(loadedSize, totalSize) + } }) res.on('end', () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index a2644cc..d005a85 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -162,7 +162,8 @@ const validListenChannels = [ 'controledMihomoConfigUpdated', 'profileConfigUpdated', 'groupsUpdated', - 'rulesUpdated' + 'rulesUpdated', + 'updateDownloadProgress' ] as const // 允许的 send channels 白名单 diff --git a/src/renderer/src/components/updater/updater-modal.tsx b/src/renderer/src/components/updater/updater-modal.tsx index c1d3740..006f741 100644 --- a/src/renderer/src/components/updater/updater-modal.tsx +++ b/src/renderer/src/components/updater/updater-modal.tsx @@ -9,7 +9,7 @@ import { } from '@heroui/react' import { toast } from '@renderer/components/base/toast' import ReactMarkdown from 'react-markdown' -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { downloadAndInstallUpdate } from '@renderer/utils/ipc' import { useTranslation } from 'react-i18next' @@ -22,15 +22,18 @@ interface Props { const UpdaterModal: React.FC = (props) => { const { version, changelog, onClose } = props const [downloading, setDownloading] = useState(false) + const [progress, setProgress] = useState<{ status: 'downloading' | 'verifying'; percent?: number } | null>(null) const { t } = useTranslation() - const onUpdate = async (): Promise => { - try { - await downloadAndInstallUpdate(version) - } catch (e) { - toast.error(String(e)) + useEffect(() => { + const handler = (_e: Electron.IpcRendererEvent, ...args: unknown[]): void => { + setProgress(args[0] as { status: 'downloading' | 'verifying'; percent?: number }) } - } + window.electron.ipcRenderer.on('updateDownloadProgress', handler) + return () => { + window.electron.ipcRenderer.removeListener('updateDownloadProgress', handler) + } + }, []) return ( = (props) => { - - - + + {downloading && progress && ( +
+
+
+
+

+ {progress.status === 'verifying' + ? t('common.updater.verifying') + : `${progress.percent ?? 0}%`} +

+
+ )} +
+ + +
diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 9599a32..dfdb97f 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -65,6 +65,7 @@ "common.updater.versionReady": "v{{version}} Version Ready", "common.updater.goToDownload": "Go to Download", "common.updater.update": "Update", + "common.updater.verifying": "Verifying...", "common.generateSecret": "Generate Secret", "common.refresh": "Refresh", "settings.general": "General Settings", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index 2152953..4fdec21 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -65,6 +65,7 @@ "common.updater.versionReady": "نسخه v{{version}} آماده است", "common.updater.goToDownload": "دانلود", "common.updater.update": "به‌روزرسانی", + "common.updater.verifying": "در حال تأیید...", "settings.general": "تنظیمات عمومی", "settings.mihomo": "تنظیمات Mihomo", "settings.language": "زبان", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index 3604beb..8ce64a1 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -67,6 +67,7 @@ "common.updater.versionReady": "Обнаружено ядро с повышенными привилегиями", "common.updater.goToDownload": "Перейти к загрузке", "common.updater.update": "Обновить", + "common.updater.verifying": "Проверка...", "settings.general": "Общие настройки", "settings.mihomo": "Настройки Mihomo", "settings.language": "Язык", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index e583eba..43f45c7 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -65,6 +65,7 @@ "common.updater.versionReady": "v{{version}} 版本就绪", "common.updater.goToDownload": "前往下载", "common.updater.update": "更新", + "common.updater.verifying": "正在校验...", "common.generateSecret": "生成密钥", "common.refresh": "刷新", "settings.general": "通用设置", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 5887820..e29733a 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -65,6 +65,7 @@ "common.updater.versionReady": "v{{version}} 版本就緒", "common.updater.goToDownload": "前往下載", "common.updater.update": "更新", + "common.updater.verifying": "正在校驗...", "common.generateSecret": "生成密鑰", "common.refresh": "重新整理", "settings.general": "通用設置",