update-manager.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { createHash, randomUUID } from 'node:crypto'
  2. import { createReadStream } from 'node:fs'
  3. import { mkdir, open, rename, rm, lstat } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { execFile } from 'node:child_process'
  6. import { promisify } from 'node:util'
  7. const hosts = new Set(['github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'github-releases.githubusercontent.com'])
  8. export async function githubDownload(url, fetchImpl, signal) {
  9. for (let redirect = 0; redirect < 6; redirect++) {
  10. const parsed = new URL(url)
  11. if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password || !hosts.has(parsed.hostname)) throw new Error('安装包下载地址不受信任')
  12. const response = await fetchImpl(parsed.href, { redirect: 'manual', signal })
  13. if ([301, 302, 303, 307, 308].includes(response.status)) {
  14. const location = response.headers.get('location')
  15. await response.body?.cancel()
  16. if (!location) throw new Error('安装包下载重定向无效')
  17. url = new URL(location, parsed).href; continue
  18. }
  19. if (!response.ok) throw new Error(`安装包下载返回 HTTP ${response.status}`)
  20. return response
  21. }
  22. throw new Error('安装包下载重定向过多')
  23. }
  24. async function fileHash(path, signal) {
  25. const stat = await lstat(path)
  26. if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('安装包缓存不是普通文件')
  27. const hash = createHash('sha256')
  28. for await (const chunk of createReadStream(path, { signal })) hash.update(chunk)
  29. return { size: stat.size, hash: hash.digest('hex') }
  30. }
  31. function assertDigest(digest, actual) {
  32. if (digest && digest !== `sha256:${actual}`) throw new Error('GitHub 附件摘要与下载内容不一致')
  33. }
  34. export class DesktopUpdateManager {
  35. constructor({ version, arch = process.arch, platform = process.platform, cacheRoot, inspect, compare, source, fetchImpl = fetch,
  36. verifyImage = (path, signal) => promisify(execFile)('/usr/bin/hdiutil', ['verify', path], { timeout: 120000, signal, maxBuffer: 1024 * 1024 }) }) {
  37. Object.assign(this, { version, arch, platform, cacheRoot, inspect, compare, source, fetchImpl, verifyImage })
  38. this.state = { phase: 'idle', installMode: 'manual', currentVersion: version, arch, message: '点击检查更新', available: false, received: 0, total: 0 }
  39. }
  40. snapshot() { return { ...this.state } }
  41. install() { throw new Error('开发运行请下载并手动安装正式 App') }
  42. get busy() { return !!this.controller }
  43. cancel() { this.controller?.abort() }
  44. async check() {
  45. if (this.busy) return this.snapshot()
  46. const controller = this.controller = new AbortController()
  47. this.file = undefined; this.release = undefined
  48. this.state = { ...this.state, phase: 'checking', available: false, latestVersion: undefined, notes: '', releasePageUrl: undefined, message: '正在检查 GitHub 稳定版本…', error: undefined, received: 0, total: 0 }
  49. try {
  50. const release = await this.inspect(this.source, this.fetchImpl, controller.signal)
  51. controller.signal.throwIfAborted()
  52. this.release = release
  53. const version = release.manifest.webVersion
  54. this.asset = release.assets.find(asset => asset.name === `Yaoyao-${version}-${this.arch}.dmg`)
  55. this.checksum = release.assets.find(asset => asset.name === 'SHA256SUMS.txt')
  56. const newer = this.compare(version, this.version) > 0
  57. const supported = this.platform === 'darwin' && ['arm64', 'x64'].includes(this.arch)
  58. && this.asset?.size > 0 && this.asset.size <= 2 * 1024 ** 3 && this.checksum?.size <= 65536
  59. this.state = { ...this.state, phase: 'checked', latestVersion: version, notes: release.notes, releasePageUrl: release.releasePageUrl,
  60. available: Boolean(newer && supported), total: supported ? this.asset.size : 0,
  61. message: !newer ? '当前 App 已是最新版本' : supported ? `发现 App ${version},可下载后手动安装` : '此版本缺少适用的安装包或校验文件,请查看发布页' }
  62. } catch (error) { this.failure(error, controller.signal) }
  63. finally { this.controller = undefined }
  64. return this.snapshot()
  65. }
  66. failure(error, signal) {
  67. this.state = { ...this.state, phase: signal.aborted ? 'cancelled' : 'failed',
  68. message: signal.aborted ? '已取消,可重新检查或下载' : '操作未完成,可重试', error: signal.aborted ? undefined : error.message }
  69. }
  70. async download() {
  71. if (this.busy || !this.state.available || !this.asset || !this.checksum) return this.snapshot()
  72. const controller = this.controller = new AbortController()
  73. const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10 * 60 * 1000)])
  74. const directory = join(this.cacheRoot, this.release.manifest.webVersion)
  75. const path = join(directory, this.asset.name), partial = join(directory, `${randomUUID()}.part`)
  76. this.file = undefined
  77. this.state = { ...this.state, phase: 'downloading', received: 0, error: undefined, message: '正在下载并核对安装包…' }
  78. try {
  79. await mkdir(directory, { recursive: true, mode: 0o700 })
  80. const sums = await githubDownload(this.checksum.url, this.fetchImpl, signal)
  81. const chunks = []; let size = 0
  82. for await (const chunk of sums.body) {
  83. size += chunk.length
  84. if (size > 65536 || size > this.checksum.size) throw new Error('校验文件大小无效')
  85. chunks.push(Buffer.from(chunk))
  86. }
  87. if (size !== this.checksum.size) throw new Error('校验文件下载不完整')
  88. const bytes = Buffer.concat(chunks)
  89. assertDigest(this.checksum.digest, createHash('sha256').update(bytes).digest('hex'))
  90. const matches = bytes.toString('utf8').split(/\r?\n/).map(line => line.match(/^([a-fA-F0-9]{64}) [ *](.+)$/))
  91. .filter(match => match && match[2] === this.asset.name)
  92. if (matches.length !== 1) throw new Error('校验文件缺少唯一的安装包 SHA-256')
  93. const expected = matches[0][1].toLowerCase()
  94. assertDigest(this.asset.digest, expected)
  95. let cached
  96. try { cached = await fileHash(path, signal) } catch (error) { if (error.code !== 'ENOENT') throw error }
  97. if (!cached || cached.size !== this.asset.size || cached.hash !== expected) {
  98. const response = await githubDownload(this.asset.url, this.fetchImpl, signal)
  99. const file = await open(partial, 'wx', 0o600), hash = createHash('sha256')
  100. let received = 0
  101. try {
  102. for await (const chunk of response.body) {
  103. signal.throwIfAborted(); received += chunk.length
  104. if (received > this.asset.size) throw new Error('安装包大小超过发布声明')
  105. hash.update(chunk)
  106. let offset = 0
  107. while (offset < chunk.length) offset += (await file.write(chunk, offset, chunk.length - offset)).bytesWritten
  108. this.state = { ...this.state, received }
  109. }
  110. if (received !== this.asset.size || hash.digest('hex') !== expected) throw new Error('安装包大小或 SHA-256 校验失败')
  111. } finally { await file.close() }
  112. this.state = { ...this.state, phase: 'verifying', message: '正在验证 DMG 完整性…' }
  113. await this.verifyImage(partial, signal); signal.throwIfAborted()
  114. await rename(partial, path)
  115. } else {
  116. this.state = { ...this.state, phase: 'verifying', message: '正在重新验证缓存安装包…' }
  117. await this.verifyImage(path, signal); signal.throwIfAborted()
  118. }
  119. this.file = { path, hash: expected, size: this.asset.size }
  120. this.state = { ...this.state, phase: 'ready', received: this.asset.size, message: '校验通过。打开安装包后手动拖入“应用程序”完成安装。' }
  121. } catch (error) { this.failure(error, controller.signal) }
  122. finally { await rm(partial, { force: true }).catch(() => {}); this.controller = undefined }
  123. return this.snapshot()
  124. }
  125. async verifiedFile() {
  126. if (this.busy || !this.file || this.state.phase !== 'ready') throw new Error('请先下载并验证安装包')
  127. const actual = await fileHash(this.file.path)
  128. if (actual.hash !== this.file.hash || actual.size !== this.file.size) {
  129. this.file = undefined; this.state = { ...this.state, phase: 'failed', message: '缓存安装包已改变,请重新下载' }
  130. throw new Error(this.state.message)
  131. }
  132. return this.file.path
  133. }
  134. }