Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for differential zip updates on macOS #7709

Merged
merged 19 commits into from Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/breezy-pans-shop.md
@@ -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
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
@@ -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
@@ -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