UNPKG

23.7 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3exports.NoOpLogger = exports.AppUpdater = void 0;
4const builder_util_runtime_1 = require("builder-util-runtime");
5const crypto_1 = require("crypto");
6const events_1 = require("events");
7const fs_extra_1 = require("fs-extra");
8const promises_1 = require("fs/promises");
9const js_yaml_1 = require("js-yaml");
10const lazy_val_1 = require("lazy-val");
11const path = require("path");
12const semver_1 = require("semver");
13const DownloadedUpdateHelper_1 = require("./DownloadedUpdateHelper");
14const ElectronAppAdapter_1 = require("./ElectronAppAdapter");
15const electronHttpExecutor_1 = require("./electronHttpExecutor");
16const GenericProvider_1 = require("./providers/GenericProvider");
17const main_1 = require("./main");
18const providerFactory_1 = require("./providerFactory");
19class AppUpdater extends events_1.EventEmitter {
20 constructor(options, app) {
21 super();
22 /**
23 * Whether to automatically download an update when it is found.
24 */
25 this.autoDownload = true;
26 /**
27 * Whether to automatically install a downloaded update on app quit (if `quitAndInstall` was not called before).
28 */
29 this.autoInstallOnAppQuit = true;
30 /**
31 * *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`.
32 *
33 * If `true`, downgrade will be allowed (`allowDowngrade` will be set to `true`).
34 */
35 this.allowPrerelease = false;
36 /**
37 * *GitHub provider only.* Get all release notes (from current version to latest), not just the latest.
38 * @default false
39 */
40 this.fullChangelog = false;
41 /**
42 * Whether to allow version downgrade (when a user from the beta channel wants to go back to the stable channel).
43 *
44 * Taken in account only if channel differs (pre-release version component in terms of semantic versioning).
45 *
46 * @default false
47 */
48 this.allowDowngrade = false;
49 this._channel = null;
50 this.downloadedUpdateHelper = null;
51 /**
52 * The request headers.
53 */
54 this.requestHeaders = null;
55 this._logger = console;
56 // noinspection JSUnusedGlobalSymbols
57 /**
58 * For type safety you can use signals, e.g. `autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})`
59 */
60 this.signals = new main_1.UpdaterSignal(this);
61 this._appUpdateConfigPath = null;
62 this.clientPromise = null;
63 this.stagingUserIdPromise = new lazy_val_1.Lazy(() => this.getOrCreateStagingUserId());
64 // public, allow to read old config for anyone
65 /** @internal */
66 this.configOnDisk = new lazy_val_1.Lazy(() => this.loadUpdateConfig());
67 this.checkForUpdatesPromise = null;
68 this.updateInfoAndProvider = null;
69 /**
70 * @private
71 * @internal
72 */
73 this._testOnlyOptions = null;
74 this.on("error", (error) => {
75 this._logger.error(`Error: ${error.stack || error.message}`);
76 });
77 if (app == null) {
78 this.app = new ElectronAppAdapter_1.ElectronAppAdapter();
79 this.httpExecutor = new electronHttpExecutor_1.ElectronHttpExecutor((authInfo, callback) => this.emit("login", authInfo, callback));
80 }
81 else {
82 this.app = app;
83 this.httpExecutor = null;
84 }
85 const currentVersionString = this.app.version;
86 const currentVersion = semver_1.parse(currentVersionString);
87 if (currentVersion == null) {
88 throw builder_util_runtime_1.newError(`App version is not a valid semver version: "${currentVersionString}"`, "ERR_UPDATER_INVALID_VERSION");
89 }
90 this.currentVersion = currentVersion;
91 this.allowPrerelease = hasPrereleaseComponents(currentVersion);
92 if (options != null) {
93 this.setFeedURL(options);
94 if (typeof options !== "string" && options.requestHeaders) {
95 this.requestHeaders = options.requestHeaders;
96 }
97 }
98 }
99 /**
100 * Get the update channel. Not applicable for GitHub. Doesn't return `channel` from the update configuration, only if was previously set.
101 */
102 get channel() {
103 return this._channel;
104 }
105 /**
106 * Set the update channel. Not applicable for GitHub. Overrides `channel` in the update configuration.
107 *
108 * `allowDowngrade` will be automatically set to `true`. If this behavior is not suitable for you, simple set `allowDowngrade` explicitly after.
109 */
110 set channel(value) {
111 if (this._channel != null) {
112 // noinspection SuspiciousTypeOfGuard
113 if (typeof value !== "string") {
114 throw builder_util_runtime_1.newError(`Channel must be a string, but got: ${value}`, "ERR_UPDATER_INVALID_CHANNEL");
115 }
116 else if (value.length === 0) {
117 throw builder_util_runtime_1.newError(`Channel must be not an empty string`, "ERR_UPDATER_INVALID_CHANNEL");
118 }
119 }
120 this._channel = value;
121 this.allowDowngrade = true;
122 }
123 /**
124 * Shortcut for explicitly adding auth tokens to request headers
125 */
126 addAuthHeader(token) {
127 this.requestHeaders = Object.assign({}, this.requestHeaders, {
128 authorization: token,
129 });
130 }
131 // noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols
132 get netSession() {
133 return electronHttpExecutor_1.getNetSession();
134 }
135 /**
136 * 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() }`.
137 * Set it to `null` if you would like to disable a logging feature.
138 */
139 get logger() {
140 return this._logger;
141 }
142 set logger(value) {
143 this._logger = value == null ? new NoOpLogger() : value;
144 }
145 // noinspection JSUnusedGlobalSymbols
146 /**
147 * test only
148 * @private
149 */
150 set updateConfigPath(value) {
151 this.clientPromise = null;
152 this._appUpdateConfigPath = value;
153 this.configOnDisk = new lazy_val_1.Lazy(() => this.loadUpdateConfig());
154 }
155 //noinspection JSMethodCanBeStatic,JSUnusedGlobalSymbols
156 getFeedURL() {
157 return "Deprecated. Do not use it.";
158 }
159 /**
160 * Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`.
161 * @param options If you want to override configuration in the `app-update.yml`.
162 */
163 setFeedURL(options) {
164 const runtimeOptions = this.createProviderRuntimeOptions();
165 // https://github.com/electron-userland/electron-builder/issues/1105
166 let provider;
167 if (typeof options === "string") {
168 provider = new GenericProvider_1.GenericProvider({ provider: "generic", url: options }, this, {
169 ...runtimeOptions,
170 isUseMultipleRangeRequest: providerFactory_1.isUrlProbablySupportMultiRangeRequests(options),
171 });
172 }
173 else {
174 provider = providerFactory_1.createClient(options, this, runtimeOptions);
175 }
176 this.clientPromise = Promise.resolve(provider);
177 }
178 /**
179 * Asks the server whether there is an update.
180 */
181 checkForUpdates() {
182 let checkForUpdatesPromise = this.checkForUpdatesPromise;
183 if (checkForUpdatesPromise != null) {
184 this._logger.info("Checking for update (already in progress)");
185 return checkForUpdatesPromise;
186 }
187 const nullizePromise = () => (this.checkForUpdatesPromise = null);
188 this._logger.info("Checking for update");
189 checkForUpdatesPromise = this.doCheckForUpdates()
190 .then(it => {
191 nullizePromise();
192 return it;
193 })
194 .catch(e => {
195 nullizePromise();
196 this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`);
197 throw e;
198 });
199 this.checkForUpdatesPromise = checkForUpdatesPromise;
200 return checkForUpdatesPromise;
201 }
202 isUpdaterActive() {
203 if (!this.app.isPackaged) {
204 this._logger.info("Skip checkForUpdatesAndNotify because application is not packed");
205 return false;
206 }
207 return true;
208 }
209 // noinspection JSUnusedGlobalSymbols
210 checkForUpdatesAndNotify(downloadNotification) {
211 if (!this.isUpdaterActive()) {
212 return Promise.resolve(null);
213 }
214 return this.checkForUpdates().then(it => {
215 const downloadPromise = it.downloadPromise;
216 if (downloadPromise == null) {
217 if (this._logger.debug != null) {
218 this._logger.debug("checkForUpdatesAndNotify called, downloadPromise is null");
219 }
220 return it;
221 }
222 void downloadPromise.then(() => {
223 const notificationContent = AppUpdater.formatDownloadNotification(it.updateInfo.version, this.app.name, downloadNotification);
224 new (require("electron").Notification)(notificationContent).show();
225 });
226 return it;
227 });
228 }
229 static formatDownloadNotification(version, appName, downloadNotification) {
230 if (downloadNotification == null) {
231 downloadNotification = {
232 title: "A new update is ready to install",
233 body: `{appName} version {version} has been downloaded and will be automatically installed on exit`,
234 };
235 }
236 downloadNotification = {
237 title: downloadNotification.title.replace("{appName}", appName).replace("{version}", version),
238 body: downloadNotification.body.replace("{appName}", appName).replace("{version}", version),
239 };
240 return downloadNotification;
241 }
242 async isStagingMatch(updateInfo) {
243 const rawStagingPercentage = updateInfo.stagingPercentage;
244 let stagingPercentage = rawStagingPercentage;
245 if (stagingPercentage == null) {
246 return true;
247 }
248 stagingPercentage = parseInt(stagingPercentage, 10);
249 if (isNaN(stagingPercentage)) {
250 this._logger.warn(`Staging percentage is NaN: ${rawStagingPercentage}`);
251 return true;
252 }
253 // convert from user 0-100 to internal 0-1
254 stagingPercentage = stagingPercentage / 100;
255 const stagingUserId = await this.stagingUserIdPromise.value;
256 const val = builder_util_runtime_1.UUID.parse(stagingUserId).readUInt32BE(12);
257 const percentage = val / 0xffffffff;
258 this._logger.info(`Staging percentage: ${stagingPercentage}, percentage: ${percentage}, user id: ${stagingUserId}`);
259 return percentage < stagingPercentage;
260 }
261 computeFinalHeaders(headers) {
262 if (this.requestHeaders != null) {
263 Object.assign(headers, this.requestHeaders);
264 }
265 return headers;
266 }
267 async isUpdateAvailable(updateInfo) {
268 const latestVersion = semver_1.parse(updateInfo.version);
269 if (latestVersion == null) {
270 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");
271 }
272 const currentVersion = this.currentVersion;
273 if (semver_1.eq(latestVersion, currentVersion)) {
274 return false;
275 }
276 const isStagingMatch = await this.isStagingMatch(updateInfo);
277 if (!isStagingMatch) {
278 return false;
279 }
280 // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405033227
281 // https://github.com/electron-userland/electron-builder/pull/3111#issuecomment-405030797
282 const isLatestVersionNewer = semver_1.gt(latestVersion, currentVersion);
283 const isLatestVersionOlder = semver_1.lt(latestVersion, currentVersion);
284 if (isLatestVersionNewer) {
285 return true;
286 }
287 return this.allowDowngrade && isLatestVersionOlder;
288 }
289 async getUpdateInfoAndProvider() {
290 await this.app.whenReady();
291 if (this.clientPromise == null) {
292 this.clientPromise = this.configOnDisk.value.then(it => providerFactory_1.createClient(it, this, this.createProviderRuntimeOptions()));
293 }
294 const client = await this.clientPromise;
295 const stagingUserId = await this.stagingUserIdPromise.value;
296 client.setRequestHeaders(this.computeFinalHeaders({ "x-user-staging-id": stagingUserId }));
297 return {
298 info: await client.getLatestVersion(),
299 provider: client,
300 };
301 }
302 // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
303 createProviderRuntimeOptions() {
304 return {
305 isUseMultipleRangeRequest: true,
306 platform: this._testOnlyOptions == null ? process.platform : this._testOnlyOptions.platform,
307 executor: this.httpExecutor,
308 };
309 }
310 async doCheckForUpdates() {
311 this.emit("checking-for-update");
312 const result = await this.getUpdateInfoAndProvider();
313 const updateInfo = result.info;
314 if (!(await this.isUpdateAvailable(updateInfo))) {
315 this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).`);
316 this.emit("update-not-available", updateInfo);
317 return {
318 versionInfo: updateInfo,
319 updateInfo,
320 };
321 }
322 this.updateInfoAndProvider = result;
323 this.onUpdateAvailable(updateInfo);
324 const cancellationToken = new builder_util_runtime_1.CancellationToken();
325 //noinspection ES6MissingAwait
326 return {
327 versionInfo: updateInfo,
328 updateInfo,
329 cancellationToken,
330 downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null,
331 };
332 }
333 onUpdateAvailable(updateInfo) {
334 this._logger.info(`Found version ${updateInfo.version} (url: ${builder_util_runtime_1.asArray(updateInfo.files)
335 .map(it => it.url)
336 .join(", ")})`);
337 this.emit("update-available", updateInfo);
338 }
339 /**
340 * Start downloading update manually. You can use this method if `autoDownload` option is set to `false`.
341 * @returns {Promise<string>} Path to downloaded file.
342 */
343 downloadUpdate(cancellationToken = new builder_util_runtime_1.CancellationToken()) {
344 const updateInfoAndProvider = this.updateInfoAndProvider;
345 if (updateInfoAndProvider == null) {
346 const error = new Error("Please check update first");
347 this.dispatchError(error);
348 return Promise.reject(error);
349 }
350 this._logger.info(`Downloading update from ${builder_util_runtime_1.asArray(updateInfoAndProvider.info.files)
351 .map(it => it.url)
352 .join(", ")}`);
353 const errorHandler = (e) => {
354 // https://github.com/electron-userland/electron-builder/issues/1150#issuecomment-436891159
355 if (!(e instanceof builder_util_runtime_1.CancellationError)) {
356 try {
357 this.dispatchError(e);
358 }
359 catch (nestedError) {
360 this._logger.warn(`Cannot dispatch error event: ${nestedError.stack || nestedError}`);
361 }
362 }
363 return e;
364 };
365 try {
366 return this.doDownloadUpdate({
367 updateInfoAndProvider,
368 requestHeaders: this.computeRequestHeaders(updateInfoAndProvider.provider),
369 cancellationToken,
370 }).catch(e => {
371 throw errorHandler(e);
372 });
373 }
374 catch (e) {
375 return Promise.reject(errorHandler(e));
376 }
377 }
378 dispatchError(e) {
379 this.emit("error", e, (e.stack || e).toString());
380 }
381 dispatchUpdateDownloaded(event) {
382 this.emit(main_1.UPDATE_DOWNLOADED, event);
383 }
384 async loadUpdateConfig() {
385 if (this._appUpdateConfigPath == null) {
386 this._appUpdateConfigPath = this.app.appUpdateConfigPath;
387 }
388 return js_yaml_1.load(await promises_1.readFile(this._appUpdateConfigPath, "utf-8"));
389 }
390 computeRequestHeaders(provider) {
391 const fileExtraDownloadHeaders = provider.fileExtraDownloadHeaders;
392 if (fileExtraDownloadHeaders != null) {
393 const requestHeaders = this.requestHeaders;
394 return requestHeaders == null
395 ? fileExtraDownloadHeaders
396 : {
397 ...fileExtraDownloadHeaders,
398 ...requestHeaders,
399 };
400 }
401 return this.computeFinalHeaders({ accept: "*/*" });
402 }
403 async getOrCreateStagingUserId() {
404 const file = path.join(this.app.userDataPath, ".updaterId");
405 try {
406 const id = await promises_1.readFile(file, "utf-8");
407 if (builder_util_runtime_1.UUID.check(id)) {
408 return id;
409 }
410 else {
411 this._logger.warn(`Staging user id file exists, but content was invalid: ${id}`);
412 }
413 }
414 catch (e) {
415 if (e.code !== "ENOENT") {
416 this._logger.warn(`Couldn't read staging user ID, creating a blank one: ${e}`);
417 }
418 }
419 const id = builder_util_runtime_1.UUID.v5(crypto_1.randomBytes(4096), builder_util_runtime_1.UUID.OID);
420 this._logger.info(`Generated new staging user ID: ${id}`);
421 try {
422 await fs_extra_1.outputFile(file, id);
423 }
424 catch (e) {
425 this._logger.warn(`Couldn't write out staging user ID: ${e}`);
426 }
427 return id;
428 }
429 /** @internal */
430 get isAddNoCacheQuery() {
431 const headers = this.requestHeaders;
432 // https://github.com/electron-userland/electron-builder/issues/3021
433 if (headers == null) {
434 return true;
435 }
436 for (const headerName of Object.keys(headers)) {
437 const s = headerName.toLowerCase();
438 if (s === "authorization" || s === "private-token") {
439 return false;
440 }
441 }
442 return true;
443 }
444 async getOrCreateDownloadHelper() {
445 let result = this.downloadedUpdateHelper;
446 if (result == null) {
447 const dirName = (await this.configOnDisk.value).updaterCacheDirName;
448 const logger = this._logger;
449 if (dirName == null) {
450 logger.error("updaterCacheDirName is not specified in app-update.yml Was app build using at least electron-builder 20.34.0?");
451 }
452 const cacheDir = path.join(this.app.baseCachePath, dirName || this.app.name);
453 if (logger.debug != null) {
454 logger.debug(`updater cache dir: ${cacheDir}`);
455 }
456 result = new DownloadedUpdateHelper_1.DownloadedUpdateHelper(cacheDir);
457 this.downloadedUpdateHelper = result;
458 }
459 return result;
460 }
461 async executeDownload(taskOptions) {
462 const fileInfo = taskOptions.fileInfo;
463 const downloadOptions = {
464 headers: taskOptions.downloadUpdateOptions.requestHeaders,
465 cancellationToken: taskOptions.downloadUpdateOptions.cancellationToken,
466 sha2: fileInfo.info.sha2,
467 sha512: fileInfo.info.sha512,
468 };
469 if (this.listenerCount(main_1.DOWNLOAD_PROGRESS) > 0) {
470 downloadOptions.onProgress = it => this.emit(main_1.DOWNLOAD_PROGRESS, it);
471 }
472 const updateInfo = taskOptions.downloadUpdateOptions.updateInfoAndProvider.info;
473 const version = updateInfo.version;
474 const packageInfo = fileInfo.packageInfo;
475 function getCacheUpdateFileName() {
476 // NodeJS URL doesn't decode automatically
477 const urlPath = decodeURIComponent(taskOptions.fileInfo.url.pathname);
478 if (urlPath.endsWith(`.${taskOptions.fileExtension}`)) {
479 return path.posix.basename(urlPath);
480 }
481 else {
482 // url like /latest, generate name
483 return `update.${taskOptions.fileExtension}`;
484 }
485 }
486 const downloadedUpdateHelper = await this.getOrCreateDownloadHelper();
487 const cacheDir = downloadedUpdateHelper.cacheDirForPendingUpdate;
488 await promises_1.mkdir(cacheDir, { recursive: true });
489 const updateFileName = getCacheUpdateFileName();
490 let updateFile = path.join(cacheDir, updateFileName);
491 const packageFile = packageInfo == null ? null : path.join(cacheDir, `package-${version}${path.extname(packageInfo.path) || ".7z"}`);
492 const done = async (isSaveCache) => {
493 await downloadedUpdateHelper.setDownloadedFile(updateFile, packageFile, updateInfo, fileInfo, updateFileName, isSaveCache);
494 await taskOptions.done({
495 ...updateInfo,
496 downloadedFile: updateFile,
497 });
498 return packageFile == null ? [updateFile] : [updateFile, packageFile];
499 };
500 const log = this._logger;
501 const cachedUpdateFile = await downloadedUpdateHelper.validateDownloadedPath(updateFile, updateInfo, fileInfo, log);
502 if (cachedUpdateFile != null) {
503 updateFile = cachedUpdateFile;
504 return await done(false);
505 }
506 const removeFileIfAny = async () => {
507 await downloadedUpdateHelper.clear().catch(() => {
508 // ignore
509 });
510 return await promises_1.unlink(updateFile).catch(() => {
511 // ignore
512 });
513 };
514 const tempUpdateFile = await DownloadedUpdateHelper_1.createTempUpdateFile(`temp-${updateFileName}`, cacheDir, log);
515 try {
516 await taskOptions.task(tempUpdateFile, downloadOptions, packageFile, removeFileIfAny);
517 await promises_1.rename(tempUpdateFile, updateFile);
518 }
519 catch (e) {
520 await removeFileIfAny();
521 if (e instanceof builder_util_runtime_1.CancellationError) {
522 log.info("cancelled");
523 this.emit("update-cancelled", updateInfo);
524 }
525 throw e;
526 }
527 log.info(`New version ${version} has been downloaded to ${updateFile}`);
528 return await done(true);
529 }
530}
531exports.AppUpdater = AppUpdater;
532function hasPrereleaseComponents(version) {
533 const versionPrereleaseComponent = semver_1.prerelease(version);
534 return versionPrereleaseComponent != null && versionPrereleaseComponent.length > 0;
535}
536/** @private */
537class NoOpLogger {
538 // eslint-disable-next-line @typescript-eslint/no-unused-vars
539 info(message) {
540 // ignore
541 }
542 // eslint-disable-next-line @typescript-eslint/no-unused-vars
543 warn(message) {
544 // ignore
545 }
546 // eslint-disable-next-line @typescript-eslint/no-unused-vars
547 error(message) {
548 // ignore
549 }
550}
551exports.NoOpLogger = NoOpLogger;
552//# sourceMappingURL=AppUpdater.js.map
\No newline at end of file