feat: add download progress and sha256 integrity verification for updates

This commit is contained in:
Memory 2026-03-07 14:50:16 +08:00 committed by GitHub
parent f5cff160f2
commit 9ddb0f15ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 91 additions and 32 deletions

View File

@ -4,8 +4,10 @@ import { existsSync } from 'fs'
import os from 'os' import os from 'os'
import { exec, execSync, spawn } from 'child_process' import { exec, execSync, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { createHash } from 'crypto'
import { app, shell } from 'electron' import { app, shell } from 'electron'
import i18next from 'i18next' import i18next from 'i18next'
import { mainWindow } from '../window'
import { appLogger } from '../utils/logger' import { appLogger } from '../utils/logger'
import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs' import { dataDir, exeDir, exePath, isPortable, resourcesFilesDir } from '../utils/dirs'
import { getControledMihomoConfig } from '../config' import { getControledMihomoConfig } from '../config'
@ -84,6 +86,15 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
} }
try { try {
if (!existsSync(path.join(dataDir(), file))) { 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}`, { const res = await chromeRequest.get(`${baseUrl}${file}`, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: 0, timeout: 0,
@ -94,9 +105,21 @@ export async function downloadAndInstallUpdate(version: string): Promise<void> {
}, },
headers: { headers: {
'Content-Type': 'application/octet-stream' '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')) { if (file.endsWith('.exe')) {
try { try {

View File

@ -15,6 +15,7 @@ export interface RequestOptions {
responseType?: 'text' | 'json' | 'arraybuffer' responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean followRedirect?: boolean
maxRedirects?: number maxRedirects?: number
onProgress?: (loaded: number, total: number) => void
} }
export interface Response<T = unknown> { export interface Response<T = unknown> {
@ -60,7 +61,8 @@ export async function request<T = unknown>(
timeout = 30000, timeout = 30000,
responseType = 'text', responseType = 'text',
followRedirect = true, followRedirect = true,
maxRedirects = 20 maxRedirects = 20,
onProgress
} = options } = options
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -124,8 +126,15 @@ export async function request<T = unknown>(
responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1] responseHeaders[rawHeaders[i].toLowerCase()] = rawHeaders[i + 1]
} }
const totalSize = parseInt(responseHeaders['content-length'] || '0', 10)
let loadedSize = 0
res.on('data', (chunk: Buffer) => { res.on('data', (chunk: Buffer) => {
chunks.push(chunk) chunks.push(chunk)
if (onProgress && totalSize > 0) {
loadedSize += chunk.length
onProgress(loadedSize, totalSize)
}
}) })
res.on('end', () => { res.on('end', () => {

View File

@ -162,7 +162,8 @@ const validListenChannels = [
'controledMihomoConfigUpdated', 'controledMihomoConfigUpdated',
'profileConfigUpdated', 'profileConfigUpdated',
'groupsUpdated', 'groupsUpdated',
'rulesUpdated' 'rulesUpdated',
'updateDownloadProgress'
] as const ] as const
// 允许的 send channels 白名单 // 允许的 send channels 白名单

View File

@ -9,7 +9,7 @@ import {
} from '@heroui/react' } from '@heroui/react'
import { toast } from '@renderer/components/base/toast' import { toast } from '@renderer/components/base/toast'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { downloadAndInstallUpdate } from '@renderer/utils/ipc' import { downloadAndInstallUpdate } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -22,15 +22,18 @@ interface Props {
const UpdaterModal: React.FC<Props> = (props) => { const UpdaterModal: React.FC<Props> = (props) => {
const { version, changelog, onClose } = props const { version, changelog, onClose } = props
const [downloading, setDownloading] = useState(false) const [downloading, setDownloading] = useState(false)
const [progress, setProgress] = useState<{ status: 'downloading' | 'verifying'; percent?: number } | null>(null)
const { t } = useTranslation() const { t } = useTranslation()
const onUpdate = async (): Promise<void> => { useEffect(() => {
try { const handler = (_e: Electron.IpcRendererEvent, ...args: unknown[]): void => {
await downloadAndInstallUpdate(version) setProgress(args[0] as { status: 'downloading' | 'verifying'; percent?: number })
} catch (e) {
toast.error(String(e))
} }
window.electron.ipcRenderer.on('updateDownloadProgress', handler)
return () => {
window.electron.ipcRenderer.removeListener('updateDownloadProgress', handler)
} }
}, [])
return ( return (
<Modal <Modal
@ -69,7 +72,23 @@ const UpdaterModal: React.FC<Props> = (props) => {
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter className="flex-col gap-2 items-stretch">
{downloading && progress && (
<div className="flex flex-col gap-1">
<div className="w-full bg-default-200 rounded-full h-1.5">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.status === 'verifying' ? 100 : (progress.percent ?? 0)}%` }}
/>
</div>
<p className="text-xs text-foreground-400 text-center">
{progress.status === 'verifying'
? t('common.updater.verifying')
: `${progress.percent ?? 0}%`}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button size="sm" variant="light" onPress={onClose}> <Button size="sm" variant="light" onPress={onClose}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
@ -80,17 +99,19 @@ const UpdaterModal: React.FC<Props> = (props) => {
onPress={async () => { onPress={async () => {
try { try {
setDownloading(true) setDownloading(true)
await onUpdate() await downloadAndInstallUpdate(version)
onClose() onClose()
} catch (e) { } catch (e) {
toast.error(String(e)) toast.detailedError(String(e))
} finally { } finally {
setDownloading(false) setDownloading(false)
setProgress(null)
} }
}} }}
> >
{t('common.updater.update')} {t('common.updater.update')}
</Button> </Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} Version Ready", "common.updater.versionReady": "v{{version}} Version Ready",
"common.updater.goToDownload": "Go to Download", "common.updater.goToDownload": "Go to Download",
"common.updater.update": "Update", "common.updater.update": "Update",
"common.updater.verifying": "Verifying...",
"common.generateSecret": "Generate Secret", "common.generateSecret": "Generate Secret",
"common.refresh": "Refresh", "common.refresh": "Refresh",
"settings.general": "General Settings", "settings.general": "General Settings",

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "نسخه v{{version}} آماده است", "common.updater.versionReady": "نسخه v{{version}} آماده است",
"common.updater.goToDownload": "دانلود", "common.updater.goToDownload": "دانلود",
"common.updater.update": "به‌روزرسانی", "common.updater.update": "به‌روزرسانی",
"common.updater.verifying": "در حال تأیید...",
"settings.general": "تنظیمات عمومی", "settings.general": "تنظیمات عمومی",
"settings.mihomo": "تنظیمات Mihomo", "settings.mihomo": "تنظیمات Mihomo",
"settings.language": "زبان", "settings.language": "زبان",

