OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / node_modules / pear-runtime-updater / index.js

234 lines · javascript ref:
const Hyperdrive = require('hyperdrive')
const Localdrive = require('localdrive')
const path = require('path')
const fs = require('fs')
const fsx = require('fs-native-extensions')
const ReadyResource = require('ready-resource')
const link = require('pear-link')
const hid = require('hypercore-id-encoding')
const { platform, arch, isWindows } = require('which-runtime')
const semver = require('bare-semver')
const debounceify = require('debounceify')
const host = platform + '-' + arch

module.exports = class PearRuntimeUpdater extends ReadyResource {
  constructor(opts = {}) {
    super()
    this.updates = opts.updates !== false
    if (!opts.dir) throw new Error('dir required')
    if (!opts.upgrade) throw new Error('upgrade link required')
    if (!opts.name) throw new Error('name required')
    if (!opts.store) throw new Error('store required')

    this.dir = opts.dir
    this.store = opts.store
    this.version = opts.version || '0.0.0-0'
    this.app = opts.app
    this.name = opts.name
    this.bundled = opts.bundled || !!this.app

    const { drive: upgrade } = link.parse(opts.upgrade)
    this.key = hid.decode(upgrade.key)
    this.length = upgrade.length || 0
    this.fork = upgrade.fork || 0
    this.link = link.serialize({ drive: { fork: this.fork, length: this.length, key: this.key } })
    this.drive = new Hyperdrive(this.store, this.key)

    this._start = Date.now()
    this._bootGracePeriod = 60000

    this.next = null
    this.nextVersion = null
    this.checkout = null
    this.prefetched = false
    this.updating = false
    this.updated = false

    this._defaultDelay = 3_600_000 // defaults to 1h
    this._delay = Math.floor(
      Math.random() * (Number.isInteger(opts.delay) ? opts.delay : this._defaultDelay)
    )
    this._scheduledUpdate = null

    this._debouncedUpdate = debounceify(this._update.bind(this))

    this.ready().catch(noop)
  }

  async _open() {
    await this.drive.ready()
    if (!this.updates) return

    if (this.bundled) {
      await fs.promises.rm(path.join(this.dir, 'pear-runtime/next'), {
        recursive: true,
        force: true
      })

      this._debouncedUpdate().catch((err) => this.emit('error', err))
      this.drive.core.on('append', () => {
        if (this._scheduledUpdate !== null) clearTimeout(this._scheduledUpdate) // cancel old pending updates before scheduling new one
        const recentBoot = Date.now() - this._start <= this._bootGracePeriod
        this._scheduledUpdate = setTimeout(
          () => {
            this._debouncedUpdate().catch((err) => this.emit('error', err))
          },
          recentBoot ? 0 : this._delay
        )
        this.emit('update-scheduled', this._delay)
      })
    }
  }

  async _close() {
    await this.drive.close()

    if (!this.updates) return
    if (this.checkout !== null) await this.checkout.close()
    if (this._scheduledUpdate) clearTimeout(this._scheduledUpdate)
  }

  async applyUpdate() {
    if (!this.updated || this.applied || !this.bundled) return
    this.applied = true

    const nextApp = path.join(this.next, 'by-arch', host, 'app', this.name)
    if (isWindows) {
      if (this.name.endsWith('.exe')) {
        await this._applyWindowsExecutableUpdate(nextApp)
      } else if (this.name.endsWith('.msix')) {
        const MSIXManager = require('msix-manager') // require must be here for platform compatibility
        const manager = new MSIXManager()
        await manager.addPackage(nextApp, { forceUpdateFromAnyVersion: true })
      } else {
        throw new Error(`extension of ${this.name} not supported`)
      }
    } else {
      await fsx.swap(nextApp, this.app)
    }
    await fs.promises.rm(this.next, { recursive: true, force: true })
  }

  async _update() {
    if (!this.updates) return

    await this.drive.update()

    const length = this.drive.core.length
    const id = length + '.' + this.drive.core.fork
    const next = path.join(this.dir, 'pear-runtime/next', id)
    const co = this.drive.checkout(length)

    this.checkout = co

    const manifestBuffer = await co.get('/package.json')
    const manifest = manifestBuffer ? JSON.parse(manifestBuffer) : null

    const current = semver.Version.parse(this.version)
    const remote = manifest ? semver.Version.parse(manifest.version) : null

    if (remote && current.compare(remote) === 0 && this.bundled && !this.prefetched) {
      try {
        await this._prefetchLatest()
      } catch (err) {
        this.emit('error', err)
      }
    }

    if (!remote || current.compare(remote) >= 0) {
      this.checkout = null
      await co.close()
      return
    }

    const local = new Localdrive(next)

    const prefix = prefixFor(host, this.name)
    // Binary may be a file or a directory bundle
    // Entries exist only for files, so try exact path first, then iterate under it
    let hasContent = (await co.entry(prefix)) !== null
    if (!hasContent) {
      for await (const _entry of co.list(prefix)) {
        hasContent = true
        break
      }
    }
    if (!hasContent) throw new Error('update not found')
    this.updating = true
    this.emit('updating')
    for await (const data of co.mirror(local, { prefix })) {
      this.emit('updating-delta', data)
    }

    await co.close()
    await local.close()

    this.checkout = null
    this.length = length
    this.next = next
    this.nextVersion = manifest.version

    this.updating = false
    this.updated = true
    this.emit('updated')
  }

  async _prefetchLatest() {
    const length = this.drive.core.length
    if (!length) return

    const co = this.drive.checkout(length)
    const prefix = prefixFor(host, this.name)

    try {
      if (!(await co.has(prefix))) {
        await co.download(prefix).done()
      }
    } finally {
      await co.close()
    }

    this.prefetched = true
  }

  async _applyWindowsExecutableUpdate(nextApp) {
    if (!this.app) throw new Error('app path required')
    if (!this.nextVersion) throw new Error('next version required')

    const ext = path.extname(this.app)
    const base = path.basename(this.app, ext)
    const dir = path.dirname(this.app)
    const previous = path.join(dir, `${base}-${this.version}${ext}`)
    const incoming = path.join(dir, `${base}-${this.nextVersion}${ext}`)

    await fs.promises.copyFile(nextApp, incoming)

    try {
      await fs.promises.rename(this.app, previous)
      await fs.promises.rename(incoming, this.app)
    } catch (err) {
      if (!(await exists(this.app)) && (await exists(previous))) {
        try {
          await fs.promises.rename(previous, this.app)
        } catch {}
      }
      throw err
    }
  }
}

function prefixFor(host, name) {
  return `/by-arch/${host}/app/${name}`
}

async function exists(filename) {
  try {
    await fs.promises.access(filename)
    return true
  } catch {
    return false
  }
}

function noop() {}