import { AllPublishOptions, newError, safeStringifyJson } from "builder-util-runtime"
import { pathExistsSync, stat, copyFile } from "fs-extra"
import { createReadStream } from "fs"
import * as path from "path"
import { createServer, IncomingMessage, Server, ServerResponse } from "http"
import { AppAdapter } from "./AppAdapter"
import { AppUpdater, DownloadUpdateOptions } from "./AppUpdater"
import { ResolvedUpdateFileInfo } from "./main"
import { UpdateDownloadedEvent } from "./types"
import { findFile } from "./providers/Provider"
import AutoUpdater = Electron.AutoUpdater
import { execFileSync } from "child_process"
import { randomBytes } from "crypto"
export class MacUpdater extends AppUpdater {
private readonly nativeUpdater: AutoUpdater = require("electron").autoUpdater
private squirrelDownloadedUpdate = false
private server?: Server
constructor(options?: AllPublishOptions, app?: AppAdapter) {
super(options, app)
this.nativeUpdater.on("error", it => {
this._logger.warn(it)
this.emit("error", it)
})
this.nativeUpdater.on("update-downloaded", () => {
this.squirrelDownloadedUpdate = true
this.debug("nativeUpdater.update-downloaded")
})
}
private debug(message: string): void {
if (this._logger.debug != null) {
this._logger.debug(message)
}
}
private closeServerIfExists() {
if (this.server) {
this.debug("Closing proxy server")
this.server.close(err => {
if (err) {
this.debug("proxy server wasn't already open, probably attempted closing again as a safety check before quit")
}
})
}
}
protected async doDownloadUpdate(downloadUpdateOptions: DownloadUpdateOptions): Promise<Array<string>> {
let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info)
const log = this._logger
const sysctlRosettaInfoKey = "sysctl.proc_translated"
let isRosetta = false
try {
this.debug("Checking for macOS Rosetta environment")
const result = execFileSync("sysctl", [sysctlRosettaInfoKey], { encoding: "utf8" })
isRosetta = result.includes(`${sysctlRosettaInfoKey}: 1`)
log.info(`Checked for macOS Rosetta environment (isRosetta=${isRosetta})`)
} catch (e: any) {
log.warn(`sysctl shell command to check for macOS Rosetta environment failed: ${e}`)
}
let isArm64Mac = false
try {
this.debug("Checking for arm64 in uname")
const result = execFileSync("uname", ["-a"], { encoding: "utf8" })
const isArm = result.includes("ARM")
log.info(`Checked 'uname -a': arm64=${isArm}`)
isArm64Mac = isArm64Mac || isArm
} catch (e: any) {
log.warn(`uname shell command to check for arm64 failed: ${e}`)
}
isArm64Mac = isArm64Mac || process.arch === "arm64" || isRosetta
const isArm64 = (file: ResolvedUpdateFileInfo) => file.url.pathname.includes("arm64") || file.info.url?.includes("arm64")
if (isArm64Mac && files.some(isArm64)) {
files = files.filter(file => isArm64Mac === isArm64(file))
} else {
files = files.filter(file => !isArm64(file))
}
const zipFileInfo = findFile(files, "zip", ["pkg", "dmg"])
if (zipFileInfo == null) {
throw newError(`ZIP file not provided: ${safeStringifyJson(files)}`, "ERR_UPDATER_ZIP_FILE_NOT_FOUND")
}
const provider = downloadUpdateOptions.updateInfoAndProvider.provider
const CURRENT_MAC_APP_ZIP_FILE_NAME = "update.zip"
return this.executeDownload({
fileExtension: "zip",
fileInfo: zipFileInfo,
downloadUpdateOptions,
task: async (destinationFile, downloadOptions) => {
const cachedUpdateFilePath = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME)
const canDifferentialDownload = () => {
if (!pathExistsSync(cachedUpdateFilePath)) {
log.info("Unable to locate previous update.zip for differential download (is this first install?), falling back to full download")
return false
}
return !downloadUpdateOptions.disableDifferentialDownload
}
let differentialDownloadFailed = true
if (canDifferentialDownload()) {
differentialDownloadFailed = await this.differentialDownloadInstaller(zipFileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_MAC_APP_ZIP_FILE_NAME)
}
if (differentialDownloadFailed) {
await this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions)
}
},
done: async event => {
if (!downloadUpdateOptions.disableDifferentialDownload) {
try {
const cachedUpdateFilePath = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME)
await copyFile(event.downloadedFile, cachedUpdateFilePath)
} catch (error: any) {
this._logger.warn(`Unable to copy file for caching for future differential downloads: ${error.message}`)
}
}
return this.updateDownloaded(zipFileInfo, event)
},
})
}
private async updateDownloaded(zipFileInfo: ResolvedUpdateFileInfo, event: UpdateDownloadedEvent): Promise<Array<string>> {
const downloadedFile = event.downloadedFile
const updateFileSize = zipFileInfo.info.size ?? (await stat(downloadedFile)).size
const log = this._logger
const logContext = `fileToProxy=${zipFileInfo.url.href}`
this.closeServerIfExists()
this.debug(`Creating proxy server for native Squirrel.Mac (${logContext})`)
this.server = createServer()
this.debug(`Proxy server for native Squirrel.Mac is created (${logContext})`)
this.server.on("close", () => {
log.info(`Proxy server for native Squirrel.Mac is closed (${logContext})`)
})
const getServerUrl = (s: Server): string => {
const address = s.address()
if (typeof address === "string") {
return address
}
return `http://127.0.0.1:${address?.port}`
}
return await new Promise<Array<string>>((resolve, reject) => {
const pass = randomBytes(64).toString("base64").replace(/\//g, "_").replace(/\+/g, "-")
const authInfo = Buffer.from(`autoupdater:${pass}`, "ascii")
const fileUrl = `/${randomBytes(64).toString("hex")}.zip`
this.server!.on("request", (request: IncomingMessage, response: ServerResponse) => {
const requestUrl = request.url!
log.info(`${requestUrl} requested`)
if (requestUrl === "/") {
if (!request.headers.authorization || request.headers.authorization.indexOf("Basic ") === -1) {
response.statusCode = 401
response.statusMessage = "Invalid Authentication Credentials"
response.end()
log.warn("No authenthication info")
return
}
const base64Credentials = request.headers.authorization.split(" ")[1]
const credentials = Buffer.from(base64Credentials, "base64").toString("ascii")
const [username, password] = credentials.split(":")
if (username !== "autoupdater" || password !== pass) {
response.statusCode = 401
response.statusMessage = "Invalid Authentication Credentials"
response.end()
log.warn("Invalid authenthication credentials")
return
}
const data = Buffer.from(`{ "url": "${getServerUrl(this.server!)}${fileUrl}" }`)
response.writeHead(200, { "Content-Type": "application/json", "Content-Length": data.length })
response.end(data)
return
}
if (!requestUrl.startsWith(fileUrl)) {
log.warn(`${requestUrl} requested, but not supported`)
response.writeHead(404)
response.end()
return
}
log.info(`${fileUrl} requested by Squirrel.Mac, pipe ${downloadedFile}`)
let errorOccurred = false
response.on("finish", () => {
if (!errorOccurred) {
this.nativeUpdater.removeListener("error", reject)
resolve([])
}
})
const readStream = createReadStream(downloadedFile)
readStream.on("error", error => {
try {
response.end()
} catch (e: any) {
log.warn(`cannot end response: ${e}`)
}
errorOccurred = true
this.nativeUpdater.removeListener("error", reject)
reject(new Error(`Cannot pipe "${downloadedFile}": ${error}`))
})
response.writeHead(200, {
"Content-Type": "application/zip",
"Content-Length": updateFileSize,
})
readStream.pipe(response)
})
this.debug(`Proxy server for native Squirrel.Mac is starting to listen (${logContext})`)
this.server!.listen(0, "127.0.0.1", () => {
this.debug(`Proxy server for native Squirrel.Mac is listening (address=${getServerUrl(this.server!)}, ${logContext})`)
this.nativeUpdater.setFeedURL({
url: getServerUrl(this.server!),
headers: {
"Cache-Control": "no-cache",
Authorization: `Basic ${authInfo.toString("base64")}`,
},
})
this.dispatchUpdateDownloaded(event)
if (this.autoInstallOnAppQuit) {
this.nativeUpdater.once("error", reject)
this.nativeUpdater.checkForUpdates()
} else {
resolve([])
}
})
})
}
private handleUpdateDownloaded() {
if (this.autoRunAppAfterInstall) {
this.nativeUpdater.quitAndInstall()
} else {
this.app.quit()
}
this.closeServerIfExists()
}
quitAndInstall(): void {
if (this.squirrelDownloadedUpdate) {
this.handleUpdateDownloaded()
} else {
this.nativeUpdater.on("update-downloaded", () => this.handleUpdateDownloaded())
if (!this.autoInstallOnAppQuit) {
* If this was not `true` previously then MacUpdater.doDownloadUpdate()
* would not actually initiate the downloading by electron's autoUpdater
*/
this.nativeUpdater.checkForUpdates()
}
}
}
}