update-coordinator.ts 3.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. /** One Electron release stream for the version-bound shell and bundled dsh runtime. */
  2. import { existsSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { app } from 'electron'
  5. import electronUpdater, { type AppUpdater } from 'electron-updater'
  6. import type { DesktopUpdateState } from './ipc.ts'
  7. const { autoUpdater } = electronUpdater
  8. /** Checks, downloads, and installs one complete Desktop release. */
  9. export class DesktopUpdateCoordinator {
  10. private availableVersion: string | undefined
  11. private checkOperation: Promise<DesktopUpdateState> | undefined
  12. private installOperation: Promise<DesktopUpdateState> | undefined
  13. /**
  14. * @param publish - state sink for every desktop window.
  15. * @param beforeRestart - stop application-owned processes before replacement.
  16. * @param updater - Electron artifact updater; replaceable for tests.
  17. * @param enabled - whether this packaged process carries updater configuration.
  18. */
  19. constructor(
  20. private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
  21. private readonly beforeRestart: () => Promise<void> = async () => {},
  22. private readonly updater: AppUpdater = autoUpdater,
  23. private readonly enabled: () => boolean = () => (
  24. app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml'))
  25. ),
  26. ) {
  27. this.updater.autoDownload = false
  28. this.updater.autoInstallOnAppQuit = false
  29. }
  30. /** Check the configured Desktop release stream and retain an available version. */
  31. async check(): Promise<DesktopUpdateState> {
  32. if (this.installOperation !== undefined) return this.installOperation
  33. if (this.checkOperation !== undefined) return this.checkOperation
  34. this.checkOperation = this.doCheck().finally(() => { this.checkOperation = undefined })
  35. return this.checkOperation
  36. }
  37. /** Wait for an in-flight check, then download and install its retained release. */
  38. async install(): Promise<DesktopUpdateState> {
  39. if (this.installOperation !== undefined) return this.installOperation
  40. this.installOperation = (async () => {
  41. await this.checkOperation
  42. return this.doInstall()
  43. })().finally(() => { this.installOperation = undefined })
  44. return this.installOperation
  45. }
  46. private async doCheck(): Promise<DesktopUpdateState> {
  47. this.publish({ phase: 'checking' })
  48. try {
  49. if (!this.enabled()) {
  50. this.availableVersion = undefined
  51. return this.publish({ phase: 'idle' })
  52. }
  53. const result = await this.updater.checkForUpdates()
  54. const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined
  55. this.availableVersion = version
  56. return version === undefined
  57. ? this.publish({ phase: 'idle' })
  58. : this.publish({ phase: 'available', version })
  59. } catch (error) {
  60. this.availableVersion = undefined
  61. return this.publish({
  62. phase: 'error',
  63. message: error instanceof Error ? error.message : String(error),
  64. })
  65. }
  66. }
  67. private async doInstall(): Promise<DesktopUpdateState> {
  68. const version = this.availableVersion
  69. if (version === undefined) {
  70. throw new Error('desktop update: no verified update is available')
  71. }
  72. this.publish({ phase: 'installing', version })
  73. try {
  74. await this.updater.downloadUpdate()
  75. this.availableVersion = undefined
  76. const ready = this.publish({ phase: 'ready', version })
  77. await this.beforeRestart()
  78. this.updater.quitAndInstall(false, true)
  79. return ready
  80. } catch (error) {
  81. return this.publish({
  82. phase: 'error',
  83. version,
  84. message: error instanceof Error ? error.message : String(error),
  85. })
  86. }
  87. }
  88. }