Update all non-major dependencies
[ProtonMail-WebClient.git] / applications / pass-desktop / src / update.ts
blob627cece9101a4158360dd7ae20e688924c0bad75
1 import assert from 'assert';
2 import { randomBytes } from 'crypto';
3 import { type MessageBoxOptions, type Session, app, autoUpdater, dialog } from 'electron';
4 import logger from 'electron-log/main';
5 import isURL from 'is-url';
6 import os from 'os';
8 import { type FeatureFlagsResponse, PassFeature } from '@proton/pass/types/api/features';
9 import noop from '@proton/utils/noop';
11 import * as config from './app/config';
12 import { ARCH } from './lib/env';
13 import { store } from './store';
14 import { isMac, isProdEnv, isWindows } from './utils/platform';
16 export type StoreUpdateProperties = {
17     distribution: number;
20 type RemoteManifestResponse = {
21     Releases: {
22         Version: string;
23         RolloutPercentage: number;
24         CategoryName: 'Stable' | 'EarlyAccess';
25     }[];
28 export enum SourceType {
29     StaticStorage = 1,
32 export type UpdateSource = {
33     type: SourceType.StaticStorage;
34     baseUrl: string;
37 export type UpdateOptions = {
38     /** Electron session */
39     readonly session: Session;
40     /** Update source configuration */
41     readonly updateSource: UpdateSource;
42     /** How frequently to check for updates, in seconds. Defaults to 60 minutes (`3600`). */
43     readonly updateInterval?: number;
44     /** Prompts to apply the update immediately after download. Defaults to `false`. */
45     readonly notifyUser?: boolean;
48 const calculateUpdateDistribution = () => randomBytes(4).readUint32LE() / Math.pow(2, 32);
50 const userAgent = `ProtonPass/${config.APP_VERSION} (${os.platform()}: ${os.arch()})`;
51 const supportedPlatforms = ['darwin', 'win32'];
53 const validateInput = (opts: UpdateOptions) => {
54     const defaults = {
55         updateInterval: 60 * 60,
56         notifyUser: false,
57     };
59     const { updateInterval, notifyUser, updateSource, session } = {
60         ...defaults,
61         ...opts,
62     };
64     // allows electron to be mocked in tests
65     const electron: typeof Electron.Main = (opts as any).electron || require('electron');
67     assert(
68         updateSource.baseUrl && isURL(updateSource.baseUrl) && updateSource.baseUrl.startsWith('https:'),
69         'baseUrl must be a valid HTTPS URL'
70     );
72     assert(updateInterval >= 5 * 60, 'updateInterval must be 5 minutes (`300`) or more');
74     return { updateSource, updateInterval, electron, notifyUser, session };
77 const checkForUpdates = async (opts: ReturnType<typeof validateInput>) => {
78     // don't attempt to update if rollout % not satisfied
79     const remoteManifestUrl = `https://proton.me/download/PassDesktop/${process.platform}/${ARCH}/version.json`;
80     const remoteManifest = await opts.session
81         .fetch(remoteManifestUrl)
82         .then((r) => r.json())
83         .then((r: RemoteManifestResponse) => r)
84         .catch(noop);
86     const latestRelease = (() => {
87         if (!Array.isArray(remoteManifest?.Releases)) return;
88         return remoteManifest.Releases.find((r) => r.CategoryName === 'Stable');
89     })();
91     if (!latestRelease) {
92         logger.log(`[Update] No stable release found, url=${remoteManifestUrl}`);
93         return;
94     }
96     const localDistributionPct = store.get('update')?.distribution || calculateUpdateDistribution();
97     const remoteDistributionPct = latestRelease.RolloutPercentage || 0;
98     if (remoteDistributionPct < localDistributionPct) {
99         logger.log(
100             `[Update] Rollout distribution short-circuit triggered, r=${remoteDistributionPct}, l=${localDistributionPct}, v=${latestRelease.Version}`
101         );
102         return;
103     }
105     // don't attempt to update if PassEnableDesktopAutoUpdate disabled
106     const featureFlagsUrl = `${config.API_URL}/feature/v2/frontend`;
107     const featureFlags = await opts.session
108         .fetch(featureFlagsUrl)
109         .then((r) => r.json())
110         .then((r: FeatureFlagsResponse) => r.toggles)
111         .catch(noop);
113     if (!featureFlags?.some((f) => f.name === PassFeature.PassEnableDesktopAutoUpdate)) {
114         logger.log('[Update] Feature flag short-circuit triggered');
115         return;
116     }
118     // don't attempt to update during development
119     if (!isProdEnv()) {
120         logger.log(`[Update] Unpacked app short-circuit triggered`);
121         return;
122     }
124     autoUpdater.checkForUpdates();
127 const initUpdater = (opts: ReturnType<typeof validateInput>) => {
128     const { updateSource, updateInterval } = opts;
130     // exit early on unsupported platforms, e.g. `linux`
131     if (!supportedPlatforms.includes(process?.platform)) {
132         logger.log(
133             `Electron's autoUpdater does not support the '${process.platform}' platform. Ref: https://www.electronjs.org/docs/latest/api/auto-updater#platform-notices`
134         );
135         return;
136     }
138     let feedURL = updateSource.baseUrl;
139     let serverType: 'default' | 'json' = 'default';
141     if (isMac) {
142         feedURL += '/RELEASES.json';
143         serverType = 'json';
144     }
146     autoUpdater.setFeedURL({
147         url: feedURL,
148         headers: { 'user-agent': userAgent },
149         serverType,
150     });
152     autoUpdater.on('error', (err) => {
153         logger.log('[Update] An error ocurred');
154         logger.log(err);
155     });
157     autoUpdater.on('checking-for-update', () => {
158         logger.log('[Update] Checking for updates...');
159     });
161     autoUpdater.on('update-available', () => {
162         logger.log('[Update] Update available; downloading...');
163     });
165     autoUpdater.on('update-downloaded', () => {
166         logger.log('[Update] Update downloaded.');
167         store.set('update', { distribution: calculateUpdateDistribution() });
168     });
170     autoUpdater.on('update-not-available', () => {
171         logger.log('[Update] No updates available.');
172     });
174     if (opts.notifyUser) {
175         autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, releaseDate, updateURL) => {
176             logger.log('update-downloaded', [event, releaseNotes, releaseName, releaseDate, updateURL]);
178             const dialogOpts: MessageBoxOptions = {
179                 type: 'info',
180                 buttons: ['Restart', 'Later'],
181                 title: 'Update Available',
182                 message: isWindows ? releaseNotes : releaseName,
183                 detail: 'A new version of Proton Pass has been downloaded. Restart the application to apply the updates.',
184             };
186             dialog
187                 .showMessageBox(dialogOpts)
188                 .then(({ response }) => {
189                     if (response === 0) autoUpdater.quitAndInstall();
190                 })
191                 .catch(noop);
192         });
193     }
195     // check for updates right away and keep checking later
196     checkForUpdates(opts).catch(noop);
197     setInterval(() => checkForUpdates(opts), updateInterval * 1_000);
200 export const updateElectronApp = (opts: UpdateOptions) => {
201     // check for bad input early, so it will be logged during development
202     const safeOpts = validateInput(opts);
204     if (safeOpts.electron.app.isReady()) initUpdater(safeOpts);
205     else app.on('ready', () => initUpdater(safeOpts));