View File

@ -67,6 +67,7 @@
"common.updater.versionReady": "Обнаружено ядро с повышенными привилегиями", "common.updater.versionReady": "Обнаружено ядро с повышенными привилегиями",
"common.updater.goToDownload": "Перейти к загрузке", "common.updater.goToDownload": "Перейти к загрузке",
"common.updater.update": "Обновить", "common.updater.update": "Обновить",
"common.updater.verifying": "Проверка...",
"settings.general": "Общие настройки", "settings.general": "Общие настройки",
"settings.mihomo": "Настройки Mihomo", "settings.mihomo": "Настройки Mihomo",
"settings.language": "Язык", "settings.language": "Язык",

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} 版本就绪", "common.updater.versionReady": "v{{version}} 版本就绪",
"common.updater.goToDownload": "前往下载", "common.updater.goToDownload": "前往下载",
"common.updater.update": "更新", "common.updater.update": "更新",
"common.updater.verifying": "正在校验...",
"common.generateSecret": "生成密钥", "common.generateSecret": "生成密钥",
"common.refresh": "刷新", "common.refresh": "刷新",
"settings.general": "通用设置", "settings.general": "通用设置",

View File

@ -65,6 +65,7 @@
"common.updater.versionReady": "v{{version}} 版本就緒", "common.updater.versionReady": "v{{version}} 版本就緒",
"common.updater.goToDownload": "前往下載", "common.updater.goToDownload": "前往下載",
"common.updater.update": "更新", "common.updater.update": "更新",
"common.updater.verifying": "正在校驗...",
"common.generateSecret": "生成密鑰", "common.generateSecret": "生成密鑰",
"common.refresh": "重新整理", "common.refresh": "重新整理",
"settings.general": "通用設置", "settings.general": "通用設置",