From 79df54238621fbe48ba20444129950ba2dc49983 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 27 Feb 2024 13:49:42 +0800 Subject: [PATCH] feat: add support for differential zip updates on macOS (#7709) --- .changeset/breezy-pans-shop.md | 5 + packages/electron-updater/src/AppUpdater.ts | 62 ++ packages/electron-updater/src/MacUpdater.ts | 41 +- packages/electron-updater/src/NsisUpdater.ts | 66 +-- .../updater/differentialUpdateTest.js.snap | 531 ++++++++++++++++++ test/src/updater/differentialUpdateTest.ts | 92 +-- 6 files changed, 685 insertions(+), 112 deletions(-) create mode 100644 .changeset/breezy-pans-shop.md diff --git a/.changeset/breezy-pans-shop.md b/.changeset/breezy-pans-shop.md new file mode 100644 index 00000000000..57bd35eadc3 --- /dev/null +++ b/.changeset/breezy-pans-shop.md @@ -0,0 +1,5 @@ +--- +"electron-updater": minor +--- + +feat: adding differential downloader for updates on macOS diff --git a/packages/electron-updater/src/AppUpdater.ts b/packages/electron-updater/src/AppUpdater.ts index ffbcdcf147a..9f5ae0eb494 100644 --- a/packages/electron-updater/src/AppUpdater.ts +++ b/packages/electron-updater/src/AppUpdater.ts @@ -9,6 +9,7 @@ import { DownloadOptions, CancellationError, ProgressInfo, + BlockMap, } from "builder-util-runtime" import { randomBytes } from "crypto" import { EventEmitter } from "events" @@ -29,6 +30,10 @@ import { ProviderPlatform } from "./providers/Provider" import type { TypedEmitter } from "tiny-typed-emitter" import Session = Electron.Session import { AuthInfo } from "electron" +import { gunzipSync } from "zlib" +import { blockmapFiles } from "./util" +import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" +import { GenericDifferentialDownloader } from "./differentialDownloader/GenericDifferentialDownloader" export type AppUpdaterEvents = { error: (error: Error, message?: string) => void @@ -697,6 +702,63 @@ export abstract class AppUpdater extends (EventEmitter as new () => TypedEmitter log.info(`New version ${version} has been downloaded to ${updateFile}`) return await done(true) } + protected async differentialDownloadInstaller( + fileInfo: ResolvedUpdateFileInfo, + downloadUpdateOptions: DownloadUpdateOptions, + installerPath: string, + provider: Provider, + oldInstallerFileName: string + ): Promise { + try { + if (this._testOnlyOptions != null && !this._testOnlyOptions.isUseDifferentialDownload) { + return true + } + const blockmapFileUrls = blockmapFiles(fileInfo.url, this.app.version, downloadUpdateOptions.updateInfoAndProvider.info.version) + this._logger.info(`Download block maps (old: "${blockmapFileUrls[0]}", new: ${blockmapFileUrls[1]})`) + + const downloadBlockMap = async (url: URL): Promise => { + const data = await this.httpExecutor.downloadToBuffer(url, { + headers: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + }) + + if (data == null || data.length === 0) { + throw new Error(`Blockmap "${url.href}" is empty`) + } + + try { + return JSON.parse(gunzipSync(data).toString()) + } catch (e: any) { + throw new Error(`Cannot parse blockmap "${url.href}", error: ${e}`) + } + } + + const downloadOptions: DifferentialDownloaderOptions = { + newUrl: fileInfo.url, + oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, oldInstallerFileName), + logger: this._logger, + newFile: installerPath, + isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, + requestHeaders: downloadUpdateOptions.requestHeaders, + cancellationToken: downloadUpdateOptions.cancellationToken, + } + + if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { + downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) + } + + const blockMapDataList = await Promise.all(blockmapFileUrls.map(u => downloadBlockMap(u))) + await new GenericDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download(blockMapDataList[0], blockMapDataList[1]) + return false + } catch (e: any) { + this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) + if (this._testOnlyOptions != null) { + // test mode + throw e + } + return true + } + } } export interface DownloadUpdateOptions { diff --git a/packages/electron-updater/src/MacUpdater.ts b/packages/electron-updater/src/MacUpdater.ts index 44043bb9a38..9816d54e616 100644 --- a/packages/electron-updater/src/MacUpdater.ts +++ b/packages/electron-updater/src/MacUpdater.ts @@ -1,6 +1,7 @@ import { AllPublishOptions, newError, safeStringifyJson } from "builder-util-runtime" -import { stat } from "fs-extra" -import { createReadStream } from "fs" +import { pathExistsSync, stat } from "fs-extra" +import { createReadStream, copyFileSync } from "fs" +import * as path from "path" import { createServer, IncomingMessage, Server, ServerResponse } from "http" import { AppAdapter } from "./AppAdapter" import { AppUpdater, DownloadUpdateOptions } from "./AppUpdater" @@ -26,6 +27,7 @@ export class MacUpdater extends AppUpdater { }) this.nativeUpdater.on("update-downloaded", () => { this.squirrelDownloadedUpdate = true + this.debug("nativeUpdater.update-downloaded") }) } @@ -35,6 +37,17 @@ export class MacUpdater extends AppUpdater { } } + 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) @@ -79,12 +92,26 @@ export class MacUpdater extends AppUpdater { 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: (destinationFile, downloadOptions) => { - return this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions) + task: async (destinationFile, downloadOptions) => { + const cachedFile = path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_MAC_APP_ZIP_FILE_NAME) + const canDifferentialDownload = () => { + if (!pathExistsSync(cachedFile)) { + 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 + } + if (canDifferentialDownload() && (await this.differentialDownloadInstaller(zipFileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_MAC_APP_ZIP_FILE_NAME))) { + await this.httpExecutor.download(zipFileInfo.url, destinationFile, downloadOptions) + } + copyFileSync(destinationFile, cachedFile) }, done: event => this.updateDownloaded(zipFileInfo, event), }) @@ -96,8 +123,8 @@ export class MacUpdater extends AppUpdater { const log = this._logger const logContext = `fileToProxy=${zipFileInfo.url.href}` + this.closeServerIfExists() this.debug(`Creating proxy server for native Squirrel.Mac (${logContext})`) - this.server?.close() this.server = createServer() this.debug(`Proxy server for native Squirrel.Mac is created (${logContext})`) this.server.on("close", () => { @@ -216,12 +243,12 @@ export class MacUpdater extends AppUpdater { if (this.squirrelDownloadedUpdate) { // update already fetched by Squirrel, it's ready to install this.nativeUpdater.quitAndInstall() - this.server?.close() + this.closeServerIfExists() } else { // Quit and install as soon as Squirrel get the update this.nativeUpdater.on("update-downloaded", () => { this.nativeUpdater.quitAndInstall() - this.server?.close() + this.closeServerIfExists() }) if (!this.autoInstallOnAppQuit) { diff --git a/packages/electron-updater/src/NsisUpdater.ts b/packages/electron-updater/src/NsisUpdater.ts index a76ca432cf4..de4780cc235 100644 --- a/packages/electron-updater/src/NsisUpdater.ts +++ b/packages/electron-updater/src/NsisUpdater.ts @@ -1,18 +1,15 @@ -import { AllPublishOptions, newError, PackageFileInfo, BlockMap, CURRENT_APP_PACKAGE_FILE_NAME, CURRENT_APP_INSTALLER_FILE_NAME } from "builder-util-runtime" +import { AllPublishOptions, newError, PackageFileInfo, CURRENT_APP_INSTALLER_FILE_NAME, CURRENT_APP_PACKAGE_FILE_NAME } from "builder-util-runtime" import * as path from "path" import { AppAdapter } from "./AppAdapter" import { DownloadUpdateOptions } from "./AppUpdater" import { BaseUpdater, InstallOptions } from "./BaseUpdater" import { DifferentialDownloaderOptions } from "./differentialDownloader/DifferentialDownloader" import { FileWithEmbeddedBlockMapDifferentialDownloader } from "./differentialDownloader/FileWithEmbeddedBlockMapDifferentialDownloader" -import { GenericDifferentialDownloader } from "./differentialDownloader/GenericDifferentialDownloader" -import { DOWNLOAD_PROGRESS, ResolvedUpdateFileInfo, verifyUpdateCodeSignature } from "./main" -import { blockmapFiles } from "./util" +import { DOWNLOAD_PROGRESS, verifyUpdateCodeSignature } from "./main" import { findFile, Provider } from "./providers/Provider" import { unlink } from "fs-extra" import { verifySignature } from "./windowsExecutableCodeSignatureVerifier" import { URL } from "url" -import { gunzipSync } from "zlib" export class NsisUpdater extends BaseUpdater { /** @@ -67,7 +64,7 @@ export class NsisUpdater extends BaseUpdater { if ( isWebInstaller || downloadUpdateOptions.disableDifferentialDownload || - (await this.differentialDownloadInstaller(fileInfo, downloadUpdateOptions, destinationFile, provider)) + (await this.differentialDownloadInstaller(fileInfo, downloadUpdateOptions, destinationFile, provider, CURRENT_APP_INSTALLER_FILE_NAME)) ) { await this.httpExecutor.download(fileInfo.url, destinationFile, downloadOptions) } @@ -176,63 +173,6 @@ export class NsisUpdater extends BaseUpdater { return true } - private async differentialDownloadInstaller( - fileInfo: ResolvedUpdateFileInfo, - downloadUpdateOptions: DownloadUpdateOptions, - installerPath: string, - provider: Provider - ): Promise { - try { - if (this._testOnlyOptions != null && !this._testOnlyOptions.isUseDifferentialDownload) { - return true - } - const blockmapFileUrls = blockmapFiles(fileInfo.url, this.app.version, downloadUpdateOptions.updateInfoAndProvider.info.version) - this._logger.info(`Download block maps (old: "${blockmapFileUrls[0]}", new: ${blockmapFileUrls[1]})`) - - const downloadBlockMap = async (url: URL): Promise => { - const data = await this.httpExecutor.downloadToBuffer(url, { - headers: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - }) - - if (data == null || data.length === 0) { - throw new Error(`Blockmap "${url.href}" is empty`) - } - - try { - return JSON.parse(gunzipSync(data).toString()) - } catch (e: any) { - throw new Error(`Cannot parse blockmap "${url.href}", error: ${e}`) - } - } - - const downloadOptions: DifferentialDownloaderOptions = { - newUrl: fileInfo.url, - oldFile: path.join(this.downloadedUpdateHelper!.cacheDir, CURRENT_APP_INSTALLER_FILE_NAME), - logger: this._logger, - newFile: installerPath, - isUseMultipleRangeRequest: provider.isUseMultipleRangeRequest, - requestHeaders: downloadUpdateOptions.requestHeaders, - cancellationToken: downloadUpdateOptions.cancellationToken, - } - - if (this.listenerCount(DOWNLOAD_PROGRESS) > 0) { - downloadOptions.onProgress = it => this.emit(DOWNLOAD_PROGRESS, it) - } - - const blockMapDataList = await Promise.all(blockmapFileUrls.map(u => downloadBlockMap(u))) - await new GenericDifferentialDownloader(fileInfo.info, this.httpExecutor, downloadOptions).download(blockMapDataList[0], blockMapDataList[1]) - return false - } catch (e: any) { - this._logger.error(`Cannot download differentially, fallback to full download: ${e.stack || e}`) - if (this._testOnlyOptions != null) { - // test mode - throw e - } - return true - } - } - private async differentialDownloadWebPackage( downloadUpdateOptions: DownloadUpdateOptions, packageInfo: PackageFileInfo, diff --git a/test/snapshots/updater/differentialUpdateTest.js.snap b/test/snapshots/updater/differentialUpdateTest.js.snap index aa982b54418..bb158b6ca60 100644 --- a/test/snapshots/updater/differentialUpdateTest.js.snap +++ b/test/snapshots/updater/differentialUpdateTest.js.snap @@ -180,6 +180,537 @@ Array [ ] `; +exports[`Mac Intel 1`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.0-mac.zip", + }, + ], + "path": "Test App ßW-1.0.0-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.0", + }, + }, + Object { + "arch": "x64", + "file": "Test App ßW-1.0.0-mac.zip", + "safeArtifactName": "TestApp-1.0.0-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.0-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.0-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac Intel 2`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.0", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac Intel 3`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.1-mac.zip", + }, + ], + "path": "Test App ßW-1.0.1-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", + }, + }, + Object { + "arch": "x64", + "file": "Test App ßW-1.0.1-mac.zip", + "safeArtifactName": "TestApp-1.0.1-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.1-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.1-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac Intel 4`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.1", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac Intel 5`] = ` +Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + }, + ], + "path": "Test App ßW-1.0.1-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", +} +`; + +exports[`Mac Intel 6`] = ` +Array [ + "Test App ßW-1.0.1-mac.zip", +] +`; + +exports[`Mac arm64 1`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.0-arm64-mac.zip", + }, + ], + "path": "Test App ßW-1.0.0-arm64-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.0", + }, + }, + Object { + "arch": "arm64", + "file": "Test App ßW-1.0.0-arm64-mac.zip", + "safeArtifactName": "TestApp-1.0.0-arm64-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.0-arm64-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.0-arm64-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac arm64 2`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.0", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac arm64 3`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.1-arm64-mac.zip", + }, + ], + "path": "Test App ßW-1.0.1-arm64-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", + }, + }, + Object { + "arch": "arm64", + "file": "Test App ßW-1.0.1-arm64-mac.zip", + "safeArtifactName": "TestApp-1.0.1-arm64-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.1-arm64-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.1-arm64-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac arm64 4`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.1", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac arm64 5`] = ` +Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + }, + ], + "path": "Test App ßW-1.0.1-arm64-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", +} +`; + +exports[`Mac arm64 6`] = ` +Array [ + "Test App ßW-1.0.1-arm64-mac.zip", +] +`; + +exports[`Mac universal 1`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.0-universal-mac.zip", + }, + ], + "path": "Test App ßW-1.0.0-universal-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.0", + }, + }, + Object { + "arch": "universal", + "file": "Test App ßW-1.0.0-universal-mac.zip", + "safeArtifactName": "TestApp-1.0.0-universal-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.0-universal-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.0-universal-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac universal 2`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.0", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac universal 3`] = ` +Object { + "mac": Array [ + Object { + "file": "latest-mac.yml", + "fileContent": Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + "url": "Test App ßW-1.0.1-universal-mac.zip", + }, + ], + "path": "Test App ßW-1.0.1-universal-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", + }, + }, + Object { + "arch": "universal", + "file": "Test App ßW-1.0.1-universal-mac.zip", + "safeArtifactName": "TestApp-1.0.1-universal-mac.zip", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + Object { + "file": "Test App ßW-1.0.1-universal-mac.zip.blockmap", + "safeArtifactName": "Test App ßW-1.0.1-universal-mac.zip.blockmap", + "updateInfo": Object { + "sha512": "@sha512", + "size": "@size", + }, + }, + ], +} +`; + +exports[`Mac universal 4`] = ` +Object { + "CFBundleDisplayName": "Test App ßW", + "CFBundleExecutable": "Test App ßW", + "CFBundleIconFile": "icon.icns", + "CFBundleIdentifier": "org.electron-builder.testApp", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Test App ßW", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0.1", + "LSApplicationCategoryType": "your.app.category.type", + "NSAppTransportSecurity": Object { + "NSAllowsLocalNetworking": true, + "NSExceptionDomains": Object { + "127.0.0.1": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + "localhost": Object { + "NSIncludesSubdomains": false, + "NSTemporaryExceptionAllowsInsecureHTTPLoads": true, + "NSTemporaryExceptionAllowsInsecureHTTPSLoads": false, + "NSTemporaryExceptionMinimumTLSVersion": "1.0", + "NSTemporaryExceptionRequiresForwardSecrecy": false, + }, + }, + }, + "NSBluetoothAlwaysUsageDescription": "This app needs access to Bluetooth", + "NSBluetoothPeripheralUsageDescription": "This app needs access to Bluetooth", + "NSHighResolutionCapable": true, + "NSPrincipalClass": "AtomApplication", + "NSSupportsAutomaticGraphicsSwitching": true, +} +`; + +exports[`Mac universal 5`] = ` +Object { + "files": Array [ + Object { + "sha512": "@sha512", + "size": "@size", + }, + ], + "path": "Test App ßW-1.0.1-universal-mac.zip", + "releaseDate": "@releaseDate", + "sha512": "@sha512", + "version": "1.0.1", +} +`; + +exports[`Mac universal 6`] = ` +Array [ + "Test App ßW-1.0.1-universal-mac.zip", +] +`; + exports[`nsis 1`] = ` Object { "win": Array [ diff --git a/test/src/updater/differentialUpdateTest.ts b/test/src/updater/differentialUpdateTest.ts index 9ba8cc9be66..1699dcb8f55 100644 --- a/test/src/updater/differentialUpdateTest.ts +++ b/test/src/updater/differentialUpdateTest.ts @@ -1,8 +1,8 @@ import { Arch, Configuration, Platform } from "app-builder-lib" import { getBinFromUrl } from "app-builder-lib/out/binDownload" -import { doSpawn } from "builder-util" +import { doSpawn, getArchSuffix } from "builder-util" import { GenericServerOptions, S3Options } from "builder-util-runtime" -import { AppImageUpdater, MacUpdater, NsisUpdater } from "electron-updater" +import { AppImageUpdater, BaseUpdater, MacUpdater, NsisUpdater } from "electron-updater" import { EventEmitter } from "events" import { move } from "fs-extra" import * as path from "path" @@ -80,7 +80,7 @@ test.ifWindows("web installer", async () => { const oldDir = outDirs[0] await move(path.join(oldDir, "nsis-web", `TestApp-${OLD_VERSION_NUMBER}-x64.nsis.7z`), path.join(getTestUpdaterCacheDir(oldDir), testAppCacheDirName, "package.7z")) - await testBlockMap(outDirs[0], path.join(outDirs[1], "nsis-web"), NsisUpdater, "win-unpacked", Platform.WINDOWS) + await testBlockMap(outDirs[0], path.join(outDirs[1], "nsis-web"), NsisUpdater, Platform.WINDOWS, Arch.x64) }) test.ifWindows("nsis", async () => { @@ -89,10 +89,11 @@ test.ifWindows("nsis", async () => { await doBuild(outDirs, Platform.WINDOWS.createTarget(["nsis"], Arch.x64), tmpDir, true) const oldDir = outDirs[0] + // move to new dir so that localhost server can read both blockmaps await move(path.join(oldDir, `Test App ßW Setup ${OLD_VERSION_NUMBER}.exe`), path.join(getTestUpdaterCacheDir(oldDir), testAppCacheDirName, "installer.exe")) await move(path.join(oldDir, `Test App ßW Setup ${OLD_VERSION_NUMBER}.exe.blockmap`), path.join(outDirs[1], "Test App ßW Setup 1.0.0.exe.blockmap")) - await testBlockMap(outDirs[0], outDirs[1], NsisUpdater, "win-unpacked", Platform.WINDOWS) + await testBlockMap(outDirs[0], outDirs[1], NsisUpdater, Platform.WINDOWS, Arch.x64) }) async function testLinux(arch: Arch) { @@ -104,7 +105,7 @@ async function testLinux(arch: Arch) { await doBuild(outDirs, Platform.LINUX.createTarget(["appimage"], arch), tmpDir, false) process.env.APPIMAGE = path.join(outDirs[0], `Test App ßW-${OLD_VERSION_NUMBER}${arch === Arch.ia32 ? "-i386" : ""}.AppImage`) - await testBlockMap(outDirs[0], outDirs[1], AppImageUpdater, `linux-${arch === Arch.ia32 ? "ia32-" : ""}unpacked`, Platform.LINUX) + await testBlockMap(outDirs[0], outDirs[1], AppImageUpdater, Platform.LINUX, arch) } finally { await tmpDir.cleanup() } @@ -114,20 +115,40 @@ test.ifDevOrLinuxCi("AppImage", () => testLinux(Arch.x64)) test.ifDevOrLinuxCi("AppImage ia32", () => testLinux(Arch.ia32)) -// ifAll.ifMac.ifNotCi todo -test.skip("zip", async () => { +async function testMac(arch: Arch) { + process.env.TEST_UPDATER_ARCH = Arch[arch] + const outDirs: Array = [] const tmpDir = new TmpDir("differential-updater-test") - await doBuild(outDirs, Platform.MAC.createTarget(["zip"], Arch.x64), tmpDir, false, { - mac: { - electronUpdaterCompatibility: ">=2.17.0", - }, - }) + try { + await doBuild(outDirs, Platform.MAC.createTarget(["zip"], arch), tmpDir, false, { + mac: { + electronUpdaterCompatibility: ">=2.17.0", + }, + }) - await testBlockMap(outDirs[0], path.join(outDirs[1]), MacUpdater, "mac/Test App ßW.app", Platform.MAC) -}) + // move to new dir so that localhost server can read both blockmaps + const oldDir = outDirs[0] + const blockmap = `Test App ßW-${OLD_VERSION_NUMBER}${getArchSuffix(arch)}-mac.zip.blockmap` + await move(path.join(oldDir, blockmap), path.join(outDirs[1], blockmap)) + await move(path.join(oldDir, `Test App ßW-${OLD_VERSION_NUMBER}${getArchSuffix(arch)}-mac.zip`), path.join(getTestUpdaterCacheDir(oldDir), testAppCacheDirName, "update.zip")) + + await testBlockMap(outDirs[0], outDirs[1], MacUpdater, Platform.MAC, arch, "Test App ßW") + } finally { + await tmpDir.cleanup() + } +} + +test.ifMac("Mac Intel", () => testMac(Arch.x64)) +test.ifMac("Mac universal", () => testMac(Arch.universal)) + +// only run on arm64 macs, otherwise of course no files can be found to be updated to (due to arch mismatch) +test.ifMac.ifEnv(process.arch === "arm64")("Mac arm64", () => testMac(Arch.arm64)) + +async function checkResult(updater: BaseUpdater) { + // disable automatic install otherwise mac updater will permanently wait on mocked electron's native updater to receive update (mocked server can't install) + updater.autoInstallOnAppQuit = false -async function checkResult(updater: NsisUpdater) { const updateCheckResult = await updater.checkForUpdates() const downloadPromise = updateCheckResult?.downloadPromise // noinspection JSIgnoredPromiseFromCall @@ -135,7 +156,7 @@ async function checkResult(updater: NsisUpdater) { const files = await downloadPromise const fileInfo: any = updateCheckResult?.updateInfo.files[0] - // because port is random + // delete url because port is random expect(fileInfo.url).toBeDefined() delete fileInfo.url expect(removeUnstableProperties(updateCheckResult?.updateInfo)).toMatchSnapshot() @@ -143,27 +164,13 @@ async function checkResult(updater: NsisUpdater) { } class TestNativeUpdater extends EventEmitter { - // private updateUrl: string | null = null - - // noinspection JSMethodCanBeStatic checkForUpdates() { console.log("TestNativeUpdater.checkForUpdates") // MacUpdater expects this to emit corresponding update-downloaded event this.emit("update-downloaded") - // this.download() - // .catch(error => { - // this.emit("error", error) - // }) } - - // private async download() { - // } - - // noinspection JSMethodCanBeStatic - // eslint-disable-next-line @typescript-eslint/no-unused-vars - setFeedURL(_updateUrl: string) { - // console.log("TestNativeUpdater.setFeedURL " + updateUrl) - // this.updateUrl = updateUrl + setFeedURL(updateConfig: any) { + console.log("TestNativeUpdater.setFeedURL " + updateConfig.url) } } @@ -171,14 +178,17 @@ function getTestUpdaterCacheDir(oldDir: string) { return path.join(oldDir, "updater-cache") } -async function testBlockMap(oldDir: string, newDir: string, updaterClass: any, appUpdateConfigPath: string, platform: Platform) { +async function testBlockMap(oldDir: string, newDir: string, updaterClass: any, platform: Platform, arch: Arch, productFilename?: string) { + const appUpdateConfigPath = path.join( + `${platform.buildConfigurationKey}${getArchSuffix(arch)}${platform === Platform.MAC ? "" : "-unpacked"}`, + platform === Platform.MAC ? `${productFilename}.app` : "" + ) const port = 8000 + (updaterClass.name.charCodeAt(0) as number) + Math.floor(Math.random() * 10000) - // noinspection SpellCheckingInspection - const httpServerProcess = doSpawn( - path.join(await getBinFromUrl("ran", "0.1.3", "imfA3LtT6umMM0BuQ29MgO3CJ9uleN5zRBi3sXzcTbMOeYZ6SQeN7eKr3kXZikKnVOIwbH+DDO43wkiR/qTdkg=="), process.platform, "ran"), - [`-root=${newDir}`, `-port=${port}`, "-gzip=false", "-listdir=true"] - ) + const serverBin = await getBinFromUrl("ran", "0.1.3", "imfA3LtT6umMM0BuQ29MgO3CJ9uleN5zRBi3sXzcTbMOeYZ6SQeN7eKr3kXZikKnVOIwbH+DDO43wkiR/qTdkg==") + const httpServerProcess = doSpawn(path.join(serverBin, process.platform, "ran"), [`-root=${newDir}`, `-port=${port}`, "-gzip=false", "-listdir=true"]) + + // Mac uses electron's native autoUpdater to serve updates to, we mock here since electron API isn't available within jest runtime const mockNativeUpdater = new TestNativeUpdater() jest.mock( "electron", @@ -190,7 +200,7 @@ async function testBlockMap(oldDir: string, newDir: string, updaterClass: any, a { virtual: true } ) - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { httpServerProcess.on("error", reject) const updater = new updaterClass(null, new TestAppAdapter(OLD_VERSION_NUMBER, getTestUpdaterCacheDir(oldDir))) @@ -220,9 +230,7 @@ async function testBlockMap(oldDir: string, newDir: string, updaterClass: any, a await checkResult(updater) } - doTest() - .then(() => resolve(null)) - .catch(reject) + doTest().then(resolve).catch(reject) }).then( v => { httpServerProcess.kill()