auto-update-manager.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. const releasePageUrl = 'https://github.com/Lsamien/yaoyao/releases'
  2. const busyPhases = new Set(['checking', 'downloading', 'preparing', 'installing'])
  3. function errorMessage(error) {
  4. const message = String(error?.message || error || '未知错误')
  5. if (/404|ERR_UPDATER_CHANNEL_FILE_NOT_FOUND|ZIP file not provided/i.test(message))
  6. return '该版本尚未提供完整的自动更新包,请到发布页下载安装。'
  7. if (/signature|code.?sign|指定的要求|签名/i.test(message))
  8. return '安装包签名验证失败,请到发布页安装正式签名版本。'
  9. if (/ENOTFOUND|ECONN|ETIMEDOUT|ERR_INTERNET|net::|timeout/i.test(message))
  10. return '无法连接更新服务器,请检查网络后重试。'
  11. if (/sha512|checksum|digest/i.test(message)) return '安装包完整性校验失败,请重新下载。'
  12. // Keep HTTP dumps and remote HTML out of the product's error area.
  13. return message.split('\n')[0].slice(0, 240)
  14. }
  15. /** Process-wide desktop updates; independent of server identity and login.
  16. * The patched MacUpdater resolves downloadUpdate only after native staging.
  17. * Native staging cannot be canceled or retried safely in the same process. */
  18. export class DesktopAutoUpdateManager {
  19. constructor({ driver, version, arch = process.arch, platform = process.platform, prepareInstall = async () => {}, recoverInstall = async () => {}, stagingTimeout = 300000, restartTimeout = 120000 }) {
  20. Object.assign(this, { driver, platform, prepareInstall, recoverInstall, stagingTimeout, restartTimeout })
  21. this.state = { phase: 'idle', installMode: 'restart', currentVersion: version, arch, available: false,
  22. received: 0, total: 0, message: '点击检查 App 更新', releasePageUrl, retryable: true }
  23. driver.autoDownload = false
  24. // NSIS installation must follow our explicit connection/process cleanup.
  25. driver.autoInstallOnAppQuit = platform !== 'win32'
  26. driver.autoRunAppAfterInstall = true
  27. driver.allowPrerelease = false
  28. driver.allowDowngrade = false
  29. driver.on('error', error => {
  30. if (this.operation || this.nativeStarted) this.failure(error)
  31. })
  32. driver.on('download-progress', progress => {
  33. if (this.operation !== 'downloading' || this.nativeStarted || this.recoveryRequired || this.cancelled) return
  34. this.set({ received: progress.transferred || 0, total: progress.total || 0, message: '正在下载 App 更新…' })
  35. })
  36. driver.on('update-downloaded', () => {
  37. if (this.operation !== 'downloading' || this.recoveryRequired || this.cancelled) return
  38. this.nativeStarted = true
  39. this.set({ phase: 'preparing', message: platform === 'win32' ? '下载完成,正在核对安装包…' : '下载完成,正在验证签名并准备安装…' })
  40. if (platform !== 'win32') {
  41. this.stagingTimer = setTimeout(() => this.failure(new Error('准备更新超时')), this.stagingTimeout)
  42. this.stagingTimer.unref?.()
  43. }
  44. })
  45. }
  46. set(patch) { this.state = { ...this.state, ...patch } }
  47. snapshot() { return { ...this.state, canCancel: this.state.phase === 'downloading' && !this.nativeStarted } }
  48. get busy() { return busyPhases.has(this.state.phase) }
  49. startChecking() {
  50. if (this.firstCheck || this.checkTimer) return
  51. this.firstCheck = setTimeout(() => { this.firstCheck = undefined; void this.check(false) }, 15000)
  52. this.checkTimer = setInterval(() => void this.check(false), 3600000)
  53. this.firstCheck.unref?.(); this.checkTimer.unref?.()
  54. }
  55. stopChecking() {
  56. clearTimeout(this.firstCheck); clearInterval(this.checkTimer)
  57. this.firstCheck = this.checkTimer = undefined
  58. }
  59. async check(manual = true) {
  60. if (this.operation === 'checking' && manual) this.manual = true
  61. if (this.operation || this.nativeStarted || this.recoveryRequired || (!manual && ['failed', 'cancelled'].includes(this.state.phase))) return this.snapshot()
  62. this.operation = 'checking'; this.manual = manual; this.cancelled = false; this.failed = false
  63. this.set({ phase: 'checking', available: false, latestVersion: undefined, notes: '', error: undefined,
  64. retryable: true, received: 0, total: 0, message: '正在检查 App 更新…' })
  65. try {
  66. const result = await this.driver.checkForUpdates()
  67. if (this.failed) return this.snapshot()
  68. if (!result) throw new Error('当前运行环境不支持自动更新,请使用正式安装的 App')
  69. this.token = result.cancellationToken
  70. const info = result.updateInfo
  71. const available = result.isUpdateAvailable === true
  72. const notes = typeof info?.releaseNotes === 'string' ? info.releaseNotes
  73. : Array.isArray(info?.releaseNotes) ? info.releaseNotes.map(note => note.note || '').join('\n\n') : ''
  74. this.set({ phase: 'checked', latestVersion: info?.version, available, notes,
  75. message: available ? `发现 App ${info.version},下载后可重启更新` : '当前 App 已是最新版本' })
  76. } catch (error) { this.failure(error) }
  77. finally { this.operation = undefined }
  78. return this.snapshot()
  79. }
  80. cancel() {
  81. if (this.state.phase !== 'downloading' || this.nativeStarted || !this.token) return
  82. this.cancelled = true
  83. this.token.cancel()
  84. }
  85. async download() {
  86. if (this.operation || this.nativeStarted || this.recoveryRequired || !this.state.available) return this.snapshot()
  87. this.operation = 'downloading'; this.cancelled = false; this.manual = true; this.failed = false
  88. this.set({ phase: 'downloading', received: 0, total: 0, error: undefined, message: '正在连接下载服务器…' })
  89. try {
  90. await this.driver.downloadUpdate(this.token)
  91. if (this.cancelled) this.set({ phase: 'cancelled', message: '下载已取消,可重新检查更新后下载', available: false })
  92. else if (!this.recoveryRequired && !this.failed) {
  93. if (!this.nativeStarted) throw new Error('安装包尚未完成准备,请重新检查更新')
  94. this.set({ phase: 'ready', available: false, message: '更新已准备完成,点击“重启更新”完成安装。' })
  95. }
  96. } catch (error) {
  97. if (this.cancelled && !this.nativeStarted) this.set({ phase: 'cancelled', message: '下载已取消,可重新检查更新后下载', available: false, error: undefined })
  98. else this.failure(error)
  99. } finally { clearTimeout(this.stagingTimer); this.operation = undefined }
  100. return this.snapshot()
  101. }
  102. async recover() {
  103. if (this.recovery) return this.recovery
  104. if (!this.installAttempted || !this.installPrepared) return
  105. this.installAttempted = false
  106. this.recovery = Promise.resolve().then(() => this.recoverInstall()).catch(error => this.driver.logger?.error?.(error))
  107. return this.recovery
  108. }
  109. failure(error) {
  110. if (this.recoveryRequired || this.cancelled) return
  111. this.failed = true
  112. clearTimeout(this.stagingTimer); clearTimeout(this.restartTimer)
  113. this.recoveryRequired = Boolean(this.nativeStarted && (this.platform !== 'win32' || this.installAttempted))
  114. if (this.platform === 'win32' && !this.recoveryRequired) this.nativeStarted = false
  115. const suffix = this.recoveryRequired ? ' 请退出并重新打开 App 后再尝试。' : ''
  116. const silent = this.manual === false && !this.nativeStarted
  117. this.set({ phase: silent ? 'idle' : 'failed',
  118. available: false, retryable: !this.recoveryRequired, error: silent ? undefined : errorMessage(error) + suffix,
  119. message: silent ? '点击检查 App 更新' : this.nativeStarted ? '更新未完成' : '检查或下载更新失败,可重试' })
  120. void this.recover()
  121. }
  122. async install() {
  123. if (this.operation || this.state.phase !== 'ready' || !this.nativeStarted || this.recoveryRequired) return this.snapshot()
  124. this.operation = 'installing'; this.manual = true; this.installAttempted = true
  125. this.stopChecking()
  126. this.set({ phase: 'installing', message: '正在关闭 App 连接并重启更新…', error: undefined })
  127. try {
  128. try { await this.prepareInstall() } finally { this.installPrepared = true }
  129. // A native error may have arrived while our app was releasing resources.
  130. if (this.recoveryRequired) { await this.recover(); return this.snapshot() }
  131. this.driver.quitAndInstall()
  132. if (this.recoveryRequired) return this.snapshot()
  133. this.restartTimer = setTimeout(() => {
  134. this.set({ message: '重启用时较长,请退出并重新打开 App 完成更新。' })
  135. }, this.restartTimeout)
  136. this.restartTimer.unref?.()
  137. } catch (error) { this.failure(error); await this.recover() }
  138. // Do not unlock another install after dispatch: quitAndInstall is not cancelable.
  139. return this.snapshot()
  140. }
  141. verifiedFile() { throw new Error('自动更新包由系统安装,请使用“重启更新”') }
  142. }