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> { let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info) const log = this._logger // detect if we are running inside Rosetta emulation 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 // allow arm64 macs to install universal or rosetta2(x64) - https://github.com/electron-userland/electron-builder/pull/5524 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> { 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})`) }) // must be called after server is listening, otherwise address is null 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>((resolve, reject) => { const pass = randomBytes(64).toString("base64").replace(/\//g, "_").replace(/\+/g, "-") const authInfo = Buffer.from(`autoupdater:${pass}`, "ascii") // insecure random is ok 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 === "/") { // check for basic auth header 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 } // verify auth credentials 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")}`, }, }) // The update has been downloaded and is ready to be served to Squirrel this.dispatchUpdateDownloaded(event) if (this.autoInstallOnAppQuit) { this.nativeUpdater.once("error", reject) // This will trigger fetching and installing the file on Squirrel side this.nativeUpdater.checkForUpdates() } else { resolve([]) } }) }) } private handleUpdateDownloaded() { if (this.autoRunAppAfterInstall) { this.nativeUpdater.quitAndInstall() } else { this.app.quit() } this.closeServerIfExists() } quitAndInstall(): void { if (this.squirrelDownloadedUpdate) { // update already fetched by Squirrel, it's ready to install this.handleUpdateDownloaded() } else { // Quit and install as soon as Squirrel get the update 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() } } } }