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() {}