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 { 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<void> {
}
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<void> {
},
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 {

View File

@ -15,6 +15,7 @@ export interface RequestOptions {
responseType?: 'text' | 'json' | 'arraybuffer'
followRedirect?: boolean
maxRedirects?: number
onProgress?: (loaded: number, total: number) => void
}
export interface Response<T = unknown> {
@ -60,7 +61,8 @@ export async function request<T = unknown>(
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<T = unknown>(
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', () => {

View File

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

View File

@ -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> = (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<void> => {
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 (
<Modal
@ -69,28 +72,46 @@ const UpdaterModal: React.FC<Props> = (props) => {
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button size="sm" variant="light" onPress={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
color="primary"
isLoading={downloading}
onPress={async () => {
try {
setDownloading(true)
await onUpdate()
onClose()
} catch (e) {
toast.error(String(e))
} finally {
setDownloading(false)
}
}}
>
{t('common.updater.update')}
</Button>
<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}>
{t('common.cancel')}
</Button>
<Button
size="sm"
color="primary"
isLoading={downloading}
onPress={async () => {
try {
setDownloading(true)
await downloadAndInstallUpdate(version)
onClose()
} catch (e) {
toast.detailedError(String(e))
} finally {
setDownloading(false)
setProgress(null)
}
}}
>
{t('common.updater.update')}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>

View File

@ -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",

View File

@ -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": "زبان",

View File

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

View File

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

View File

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