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';
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 = {
20 type RemoteManifestResponse = {
23 RolloutPercentage: number;
24 CategoryName: 'Stable' | 'EarlyAccess';
28 export enum SourceType {
32 export type UpdateSource = {
33 type: SourceType.StaticStorage;
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) => {
55 updateInterval: 60 * 60,
59 const { updateInterval, notifyUser, updateSource, session } = {
64 // allows electron to be mocked in tests
65 const electron: typeof Electron.Main = (opts as any).electron || require('electron');
68 updateSource.baseUrl && isURL(updateSource.baseUrl) && updateSource.baseUrl.startsWith('https:'),
69 'baseUrl must be a valid HTTPS URL'
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)
86 const latestRelease = (() => {
87 if (!Array.isArray(remoteManifest?.Releases)) return;
88 return remoteManifest.Releases.find((r) => r.CategoryName === 'Stable');
92 logger.log(`[Update] No stable release found, url=${remoteManifestUrl}`);
96 const localDistributionPct = store.get('update')?.distribution || calculateUpdateDistribution();
97 const remoteDistributionPct = latestRelease.RolloutPercentage || 0;
98 if (remoteDistributionPct < localDistributionPct) {
100 `[Update] Rollout distribution short-circuit triggered, r=${remoteDistributionPct}, l=${localDistributionPct}, v=${latestRelease.Version}`
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)
113 if (!featureFlags?.some((f) => f.name === PassFeature.PassEnableDesktopAutoUpdate)) {
114 logger.log('[Update] Feature flag short-circuit triggered');
118 // don't attempt to update during development
120 logger.log(`[Update] Unpacked app short-circuit triggered`);
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)) {
133 `Electron's autoUpdater does not support the '${process.platform}' platform. Ref: https://www.electronjs.org/docs/latest/api/auto-updater#platform-notices`
138 let feedURL = updateSource.baseUrl;
139 let serverType: 'default' | 'json' = 'default';
142 feedURL += '/RELEASES.json';
146 autoUpdater.setFeedURL({
148 headers: { 'user-agent': userAgent },
152 autoUpdater.on('error', (err) => {
153 logger.log('[Update] An error ocurred');
157 autoUpdater.on('checking-for-update', () => {
158 logger.log('[Update] Checking for updates...');
161 autoUpdater.on('update-available', () => {
162 logger.log('[Update] Update available; downloading...');
165 autoUpdater.on('update-downloaded', () => {
166 logger.log('[Update] Update downloaded.');
167 store.set('update', { distribution: calculateUpdateDistribution() });
170 autoUpdater.on('update-not-available', () => {
171 logger.log('[Update] No updates available.');
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 = {
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.',
187 .showMessageBox(dialogOpts)
188 .then(({ response }) => {
189 if (response === 0) autoUpdater.quitAndInstall();
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));