mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2026-04-13 08:00:30 +08:00
feat: add download progress and sha256 integrity verification for updates
This commit is contained in:
parent
f5cff160f2
commit
9ddb0f15ff
@ -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 {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -162,7 +162,8 @@ const validListenChannels = [
|
|||||||
'controledMihomoConfigUpdated',
|
'controledMihomoConfigUpdated',
|
||||||
'profileConfigUpdated',
|
'profileConfigUpdated',
|
||||||
'groupsUpdated',
|
'groupsUpdated',
|
||||||
'rulesUpdated'
|
'rulesUpdated',
|
||||||
|
'updateDownloadProgress'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// 允许的 send channels 白名单
|
// 允许的 send channels 白名单
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "زبان",
|
||||||
|
|||||||
@ -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": "Язык",
|
||||||
|
|||||||
@ -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": "通用设置",
|
||||||
|
|||||||
@ -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": "通用設置",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user