Skip to content

Commit

Permalink
feat: add support for differential zip updates on macOS (#7709)
Browse files Browse the repository at this point in the history
  • Loading branch information
beyondkmp authored Feb 27, 2024
1 parent 8e51ba5 commit 79df542
Show file tree
Hide file tree
Showing 6 changed files with 685 additions and 112 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-pans-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"electron-updater": minor
---

feat: adding differential downloader for updates on macOS
62 changes: 62 additions & 0 deletions packages/electron-updater/src/AppUpdater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DownloadOptions,
CancellationError,
ProgressInfo,
BlockMap,
} from "builder-util-runtime"
import { randomBytes } from "crypto"
import { EventEmitter } from "events"
Expand All @@ -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
Expand Down Expand Up @@ -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<any>,
oldInstallerFileName: string
): Promise<boolean> {
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<BlockMap> => {
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 {
Expand Down
41 changes: 34 additions & 7 deletions packages/electron-updater/src/MacUpdater.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -26,6 +27,7 @@ export class MacUpdater extends AppUpdater {
})
this.nativeUpdater.on("update-downloaded", () => {
this.squirrelDownloadedUpdate = true
this.debug("nativeUpdater.update-downloaded")
})
}

Expand All @@ -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<Array<string>> {
let files = downloadUpdateOptions.updateInfoAndProvider.provider.resolveFiles(downloadUpdateOptions.updateInfoAndProvider.info)

Expand Down Expand Up @@ -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),
})
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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) {
Expand Down
66 changes: 3 additions & 63 deletions packages/electron-updater/src/NsisUpdater.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -176,63 +173,6 @@ export class NsisUpdater extends BaseUpdater {
return true
}

private async differentialDownloadInstaller(
fileInfo: ResolvedUpdateFileInfo,
downloadUpdateOptions: DownloadUpdateOptions,
installerPath: string,
provider: Provider<any>
): Promise<boolean> {
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<BlockMap> => {
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,
Expand Down
Loading

0 comments on commit 79df542

Please sign in to comment.