| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135 |
- import { createHash, randomUUID } from 'node:crypto'
- import { createReadStream } from 'node:fs'
- import { mkdir, open, rename, rm, lstat } from 'node:fs/promises'
- import { join } from 'node:path'
- import { execFile } from 'node:child_process'
- import { promisify } from 'node:util'
- const hosts = new Set(['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'github-releases.githubusercontent.com'])
- export async function githubDownload(url, fetchImpl, signal) {
- for (let redirect = 0; redirect < 6; redirect++) {
- const parsed = new URL(url)
- if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password || !hosts.has(parsed.hostname)) throw new Error('安装包下载地址不受信任')
- const response = await fetchImpl(parsed.href, { redirect: 'manual', signal })
- if ([301, 302, 303, 307, 308].includes(response.status)) {
- const location = response.headers.get('location')
- await response.body?.cancel()
- if (!location) throw new Error('安装包下载重定向无效')
- url = new URL(location, parsed).href; continue
- }
- if (!response.ok) throw new Error(`安装包下载返回 HTTP ${response.status}`)
- return response
- }
- throw new Error('安装包下载重定向过多')
- }
- async function fileHash(path, signal) {
- const stat = await lstat(path)
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('安装包缓存不是普通文件')
- const hash = createHash('sha256')
- for await (const chunk of createReadStream(path, { signal })) hash.update(chunk)
- return { size: stat.size, hash: hash.digest('hex') }
- }
- function assertDigest(digest, actual) {
- if (digest && digest !== `sha256:${actual}`) throw new Error('GitHub 附件摘要与下载内容不一致')
- }
- export class DesktopUpdateManager {
- constructor({ version, arch = process.arch, platform = process.platform, cacheRoot, inspect, compare, source, fetchImpl = fetch,
- verifyImage = (path, signal) => promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
- Object.assign(this, { version, arch, platform, cacheRoot, inspect, compare, source, fetchImpl, verifyImage })
- this.state = { phase: 'idle', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
- }
- snapshot() { return { ...this.state } }
- get busy() { return !!this.controller }
- cancel() { this.controller?.abort() }
- async check() {
- if (this.busy) return this.snapshot()
- const controller = this.controller = new AbortController()
- this.file = undefined; this.release = undefined
- this.state = { ...this.state, phase: 'checking', available: false, latestVersion: undefined, notes: '', releasePageUrl: undefined, message: '正在检查 GitHub 稳定版本…', error: undefined, received: 0, total: 0 }
- try {
- const release = await this.inspect(this.source, this.fetchImpl, controller.signal)
- controller.signal.throwIfAborted()
- this.release = release
- const version = release.manifest.webVersion
- this.asset = release.assets.find(asset => asset.name === `Yaoyao-${version}-${this.arch}.dmg`)
- this.checksum = release.assets.find(asset => asset.name === 'SHA256SUMS.txt')
- const newer = this.compare(version, this.version) > 0
- const supported = this.platform === 'darwin' && ['arm64', 'x64'].includes(this.arch)
- && this.asset?.size > 0 && this.asset.size <= 2 * 1024 ** 3 && this.checksum?.size <= 65536
- this.state = { ...this.state, phase: 'checked', latestVersion: version, notes: release.notes, releasePageUrl: release.releasePageUrl,
- available: Boolean(newer && supported), total: supported ? this.asset.size : 0,
- message: !newer ? '当前 App 已是最新版本' : supported ? `发现 App ${version},可下载后手动安装` : '此版本缺少适用的安装包或校验文件,请查看发布页' }
- } catch (error) { this.failure(error, controller.signal) }
- finally { this.controller = undefined }
- return this.snapshot()
- }
- failure(error, signal) {
- this.state = { ...this.state, phase: signal.aborted ? 'cancelled' : 'failed',
- message: signal.aborted ? '已取消,可重新检查或下载' : '操作未完成,可重试', error: signal.aborted ? undefined : error.message }
- }
- async download() {
- if (this.busy || !this.state.available || !this.asset || !this.checksum) return this.snapshot()
- const controller = this.controller = new AbortController()
- const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10 * 60 * 1000)])
- const directory = join(this.cacheRoot, this.release.manifest.webVersion)
- const path = join(directory, this.asset.name), partial = join(directory, `${randomUUID()}.part`)
- this.file = undefined
- this.state = { ...this.state, phase: 'downloading', received: 0, error: undefined, message: '正在下载并核对安装包…' }
- try {
- await mkdir(directory, { recursive: true, mode: 0o700 })
- const sums = await githubDownload(this.checksum.url, this.fetchImpl, signal)
- const chunks = []; let size = 0
- for await (const chunk of sums.body) {
- size += chunk.length
- if (size > 65536 || size > this.checksum.size) throw new Error('校验文件大小无效')
- chunks.push(Buffer.from(chunk))
- }
- if (size !== this.checksum.size) throw new Error('校验文件下载不完整')
- const bytes = Buffer.concat(chunks)
- assertDigest(this.checksum.digest, createHash('sha256').update(bytes).digest('hex'))
- const matches = bytes.toString('utf8').split(/\r?\n/).map(line => line.match(/^([a-fA-F0-9]{64}) [ *](.+)$/))
- .filter(match => match && match[2] === this.asset.name)
- if (matches.length !== 1) throw new Error('校验文件缺少唯一的安装包 SHA-256')
- const expected = matches[0][1].toLowerCase()
- assertDigest(this.asset.digest, expected)
- let cached
- try { cached = await fileHash(path, signal) } catch (error) { if (error.code !== 'ENOENT') throw error }
- if (!cached || cached.size !== this.asset.size || cached.hash !== expected) {
- const response = await githubDownload(this.asset.url, this.fetchImpl, signal)
- const file = await open(partial, 'wx', 0o600), hash = createHash('sha256')
- let received = 0
- try {
- for await (const chunk of response.body) {
- signal.throwIfAborted(); received += chunk.length
- if (received > this.asset.size) throw new Error('安装包大小超过发布声明')
- hash.update(chunk)
- let offset = 0
- while (offset < chunk.length) offset += (await file.write(chunk, offset, chunk.length - offset)).bytesWritten
- this.state = { ...this.state, received }
- }
- if (received !== this.asset.size || hash.digest('hex') !== expected) throw new Error('安装包大小或 SHA-256 校验失败')
- } finally { await file.close() }
- this.state = { ...this.state, phase: 'verifying', message: '正在验证 DMG 完整性…' }
- await this.verifyImage(partial, signal); signal.throwIfAborted()
- await rename(partial, path)
- } else {
- this.state = { ...this.state, phase: 'verifying', message: '正在重新验证缓存安装包…' }
- await this.verifyImage(path, signal); signal.throwIfAborted()
- }
- this.file = { path, hash: expected, size: this.asset.size }
- this.state = { ...this.state, phase: 'ready', received: this.asset.size, message: '校验通过。打开安装包后手动拖入“应用程序”完成安装。' }
- } catch (error) { this.failure(error, controller.signal) }
- finally { await rm(partial, { force: true }).catch(() => {}); this.controller = undefined }
- return this.snapshot()
- }
- async verifiedFile() {
- if (this.busy || !this.file || this.state.phase !== 'ready') throw new Error('请先下载并验证安装包')
- const actual = await fileHash(this.file.path)
- if (actual.hash !== this.file.hash || actual.size !== this.file.size) {
- this.file = undefined; this.state = { ...this.state, phase: 'failed', message: '缓存安装包已改变,请重新下载' }
- throw new Error(this.state.message)
- }
- return this.file.path
- }
- }
|