"use strict";
|
Object.defineProperty(exports, "__esModule", { value: true });
|
exports.NoOpLogger = exports.AppUpdater = void 0;
|
const builder_util_runtime_1 = require("builder-util-runtime");
|
const crypto_1 = require("crypto");
|
const events_1 = require("events");
|
const fs_extra_1 = require("fs-extra");
|
const js_yaml_1 = require("js-yaml");
|
const lazy_val_1 = require("lazy-val");
|
const path = require("path");
|
const semver_1 = require("semver");
|
const DownloadedUpdateHelper_1 = require("./DownloadedUpdateHelper");
|
const ElectronAppAdapter_1 = require("./ElectronAppAdapter");
|
const electronHttpExecutor_1 = require("./electronHttpExecutor");
|
const GenericProvider_1 = require("./providers/GenericProvider");
|
const main_1 = require("./main");
|
const providerFactory_1 = require("./providerFactory");
|
class AppUpdater extends events_1.EventEmitter {
|
constructor(options, app) {
|
super();
|
/**
|
* Whether to automatically download an update when it is found.
|
*/
|
this.autoDownload = true;
|
/**
|
* Whether to automatically install a downloaded update on app quit (if `quitAndInstall` was not called before).
|
*/
|
this.autoInstallOnAppQuit = true;
|
/**
|
* *windows-only* Whether to run the app after finish install when run the installer NOT in silent mode.
|
* @default true
|
*/
|
this.autoRunAppAfterInstall = true;
|
/**
|
* *GitHub provider only.* Whether to allow update to pre-release versions. Defaults to `true` if application version contains prerelease components (e.g. `0.12.1-alpha.1`, here `alpha` is a prerelease component), otherwise `false`.
|
*
|
* If `true`, downgrade will be allowed (`allowDowngrade` will be set to `true`).
|
*/
|
this.allowPrerelease = false;
|
/**
|
* *GitHub provider only.* Get all release notes (from current version to latest), not just the latest.
|
* @default false
|
*/
|
this.fullChangelog = false;
|
/**
|
* Whether to allow version downgrade (when a user from the beta channel wants to go back to the stable channel).
|
*
|
* Taken in account only if channel differs (pre-release version component in terms of semantic versioning).
|
*
|
* @default false
|
*/
|
this.allowDowngrade = false;
|
/**
|
* Web installer files might not have signature verification, this switch prevents to load them unless it is needed.
|
*
|
* Currently false to prevent breaking the current API, but it should be changed to default true at some point that
|
* breaking changes are allowed.
|
*
|
* @default false
|
*/
|
this.disableWebInstaller = false;
|
/**
|
* Allows developer to force the updater to work in "dev" mode, looking for "dev-app-update.yml" instead of "app-update.yml"
|
* Dev: `path.join(this.app.getAppPath(), "dev-app-update.yml")`
|
* Prod: `path.join(process.resourcesPath!, "app-update.yml")`
|
*
|
* @default false
|
*/
|
this.forceDevUpdateConfig = false;
|
this._channel = null;
|
this.downloadedUpdateHelper = null;
|
/**
|
* The request headers.
|
*/
|
this.requestHeaders = null;
|
this._logger = console;
|
// noinspection JSUnusedGlobalSymbols
|
/**
|
* For type safety you can use signals, e.g. `autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})`
|
*/
|
this.signals = new main_1.UpdaterSignal(this);
|
this._appUpdateConfigPath = null;
|
this.clientPromise = null;
|
this.stagingUserIdPromise = new lazy_val_1.Lazy(() => this.getOrCreateStagingUserId());
|
// public, allow to read old config for anyone
|
/** @internal */
|
this.configOnDisk = new lazy_val_1.Lazy(() => this.loadUpdateConfig());
|
this.checkForUpdatesPromise = null;
|
this.updateInfoAndProvider = null;
|
/**
|
* @private
|
* @internal
|
*/
|
this._testOnlyOptions = null;
|
this.on("error", (error) => {
|
this._logger.error(`Error: ${error.stack || error.message}`);
|
});
|
if (app == null) {
|
this.app = new ElectronAppAdapter_1.ElectronAppAdapter();
|
this.httpExecutor = new electronHttpExecutor_1.ElectronHttpExecutor((authInfo, callback) => this.emit("login", authInfo, callback));
|
}
|
else {
|
this.app = app;
|
this.httpExecutor = null;
|
}
|
const currentVersionString = this.app.version;
|
const currentVersion = semver_1.parse(currentVersionString);
|
if (currentVersion == null) {
|
throw builder_util_runtime_1.newError(`App version is not a valid semver version: "${currentVersionString}"`, "ERR_UPDATER_INVALID_VERSION");
|
}
|
this.currentVersion = currentVersion;
|
this.allowPrerelease = hasPrereleaseComponents(currentVersion);
|
if (options != null) {
|
this.setFeedURL(options);
|
if (typeof options !== "string" && options.requestHeaders) {
|
this.requestHeaders = options.requestHeaders;
|
}
|
}
|
}
|
/**
|
* Get the update channel. Not applicable for GitHub. Doesn't return `channel` from the update configuration, only if was previously set.
|
*/
|
get channel() {
|
return this._channel;
|
}
|
/**
|
* Set the update channel. Not applicable for GitHub. Overrides `channel` in the update configuration.
|
*
|
* `allowDowngrade` will be automatically set to `true`. If this behavior is not suitable for you, simple set `allowDowngrade` explicitly after.
|
*/
|
set channel(value) {
|
if (this._channel != null) {
|
// noinspection SuspiciousTypeOfGuard
|
if (typeof value !== "string") {
|
throw builder_util_runtime_1.newError(`Channel must be a string, but got: ${value}`, "ERR_UPDATER_INVALID_CHANNEL");
|
}
|
else if (value.length === 0) {
|
throw builder_util_runtime_1.newError(`Channel must be not an empty string`, "ERR_UPDATER_INVALID_CHANNEL");
|
}
|
}
|
this._channel = value;
|
this.allowDowngrade = true;
|
}
|
/**
|
* Shortcut for explicitly adding auth tokens to request headers
|
*/
|
addAuthHeader(token) {
|
this.requestHeaders = Object.assign({}, this.requestHeaders, {
|
authorization: token,
|
});
|
}
|
// noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols
|
get netSession() {
|
return electronHttpExecutor_1.getNetSession();
|
}
|
/**
|
* The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`.
|
* Set it to `null` if you would like to disable a logging feature.
|
*/
|
get logger() {
|
return this._logger;
|
}
|
set logger(value) {
|
this._logger = value == null ? new NoOpLogger() : value;
|
}
|
// noinspection JSUnusedGlobalSymbols
|
/**
|
* test only
|
* @private
|
*/
|
set updateConfigPath(value) {
|
this.clientPromise = null;
|
this._appUpdateConfigPath = value;
|
this.configOnDisk = new lazy_val_1.Lazy(() => this.loadUpdateConfig());
|
}
|
//noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols
|
getFeedURL() {
|
return "Deprecated. Do not use it.";
|
}
|
/**
|
* Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`.
|
* @param options If you want to override configuration in the `app-update.yml`.
|
*/
|
setFeedURL(options) {
|
const runtimeOptions = this.createProviderRuntimeOptions();
|
// https://github.com/electron-userland/electron-builder/issues/1105
|
let provider;
|
if (typeof options === "string") {
|
provider = new GenericProvider_1.GenericProvider({ provider: "generic", url: options }, this, {
|
...runtimeOptions,
|
isUseMultipleRangeRequest: providerFactory_1.isUrlProbablySupportMultiRangeRequests(options),
|
});
|
}
|
else {
|
provider = providerFactory_1.createClient(options, this, runtimeOptions);
|
}
|
this.clientPromise = Promise.resolve(provider);
|
}
|
/**
|
* Asks the server whether there is an update.
|
*/
|
checkForUpdates() {
|
if (!this.isUpdaterActive()) {
|
return Promise.resolve(null);
|
}
|
let checkForUpdatesPromise = this.checkForUpdatesPromise;
|
if (checkForUpdatesPromise != null) {
|
this._logger.info("Checking for update (already in progress)");
|
return checkForUpdatesPromise;
|
}
|
const nullizePromise = () => (this.checkForUpdatesPromise = null);
|
this._logger.info("Checking for update");
|
checkForUpdatesPromise = this.doCheckForUpdates()
|
.then(it => {
|
nullizePromise();
|
return it;
|
})
|
.catch(e => {
|
nullizePromise();
|
this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`);
|
throw e;
|
});
|
this.checkForUpdatesPromise = checkForUpdatesPromise;
|
return checkForUpdatesPromise;
|
}
|
isUpdaterActive() {
|
const isEnabled = this.app.isPackaged || this.forceDevUpdateConfig;
|
if (!isEnabled) {
|
this._logger.info("Skip checkForUpdates because application is not packed and dev update config is not forced");
|
return false;
|
}
|
return true;
|
}
|
// noinspection JSUnusedGlobalSymbols
|
checkForUpdatesAndNotify(downloadNotification) {
|
return this.checkForUpdates().then(it => {
|
if (!(it === null || it === void 0 ? void 0 : it.downloadPromise)) {
|
if (this._logger.debug != null) {
|
this._logger.debug("checkForUpdatesAndNotify called, downloadPromise is null");
|
}
|
return it;
|
}
|
void it.downloadPromise.then(() => {
|
const notificationContent = AppUpdater.formatDownloadNotification(it.updateInfo.version, this.app.name, downloadNotification);
|
new (require("electron").Notification)(notificationContent).show();
|
});
|
return it;
|
});
|
}
|
static formatDownloadNotification(version, appName, downloadNotification) {
|
if (downloadNotification == null) {
|
downloadNotification = {
|
title: "A new update is ready to install",
|
body: `{appName} version {version} has been downloaded and will be automatically installed on exit`,
|
};
|
}
|
downloadNotification = {
|
title: downloadNotification.title.replace("{appName}", appName).replace("{version}", version),
|
body: downloadNotification.body.replace("{appName}", appName).replace("{version}", version),
|
};
|
return downloadNotification;
|
}
|
async isStagingMatch(updateInfo) {
|
const rawStagingPercentage = updateInfo.stagingPercentage;
|
let stagingPercentage = rawStagingPercentage;
|
if (stagingPercentage == null) {
|
return true;
|
}
|
stagingPercentage = parseInt(stagingPercentage, 10);
|
if (isNaN(stagingPercentage)) {
|
this._logger.warn(`Staging percentage is NaN: ${rawStagingPercentage}`);
|
return true;
|
}
|
// convert from user 0-100 to internal 0-1
|
stagingPercentage = stagingPercentage / 100;
|
const stagingUserId = await this.stagingUserIdPromise.value;
|
const val = builder_util_runtime_1.UUID.parse(stagingUserId).readUInt32BE(12);
|
const percentage = val / 0xffffffff;
|
this._logger.info(`Staging percentage: ${stagingPercentage}, percentage: ${percentage}, user id: ${stagingUserId}`);
|
return percentage < stagingPercentage;
|
}
|
computeFinalHeaders(headers) {
|
if (this.requestHeaders != null) {
|
Object.assign(headers, this.requestHeaders);
|
}
|
return headers;
|
}
|
async isUpdateAvailable(updateInfo) {
|
const latestVersion = semver_1.parse(updateInfo.version);
|
if (latestVersion == null) {
|
throw builder_util_runtime_1.newError(`This file could not be downloaded, or the latest version (from update server) does not have a valid semver version: "${updateInfo.version}"`, "ERR_UPDATER_INVALID_VERSION");
|
}
|
const currentVersion = this.currentVersion;
|
if (semver_1.eq(latestVersion, currentVersion)) {
|
return false;
|
}
|
const isStagingMatch = await this.isStagingMatch(updateInfo);
|
if (!isStagingMatch) {
|
return false;
|
}
|
// https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405033227
|
// https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405030797
|
const isLatestVersionNewer = semver_1.gt(latestVersion, currentVersion);
|
const isLatestVersionOlder = semver_1.lt(latestVersion, currentVersion);
|
if (isLatestVersionNewer) {
|
return true;
|
}
|
return this.allowDowngrade && isLatestVersionOlder;
|
}
|
async getUpdateInfoAndProvider() {
|
await this.app.whenReady();
|
if (this.clientPromise == null) {
|
this.clientPromise = this.configOnDisk.value.then(it => providerFactory_1.createClient(it, this, this.createProviderRuntimeOptions()));
|
}
|
const client = await this.clientPromise;
|
const stagingUserId = await this.stagingUserIdPromise.value;
|
client.setRequestHeaders(this.computeFinalHeaders({ "x-user-staging-id": stagingUserId }));
|
return {
|
info: await client.getLatestVersion(),
|
provider: client,
|
};
|
}
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
createProviderRuntimeOptions() {
|
return {
|
isUseMultipleRangeRequest: true,
|
platform: this._testOnlyOptions == null ? process.platform : this._testOnlyOptions.platform,
|
executor: this.httpExecutor,
|
};
|
}
|
async doCheckForUpdates() {
|
this.emit("checking-for-update");
|
const result = await this.getUpdateInfoAndProvider();
|
const updateInfo = result.info;
|
if (!(await this.isUpdateAvailable(updateInfo))) {
|
this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).`);
|
this.emit("update-not-available", updateInfo);
|
return {
|
versionInfo: updateInfo,
|
updateInfo,
|
};
|
}
|
this.updateInfoAndProvider = result;
|
this.onUpdateAvailable(updateInfo);
|
const cancellationToken = new builder_util_runtime_1.CancellationToken();
|
//noinspection ES6MissingAwait
|
return {
|
versionInfo: updateInfo,
|
updateInfo,
|
cancellationToken,
|
downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null,
|
};
|
}
|
onUpdateAvailable(updateInfo) {
|
this._logger.info(`Found version ${updateInfo.version} (url: ${builder_util_runtime_1.asArray(updateInfo.files)
|
.map(it => it.url)
|
.join(", ")})`);
|
this.emit("update-available", updateInfo);
|
}
|
/**
|
* Start downloading update manually. You can use this method if `autoDownload` option is set to `false`.
|
* @returns {Promise<Array<string>>} Paths to downloaded files.
|
*/
|
downloadUpdate(cancellationToken = new builder_util_runtime_1.CancellationToken()) {
|
const updateInfoAndProvider = this.updateInfoAndProvider;
|
if (updateInfoAndProvider == null) {
|
const error = new Error("Please check update first");
|
this.dispatchError(error);
|
return Promise.reject(error);
|
}
|
this._logger.info(`Downloading update from ${builder_util_runtime_1.asArray(updateInfoAndProvider.info.files)
|
.map(it => it.url)
|
.join(", ")}`);
|
const errorHandler = (e) => {
|
// https://github.com/electron-userland/electron-builder/issues/1150#issuecomment-436891159
|
if (!(e instanceof builder_util_runtime_1.CancellationError)) {
|
try {
|
this.dispatchError(e);
|
}
|
catch (nestedError) {
|
this._logger.warn(`Cannot dispatch error event: ${nestedError.stack || nestedError}`);
|
}
|
}
|
return e;
|
};
|
try {
|
return this.doDownloadUpdate({
|
updateInfoAndProvider,
|
requestHeaders: this.computeRequestHeaders(updateInfoAndProvider.provider),
|
cancellationToken,
|
disableWebInstaller: this.disableWebInstaller,
|
}).catch(e => {
|
throw errorHandler(e);
|
});
|
}
|
catch (e) {
|
return Promise.reject(errorHandler(e));
|
}
|
}
|
dispatchError(e) {
|
this.emit("error", e, (e.stack || e).toString());
|
}
|
dispatchUpdateDownloaded(event) {
|
this.emit(main_1.UPDATE_DOWNLOADED, event);
|
}
|
async loadUpdateConfig() {
|
if (this._appUpdateConfigPath == null) {
|
this._appUpdateConfigPath = this.app.appUpdateConfigPath;
|
}
|
return js_yaml_1.load(await fs_extra_1.readFile(this._appUpdateConfigPath, "utf-8"));
|
}
|
computeRequestHeaders(provider) {
|
const fileExtraDownloadHeaders = provider.fileExtraDownloadHeaders;
|
if (fileExtraDownloadHeaders != null) {
|
const requestHeaders = this.requestHeaders;
|
return requestHeaders == null
|
? fileExtraDownloadHeaders
|
: {
|
...fileExtraDownloadHeaders,
|
...requestHeaders,
|
};
|
}
|
return this.computeFinalHeaders({ accept: "*/*" });
|
}
|
async getOrCreateStagingUserId() {
|
const file = path.join(this.app.userDataPath, ".updaterId");
|
try {
|
const id = await fs_extra_1.readFile(file, "utf-8");
|
if (builder_util_runtime_1.UUID.check(id)) {
|
return id;
|
}
|
else {
|
this._logger.warn(`Staging user id file exists, but content was invalid: ${id}`);
|
}
|
}
|
catch (e) {
|
if (e.code !== "ENOENT") {
|
this._logger.warn(`Couldn't read staging user ID, creating a blank one: ${e}`);
|
}
|
}
|
const id = builder_util_runtime_1.UUID.v5(crypto_1.randomBytes(4096), builder_util_runtime_1.UUID.OID);
|
this._logger.info(`Generated new staging user ID: ${id}`);
|
try {
|
await fs_extra_1.outputFile(file, id);
|
}
|
catch (e) {
|
this._logger.warn(`Couldn't write out staging user ID: ${e}`);
|
}
|
return id;
|
}
|
/** @internal */
|
get isAddNoCacheQuery() {
|
const headers = this.requestHeaders;
|
// https://github.com/electron-userland/electron-builder/issues/3021
|
if (headers == null) {
|
return true;
|
}
|
for (const headerName of Object.keys(headers)) {
|
const s = headerName.toLowerCase();
|
if (s === "authorization" || s === "private-token") {
|
return false;
|
}
|
}
|
return true;
|
}
|
async getOrCreateDownloadHelper() {
|
let result = this.downloadedUpdateHelper;
|
if (result == null) {
|
const dirName = (await this.configOnDisk.value).updaterCacheDirName;
|
const logger = this._logger;
|
if (dirName == null) {
|
logger.error("updaterCacheDirName is not specified in app-update.yml Was app build using at least electron-builder 20.34.0?");
|
}
|
const cacheDir = path.join(this.app.baseCachePath, dirName || this.app.name);
|
if (logger.debug != null) {
|
logger.debug(`updater cache dir: ${cacheDir}`);
|
}
|
result = new DownloadedUpdateHelper_1.DownloadedUpdateHelper(cacheDir);
|
this.downloadedUpdateHelper = result;
|
}
|
return result;
|
}
|
async executeDownload(taskOptions) {
|
const fileInfo = taskOptions.fileInfo;
|
const downloadOptions = {
|
headers: taskOptions.downloadUpdateOptions.requestHeaders,
|
cancellationToken: taskOptions.downloadUpdateOptions.cancellationToken,
|
sha2: fileInfo.info.sha2,
|
sha512: fileInfo.info.sha512,
|
};
|
if (this.listenerCount(main_1.DOWNLOAD_PROGRESS) > 0) {
|
downloadOptions.onProgress = it => this.emit(main_1.DOWNLOAD_PROGRESS, it);
|
}
|
const updateInfo = taskOptions.downloadUpdateOptions.updateInfoAndProvider.info;
|
const version = updateInfo.version;
|
const packageInfo = fileInfo.packageInfo;
|
function getCacheUpdateFileName() {
|
// NodeJS URL doesn't decode automatically
|
const urlPath = decodeURIComponent(taskOptions.fileInfo.url.pathname);
|
if (urlPath.endsWith(`.${taskOptions.fileExtension}`)) {
|
return path.basename(urlPath);
|
}
|
else {
|
// url like /latest, generate name
|
return `update.${taskOptions.fileExtension}`;
|
}
|
}
|
const downloadedUpdateHelper = await this.getOrCreateDownloadHelper();
|
const cacheDir = downloadedUpdateHelper.cacheDirForPendingUpdate;
|
await fs_extra_1.mkdir(cacheDir, { recursive: true });
|
const updateFileName = getCacheUpdateFileName();
|
let updateFile = path.join(cacheDir, updateFileName);
|
const packageFile = packageInfo == null ? null : path.join(cacheDir, `package-${version}${path.extname(packageInfo.path) || ".7z"}`);
|
const done = async (isSaveCache) => {
|
await downloadedUpdateHelper.setDownloadedFile(updateFile, packageFile, updateInfo, fileInfo, updateFileName, isSaveCache);
|
await taskOptions.done({
|
...updateInfo,
|
downloadedFile: updateFile,
|
});
|
return packageFile == null ? [updateFile] : [updateFile, packageFile];
|
};
|
const log = this._logger;
|
const cachedUpdateFile = await downloadedUpdateHelper.validateDownloadedPath(updateFile, updateInfo, fileInfo, log);
|
if (cachedUpdateFile != null) {
|
updateFile = cachedUpdateFile;
|
return await done(false);
|
}
|
const removeFileIfAny = async () => {
|
await downloadedUpdateHelper.clear().catch(() => {
|
// ignore
|
});
|
return await fs_extra_1.unlink(updateFile).catch(() => {
|
// ignore
|
});
|
};
|
const tempUpdateFile = await DownloadedUpdateHelper_1.createTempUpdateFile(`temp-${updateFileName}`, cacheDir, log);
|
try {
|
await taskOptions.task(tempUpdateFile, downloadOptions, packageFile, removeFileIfAny);
|
await fs_extra_1.rename(tempUpdateFile, updateFile);
|
}
|
catch (e) {
|
await removeFileIfAny();
|
if (e instanceof builder_util_runtime_1.CancellationError) {
|
log.info("cancelled");
|
this.emit("update-cancelled", updateInfo);
|
}
|
throw e;
|
}
|
log.info(`New version ${version} has been downloaded to ${updateFile}`);
|
return await done(true);
|
}
|
}
|
exports.AppUpdater = AppUpdater;
|
function hasPrereleaseComponents(version) {
|
const versionPrereleaseComponent = semver_1.prerelease(version);
|
return versionPrereleaseComponent != null && versionPrereleaseComponent.length > 0;
|
}
|
/** @private */
|
class NoOpLogger {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
info(message) {
|
// ignore
|
}
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
warn(message) {
|
// ignore
|
}
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
error(message) {
|
// ignore
|
}
|
}
|
exports.NoOpLogger = NoOpLogger;
|
//# sourceMappingURL=AppUpdater.js.map
|