Enable auto-updating using electron-updater

This commit is contained in:
Ryan Tharp 2020-02-23 18:30:27 -08:00 committed by Mikunj
parent 06142c880a
commit 178d788dca
16 changed files with 178 additions and 1388 deletions

View File

@ -23,10 +23,6 @@
"port": "38157"
}
],
"disableAutoUpdate": true,
"updatesUrl": "TODO",
"updatesPublicKey":
"fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401",
"updatesEnabled": false,
"openDevTools": false,
"buildExpiration": 0,

View File

@ -1 +1,3 @@
{}
{
"updatesEnabled": true
}

View File

@ -1,5 +1,3 @@
provider: s3
region: us-east-1
bucket: your-test-bucket.signal.org
path: desktop
acl: public-read
owner: <yourGHName>
repo: <yourGHRepoName>
provider: github

37
main.js
View File

@ -65,9 +65,7 @@ const appInstance = config.util.getEnv('NODE_APP_INSTANCE') || 0;
const attachments = require('./app/attachments');
const attachmentChannel = require('./app/attachment_channel');
// TODO: Enable when needed
// const updater = require('./ts/updater/index');
const updater = null;
const updater = require('./ts/updater/index');
const createTrayIcon = require('./app/tray_icon');
const ephemeralConfig = require('./app/ephemeral_config');
@ -410,22 +408,40 @@ ipc.on('show-window', () => {
showWindow();
});
let updatesStarted = false;
ipc.on('ready-for-updates', async () => {
if (updatesStarted || !updater) {
let isReadyForUpdates = false;
async function readyForUpdates() {
if (isReadyForUpdates) {
return;
}
updatesStarted = true;
isReadyForUpdates = true;
// disable for now
/*
// First, install requested sticker pack
const incomingUrl = getIncomingUrl(process.argv);
if (incomingUrl) {
handleSgnlLink(incomingUrl);
}
*/
// Second, start checking for app updates
try {
await updater.start(getMainWindow, locale.messages, logger);
} catch (error) {
logger.error(
const log = logger || console;
log.error(
'Error starting update checks:',
error && error.stack ? error.stack : error
);
}
});
}
ipc.once('ready-for-updates', readyForUpdates);
// Forcefully call readyForUpdates after 10 minutes.
// This ensures we start the updater.
const TEN_MINUTES = 10 * 60 * 1000;
setTimeout(readyForUpdates, TEN_MINUTES);
function openReleaseNotes() {
shell.openExternal(
@ -842,6 +858,9 @@ async function showMainWindow(sqlKey, passwordAttempt = false) {
}
setupMenu();
// Check updates
readyForUpdates();
}
function setupMenu(options) {

View File

@ -2,13 +2,16 @@
"name": "session-messenger-desktop",
"productName": "Session",
"description": "Private messaging from your desktop",
"repository": "https://github.com/loki-project/loki-messenger.git",
"version": "1.0.3",
"license": "GPL-3.0",
"author": {
"name": "Loki Project",
"email": "team@loki.network"
},
"repository": {
"type": "git",
"url": "https://github.com/loki-project/session-desktop.git"
},
"main": "main.js",
"scripts": {
"postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider",
@ -25,7 +28,6 @@
"build": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "cross-env SIGNAL_ENV=production npm run build -- --config.directories.output=release",
"make:linux:x64:appimage": "electron-builder build --linux appimage --x64",
"sign-release": "node ts/updater/generateSignature.js",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"clean-module-protobuf": "rm -f ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
"build-protobuf": "yarn build-module-protobuf",
@ -78,8 +80,9 @@
"dompurify": "^2.0.7",
"electron-context-menu": "^0.15.0",
"electron-editor-context-menu": "1.1.1",
"electron-is-dev": "0.3.0",
"electron-is-dev": "^1.1.0",
"electron-localshortcut": "^3.2.1",
"electron-updater": "^4.2.2",
"emoji-datasource": "4.0.0",
"emoji-datasource-apple": "4.0.0",
"emoji-js": "3.4.0",
@ -139,6 +142,7 @@
"@types/classnames": "2.2.3",
"@types/color": "^3.0.0",
"@types/config": "0.0.34",
"@types/electron-is-dev": "^1.1.1",
"@types/filesize": "3.6.0",
"@types/fs-extra": "5.0.5",
"@types/google-libphonenumber": "7.4.14",
@ -208,7 +212,8 @@
"build": {
"appId": "com.loki-project.messenger-desktop",
"afterSign": "build/notarize.js",
"artifactName": "${name}-${os}-${version}.${ext}",
"artifactName": "${name}-${os}-${arch}-${version}.${ext}",
"publish": "github",
"mac": {
"category": "public.app-category.social-networking",
"icon": "build/icons/mac/icon.icns",
@ -318,7 +323,8 @@
"!node_modules/@journeyapps/sqlcipher/deps/*",
"!node_modules/@journeyapps/sqlcipher/build/*",
"!node_modules/@journeyapps/sqlcipher/lib/binding/node-*",
"!build/*.js"
"!build/*.js",
"!dev-app-update.yml"
]
}
}

View File

@ -1,77 +0,0 @@
import { assert } from 'chai';
import { getUpdateFileName, getVersion } from '../../updater/common';
describe('updater/signatures', () => {
const windows = `version: 1.23.2
files:
- url: signal-desktop-win-1.23.2.exe
sha512: hhK+cVAb+QOK/Ln0RBcq8Rb1iPcUC0KZeT4NwLB25PMGoPmakY27XE1bXq4QlkASJN1EkYTbKf3oUJtcllziyQ==
size: 92020776
path: signal-desktop-win-1.23.2.exe
sha512: hhK+cVAb+QOK/Ln0RBcq8Rb1iPcUC0KZeT4NwLB25PMGoPmakY27XE1bXq4QlkASJN1EkYTbKf3oUJtcllziyQ==
releaseDate: '2019-03-29T16:58:08.210Z'
`;
const mac = `version: 1.23.2
files:
- url: signal-desktop-mac-1.23.2.zip
sha512: f4pPo3WulTVi9zBWGsJPNIlvPOTCxPibPPDmRFDoXMmFm6lqJpXZQ9DSWMJumfc4BRp4y/NTQLGYI6b4WuJwhg==
size: 105179791
blockMapSize: 111109
path: signal-desktop-mac-1.23.2.zip
sha512: f4pPo3WulTVi9zBWGsJPNIlvPOTCxPibPPDmRFDoXMmFm6lqJpXZQ9DSWMJumfc4BRp4y/NTQLGYI6b4WuJwhg==
releaseDate: '2019-03-29T16:57:16.997Z'
`;
const windowsBeta = `version: 1.23.2-beta.1
files:
- url: signal-desktop-beta-win-1.23.2-beta.1.exe
sha512: ZHM1F3y/Y6ulP5NhbFuh7t2ZCpY4lD9BeBhPV+g2B/0p/66kp0MJDeVxTgjR49OakwpMAafA1d6y2QBail4hSQ==
size: 92028656
path: signal-desktop-beta-win-1.23.2-beta.1.exe
sha512: ZHM1F3y/Y6ulP5NhbFuh7t2ZCpY4lD9BeBhPV+g2B/0p/66kp0MJDeVxTgjR49OakwpMAafA1d6y2QBail4hSQ==
releaseDate: '2019-03-29T01:56:00.544Z'
`;
const macBeta = `version: 1.23.2-beta.1
files:
- url: signal-desktop-beta-mac-1.23.2-beta.1.zip
sha512: h/01N0DD5Jw2Q6M1n4uLGLTCrMFxcn8QOPtLR3HpABsf3w9b2jFtKb56/2cbuJXP8ol8TkTDWKnRV6mnqnLBDw==
size: 105182398
blockMapSize: 110894
path: signal-desktop-beta-mac-1.23.2-beta.1.zip
sha512: h/01N0DD5Jw2Q6M1n4uLGLTCrMFxcn8QOPtLR3HpABsf3w9b2jFtKb56/2cbuJXP8ol8TkTDWKnRV6mnqnLBDw==
releaseDate: '2019-03-29T01:53:23.881Z'
`;
describe('#getVersion', () => {
it('successfully gets version', () => {
const expected = '1.23.2';
assert.strictEqual(getVersion(windows), expected);
assert.strictEqual(getVersion(mac), expected);
const expectedBeta = '1.23.2-beta.1';
assert.strictEqual(getVersion(windowsBeta), expectedBeta);
assert.strictEqual(getVersion(macBeta), expectedBeta);
});
});
describe('#getUpdateFileName', () => {
it('successfully gets version', () => {
assert.strictEqual(
getUpdateFileName(windows),
'signal-desktop-win-1.23.2.exe'
);
assert.strictEqual(
getUpdateFileName(mac),
'signal-desktop-mac-1.23.2.zip'
);
assert.strictEqual(
getUpdateFileName(windowsBeta),
'signal-desktop-beta-win-1.23.2-beta.1.exe'
);
assert.strictEqual(
getUpdateFileName(macBeta),
'signal-desktop-beta-mac-1.23.2-beta.1.zip'
);
});
});
});

View File

@ -1,206 +0,0 @@
import { existsSync } from 'fs';
import { join } from 'path';
import { assert } from 'chai';
import { copy } from 'fs-extra';
import {
_getFileHash,
getSignaturePath,
loadHexFromPath,
verifySignature,
writeHexToPath,
writeSignature,
} from '../../updater/signature';
import { createTempDir, deleteTempDir } from '../../updater/common';
import { keyPair } from '../../updater/curve';
describe('updater/signatures', () => {
it('_getFileHash returns correct hash', async () => {
const filePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const expected =
'7bc77f27d92d00b4a1d57c480ca86dacc43d57bc318339c92119d1fbf6b557a5';
const hash = await _getFileHash(filePath);
assert.strictEqual(expected, Buffer.from(hash).toString('hex'));
});
it('roundtrips binary file writes', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const path = join(tempDir, 'something.bin');
const { publicKey } = keyPair();
await writeHexToPath(path, publicKey);
const fromDisk = await loadHexFromPath(path);
assert.strictEqual(
Buffer.from(fromDisk).compare(Buffer.from(publicKey)),
0
);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
it('roundtrips signature', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const version = 'v1.23.2';
const sourcePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const updatePath = join(tempDir, 'ghost-kitty.mp4');
await copy(sourcePath, updatePath);
const privateKeyPath = join(tempDir, 'private.key');
const { publicKey, privateKey } = keyPair();
await writeHexToPath(privateKeyPath, privateKey);
await writeSignature(updatePath, version, privateKeyPath);
const signaturePath = getSignaturePath(updatePath);
assert.strictEqual(existsSync(signaturePath), true);
const verified = await verifySignature(updatePath, version, publicKey);
assert.strictEqual(verified, true);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
it('fails signature verification if version changes', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const version = 'v1.23.2';
const brokenVersion = 'v1.23.3';
const sourcePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const updatePath = join(tempDir, 'ghost-kitty.mp4');
await copy(sourcePath, updatePath);
const privateKeyPath = join(tempDir, 'private.key');
const { publicKey, privateKey } = keyPair();
await writeHexToPath(privateKeyPath, privateKey);
await writeSignature(updatePath, version, privateKeyPath);
const verified = await verifySignature(
updatePath,
brokenVersion,
publicKey
);
assert.strictEqual(verified, false);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
it('fails signature verification if signature tampered with', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const version = 'v1.23.2';
const sourcePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const updatePath = join(tempDir, 'ghost-kitty.mp4');
await copy(sourcePath, updatePath);
const privateKeyPath = join(tempDir, 'private.key');
const { publicKey, privateKey } = keyPair();
await writeHexToPath(privateKeyPath, privateKey);
await writeSignature(updatePath, version, privateKeyPath);
const signaturePath = getSignaturePath(updatePath);
const signature = Buffer.from(await loadHexFromPath(signaturePath));
signature[4] += 3;
await writeHexToPath(signaturePath, signature);
const verified = await verifySignature(updatePath, version, publicKey);
assert.strictEqual(verified, false);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
it('fails signature verification if binary file tampered with', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const version = 'v1.23.2';
const sourcePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const updatePath = join(tempDir, 'ghost-kitty.mp4');
await copy(sourcePath, updatePath);
const privateKeyPath = join(tempDir, 'private.key');
const { publicKey, privateKey } = keyPair();
await writeHexToPath(privateKeyPath, privateKey);
await writeSignature(updatePath, version, privateKeyPath);
const brokenSourcePath = join(
__dirname,
'../../../fixtures/pixabay-Soap-Bubble-7141.mp4'
);
await copy(brokenSourcePath, updatePath);
const verified = await verifySignature(updatePath, version, publicKey);
assert.strictEqual(verified, false);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
it('fails signature verification if signed by different key', async () => {
let tempDir;
try {
tempDir = await createTempDir();
const version = 'v1.23.2';
const sourcePath = join(__dirname, '../../../fixtures/ghost-kitty.mp4');
const updatePath = join(tempDir, 'ghost-kitty.mp4');
await copy(sourcePath, updatePath);
const privateKeyPath = join(tempDir, 'private.key');
const { publicKey } = keyPair();
const { privateKey } = keyPair();
await writeHexToPath(privateKeyPath, privateKey);
await writeSignature(updatePath, version, privateKeyPath);
const verified = await verifySignature(updatePath, version, publicKey);
assert.strictEqual(verified, false);
} finally {
if (tempDir) {
await deleteTempDir(tempDir);
}
}
});
});

View File

@ -1,28 +1,4 @@
import {
createWriteStream,
statSync,
writeFile as writeFileCallback,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
// @ts-ignore
import { createParser } from 'dashdash';
// @ts-ignore
import ProxyAgent from 'proxy-agent';
import { FAILSAFE_SCHEMA, safeLoad } from 'js-yaml';
import { gt } from 'semver';
import { get as getFromConfig } from 'config';
import { get, GotOptions, stream } from 'got';
import { v4 as getGuid } from 'uuid';
import pify from 'pify';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
import { app, BrowserWindow, dialog } from 'electron';
// @ts-ignore
import * as packageJson from '../../package.json';
import { getSignatureFileName } from './signature';
import { BrowserWindow, dialog } from 'electron';
export type MessagesType = {
[key: string]: {
@ -42,90 +18,6 @@ export type LoggerType = {
trace: LogFunction;
};
const writeFile = pify(writeFileCallback);
const mkdirpPromise = pify(mkdirp);
const rimrafPromise = pify(rimraf);
const { platform } = process;
export async function checkForUpdates(
logger: LoggerType
): Promise<{
fileName: string;
version: string;
} | null> {
const yaml = await getUpdateYaml();
const version = getVersion(yaml);
if (!version) {
logger.warn('checkForUpdates: no version extracted from downloaded yaml');
return null;
}
if (isVersionNewer(version)) {
logger.info(`checkForUpdates: found newer version ${version}`);
return {
fileName: getUpdateFileName(yaml),
version,
};
}
logger.info(
`checkForUpdates: ${version} is not newer; no new update available`
);
return null;
}
export async function downloadUpdate(
fileName: string,
logger: LoggerType
): Promise<string> {
const baseUrl = getUpdatesBase();
const updateFileUrl = `${baseUrl}/${fileName}`;
const signatureFileName = getSignatureFileName(fileName);
const signatureUrl = `${baseUrl}/${signatureFileName}`;
let tempDir;
try {
tempDir = await createTempDir();
const targetUpdatePath = join(tempDir, fileName);
const targetSignaturePath = join(tempDir, getSignatureFileName(fileName));
logger.info(`downloadUpdate: Downloading ${signatureUrl}`);
const { body } = await get(signatureUrl, getGotOptions());
await writeFile(targetSignaturePath, body);
logger.info(`downloadUpdate: Downloading ${updateFileUrl}`);
const downloadStream = stream(updateFileUrl, getGotOptions());
const writeStream = createWriteStream(targetUpdatePath);
await new Promise((resolve, reject) => {
downloadStream.on('error', error => {
reject(error);
});
downloadStream.on('end', () => {
resolve();
});
writeStream.on('error', error => {
reject(error);
});
downloadStream.pipe(writeStream);
});
return targetUpdatePath;
} catch (error) {
if (tempDir) {
await deleteTempDir(tempDir);
}
throw error;
}
}
export async function showUpdateDialog(
mainWindow: BrowserWindow,
messages: MessagesType
@ -179,145 +71,6 @@ export async function showCannotUpdateDialog(
});
}
// Helper functions
export function getUpdateCheckUrl(): string {
return `${getUpdatesBase()}/${getUpdatesFileName()}`;
}
export function getUpdatesBase(): string {
return getFromConfig('updatesUrl');
}
export function getCertificateAuthority(): string {
return getFromConfig('certificateAuthority');
}
export function getProxyUrl(): string | undefined {
return process.env.HTTPS_PROXY || process.env.https_proxy;
}
export function getUpdatesFileName(): string {
const prefix = isBetaChannel() ? 'beta' : 'latest';
if (platform === 'darwin') {
return `${prefix}-mac.yml`;
} else {
return `${prefix}.yml`;
}
}
const hasBeta = /beta/i;
function isBetaChannel(): boolean {
return hasBeta.test(packageJson.version);
}
function isVersionNewer(newVersion: string): boolean {
const { version } = packageJson;
return gt(newVersion, version);
}
export function getVersion(yaml: string): string | undefined {
const info = parseYaml(yaml);
if (info && info.version) {
return info.version;
}
return;
}
export function getUpdateFileName(yaml: string) {
const info = parseYaml(yaml);
if (info && info.path) {
return info.path;
}
return;
}
function parseYaml(yaml: string): any {
return safeLoad(yaml, { schema: FAILSAFE_SCHEMA, json: true });
}
async function getUpdateYaml(): Promise<string> {
const targetUrl = getUpdateCheckUrl();
const { body } = await get(targetUrl, getGotOptions());
if (!body) {
throw new Error('Got unexpected response back from update check');
}
return body.toString('utf8');
}
function getGotOptions(): GotOptions<null> {
const ca = getCertificateAuthority();
const proxyUrl = getProxyUrl();
const agent = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
return {
agent,
ca,
headers: {
'Cache-Control': 'no-cache',
'User-Agent': 'Session Desktop (+https://getsession.org)',
},
useElectronNet: false,
};
}
function getBaseTempDir() {
// We only use tmpdir() when this code is run outside of an Electron app (as in: tests)
return app ? join(app.getPath('userData'), 'temp') : tmpdir();
}
export async function createTempDir() {
const baseTempDir = getBaseTempDir();
const uniqueName = getGuid();
const targetDir = join(baseTempDir, uniqueName);
await mkdirpPromise(targetDir);
return targetDir;
}
export async function deleteTempDir(targetDir: string) {
const pathInfo = statSync(targetDir);
if (!pathInfo.isDirectory()) {
throw new Error(
`deleteTempDir: Cannot delete path '${targetDir}' because it is not a directory`
);
}
const baseTempDir = getBaseTempDir();
if (!targetDir.startsWith(baseTempDir)) {
throw new Error(
`deleteTempDir: Cannot delete path '${targetDir}' since it is not within base temp dir`
);
}
await rimrafPromise(targetDir);
}
export function getPrintableError(error: Error) {
return error && error.stack ? error.stack : error;
}
export async function deleteBaseTempDir() {
const baseTempDir = getBaseTempDir();
await rimrafPromise(baseTempDir);
}
export function getCliOptions<T>(options: any): T {
const parser = createParser({ options });
const cliOptions = parser.parse(process.argv);
if (cliOptions.help) {
const help = parser.help().trimRight();
// tslint:disable-next-line:no-console
console.log(help);
process.exit(0);
}
return cliOptions;
}

View File

@ -1,45 +0,0 @@
import { getCliOptions, getPrintableError } from './common';
import { keyPair } from './curve';
import { writeHexToPath } from './signature';
/* tslint:disable:no-console */
const OPTIONS = [
{
names: ['help', 'h'],
type: 'bool',
help: 'Print this help and exit.',
},
{
names: ['key', 'k'],
type: 'string',
help: 'Path where public key will go',
default: 'public.key',
},
{
names: ['private', 'p'],
type: 'string',
help: 'Path where private key will go',
default: 'private.key',
},
];
type OptionsType = {
key: string;
private: string;
};
const cliOptions = getCliOptions<OptionsType>(OPTIONS);
go(cliOptions).catch(error => {
console.error('Something went wrong!', getPrintableError(error));
});
async function go(options: OptionsType) {
const { key: publicKeyPath, private: privateKeyPath } = options;
const { publicKey, privateKey } = keyPair();
await Promise.all([
writeHexToPath(publicKeyPath, publicKey),
writeHexToPath(privateKeyPath, privateKey),
]);
}

View File

@ -1,85 +0,0 @@
import { join, resolve } from 'path';
import { readdir as readdirCallback } from 'fs';
import pify from 'pify';
import { getCliOptions, getPrintableError } from './common';
import { writeSignature } from './signature';
// @ts-ignore
import * as packageJson from '../../package.json';
const readdir = pify(readdirCallback);
/* tslint:disable:no-console */
const OPTIONS = [
{
names: ['help', 'h'],
type: 'bool',
help: 'Print this help and exit.',
},
{
names: ['private', 'p'],
type: 'string',
help: 'Path to private key file (default: ./private.key)',
default: 'private.key',
},
{
names: ['update', 'u'],
type: 'string',
help: 'Path to the update package (default: the .exe or .zip in ./release)',
},
{
names: ['version', 'v'],
type: 'string',
help: `Version number of this package (default: ${packageJson.version})`,
default: packageJson.version,
},
];
type OptionsType = {
private: string;
update: string;
version: string;
};
const cliOptions = getCliOptions<OptionsType>(OPTIONS);
go(cliOptions).catch(error => {
console.error('Something went wrong!', getPrintableError(error));
});
async function go(options: OptionsType) {
const { private: privateKeyPath, version } = options;
let { update: updatePath } = options;
if (!updatePath) {
updatePath = await findUpdatePath();
}
console.log('Signing with...');
console.log(` version: ${version}`);
console.log(` update file: ${updatePath}`);
console.log(` private key file: ${privateKeyPath}`);
await writeSignature(updatePath, version, privateKeyPath);
}
const IS_EXE = /\.exe$/;
const IS_ZIP = /\.zip$/;
async function findUpdatePath(): Promise<string> {
const releaseDir = resolve('release');
const files: Array<string> = await readdir(releaseDir);
const max = files.length;
for (let i = 0; i < max; i += 1) {
const file = files[i];
const fullPath = join(releaseDir, file);
if (IS_EXE.test(file) || IS_ZIP.test(file)) {
return fullPath;
}
}
throw new Error("No suitable file found in 'release' folder!");
}

View File

@ -1,14 +1,7 @@
import { get as getFromConfig } from 'config';
import { BrowserWindow } from 'electron';
import { start as startMacOS } from './macos';
import { start as startWindows } from './windows';
import {
deleteBaseTempDir,
getPrintableError,
LoggerType,
MessagesType,
} from './common';
import { start as startUpdater } from './updater';
import { LoggerType, MessagesType } from './common';
let initialized = false;
@ -17,8 +10,6 @@ export async function start(
messages?: MessagesType,
logger?: LoggerType
) {
const { platform } = process;
if (initialized) {
throw new Error('updater/start: Updates have already been initialized!');
}
@ -32,6 +23,13 @@ export async function start(
}
if (autoUpdateDisabled()) {
/*
If you really want to enable auto-updating in dev mode
You need to create a dev-app-update.yml file.
A sample can be found in dev-app-update.yml.sample.
After that you can change `updatesEnabled` to `true` in the default config.
*/
logger.info(
'updater/start: Updates disabled - not starting new version checks'
);
@ -39,28 +37,11 @@ export async function start(
return;
}
try {
await deleteBaseTempDir();
} catch (error) {
logger.error(
'updater/start: Error deleting temp dir:',
getPrintableError(error)
);
}
if (platform === 'win32') {
await startWindows(getMainWindow, messages, logger);
} else if (platform === 'darwin') {
await startMacOS(getMainWindow, messages, logger);
} else {
throw new Error('updater/start: Unsupported platform');
}
await startUpdater(getMainWindow, messages, logger);
}
function autoUpdateDisabled() {
return (
process.platform === 'linux' ||
process.mas ||
!getFromConfig('updatesEnabled')
process.mas || !getFromConfig('updatesEnabled') // From Electron: Mac App Store build
);
}

View File

@ -1,324 +0,0 @@
import { createReadStream, statSync } from 'fs';
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { AddressInfo } from 'net';
import { dirname } from 'path';
import { v4 as getGuid } from 'uuid';
import { app, autoUpdater, BrowserWindow, dialog } from 'electron';
import { get as getFromConfig } from 'config';
import { gt } from 'semver';
import {
checkForUpdates,
deleteTempDir,
downloadUpdate,
getPrintableError,
LoggerType,
MessagesType,
showCannotUpdateDialog,
showUpdateDialog,
} from './common';
import { hexToBinary, verifySignature } from './signature';
import { markShouldQuit } from '../../app/window_state';
let isChecking = false;
const SECOND = 1000;
const MINUTE = SECOND * 60;
const INTERVAL = MINUTE * 30;
export async function start(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
logger.info('macos/start: starting checks...');
loggerForQuitHandler = logger;
app.once('quit', quitHandler);
setInterval(async () => {
try {
await checkDownloadAndInstall(getMainWindow, messages, logger);
} catch (error) {
logger.error('macos/start: error:', getPrintableError(error));
}
}, INTERVAL);
await checkDownloadAndInstall(getMainWindow, messages, logger);
}
let fileName: string;
let version: string;
let updateFilePath: string;
let loggerForQuitHandler: LoggerType;
async function checkDownloadAndInstall(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
if (isChecking) {
return;
}
logger.info('checkDownloadAndInstall: checking for update...');
try {
isChecking = true;
const result = await checkForUpdates(logger);
if (!result) {
return;
}
const { fileName: newFileName, version: newVersion } = result;
if (fileName !== newFileName || !version || gt(newVersion, version)) {
deleteCache(updateFilePath, logger);
fileName = newFileName;
version = newVersion;
updateFilePath = await downloadUpdate(fileName, logger);
}
const publicKey = hexToBinary(getFromConfig('updatesPublicKey'));
const verified = verifySignature(updateFilePath, version, publicKey);
if (!verified) {
// Note: We don't delete the cache here, because we don't want to continually
// re-download the broken release. We will download it only once per launch.
throw new Error(
`checkDownloadAndInstall: Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')`
);
}
try {
await handToAutoUpdate(updateFilePath, logger);
} catch (error) {
const readOnly = 'Cannot update while running on a read-only volume';
const message: string = error.message || '';
if (message.includes(readOnly)) {
logger.info('checkDownloadAndInstall: showing read-only dialog...');
await showReadOnlyDialog(getMainWindow(), messages);
} else {
logger.info(
'checkDownloadAndInstall: showing general update failure dialog...'
);
await showCannotUpdateDialog(getMainWindow(), messages);
}
throw error;
}
// At this point, closing the app will cause the update to be installed automatically
// because Squirrel has cached the update file and will do the right thing.
logger.info('checkDownloadAndInstall: showing update dialog...');
const shouldUpdate = await showUpdateDialog(getMainWindow(), messages);
if (!shouldUpdate) {
return;
}
logger.info('checkDownloadAndInstall: calling quitAndInstall...');
markShouldQuit();
autoUpdater.quitAndInstall();
} catch (error) {
logger.error('checkDownloadAndInstall: error', getPrintableError(error));
} finally {
isChecking = false;
}
}
function quitHandler() {
deleteCache(updateFilePath, loggerForQuitHandler);
}
// Helpers
function deleteCache(filePath: string | null, logger: LoggerType) {
if (filePath) {
const tempDir = dirname(filePath);
deleteTempDir(tempDir).catch(error => {
logger.error(
'quitHandler: error deleting temporary directory:',
getPrintableError(error)
);
});
}
}
async function handToAutoUpdate(
filePath: string,
logger: LoggerType
): Promise<void> {
return new Promise((resolve, reject) => {
const updateFileUrl = generateFileUrl();
const server = createServer();
let serverUrl: string;
server.on('error', (error: Error) => {
logger.error(
'handToAutoUpdate: server had error',
getPrintableError(error)
);
shutdown(server, logger);
reject(error);
});
server.on(
'request',
(request: IncomingMessage, response: ServerResponse) => {
const { url } = request;
if (url === '/') {
const absoluteUrl = `${serverUrl}${updateFileUrl}`;
writeJSONResponse(absoluteUrl, response);
return;
}
if (!url || !url.startsWith(updateFileUrl)) {
write404(url, response, logger);
return;
}
pipeUpdateToSquirrel(filePath, server, response, logger, reject);
}
);
server.listen(0, '127.0.0.1', () => {
serverUrl = getServerUrl(server);
autoUpdater.on('error', (error: Error) => {
logger.error('autoUpdater: error', getPrintableError(error));
reject(error);
});
autoUpdater.on('update-downloaded', () => {
logger.info('autoUpdater: update-downloaded event fired');
shutdown(server, logger);
resolve();
});
autoUpdater.setFeedURL({
url: serverUrl,
headers: { 'Cache-Control': 'no-cache' },
});
autoUpdater.checkForUpdates();
});
});
}
function pipeUpdateToSquirrel(
filePath: string,
server: Server,
response: ServerResponse,
logger: LoggerType,
reject: (error: Error) => void
) {
const updateFileSize = getFileSize(filePath);
const readStream = createReadStream(filePath);
response.on('error', (error: Error) => {
logger.error(
'pipeUpdateToSquirrel: update file download request had an error',
getPrintableError(error)
);
shutdown(server, logger);
reject(error);
});
readStream.on('error', (error: Error) => {
logger.error(
'pipeUpdateToSquirrel: read stream error response:',
getPrintableError(error)
);
shutdown(server, logger, response);
reject(error);
});
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Length': updateFileSize,
});
readStream.pipe(response);
}
function writeJSONResponse(url: string, response: ServerResponse) {
const data = Buffer.from(
JSON.stringify({
url,
})
);
response.writeHead(200, {
'Content-Type': 'application/json',
'Content-Length': data.byteLength,
});
response.end(data);
}
function write404(
url: string | undefined,
response: ServerResponse,
logger: LoggerType
) {
logger.error(`write404: Squirrel requested unexpected url '${url}'`);
response.writeHead(404);
response.end();
}
function getServerUrl(server: Server) {
const address = server.address() as AddressInfo;
// tslint:disable-next-line:no-http-string
return `http://127.0.0.1:${address.port}`;
}
function generateFileUrl(): string {
return `/${getGuid()}.zip`;
}
function getFileSize(targetPath: string): number {
const { size } = statSync(targetPath);
return size;
}
function shutdown(
server: Server,
logger: LoggerType,
response?: ServerResponse
) {
try {
if (server) {
server.close();
}
} catch (error) {
logger.error('shutdown: Error closing server', getPrintableError(error));
}
try {
if (response) {
response.end();
}
} catch (endError) {
logger.error(
"shutdown: couldn't end response",
getPrintableError(endError)
);
}
}
export async function showReadOnlyDialog(
mainWindow: BrowserWindow,
messages: MessagesType
): Promise<void> {
const options = {
type: 'warning',
buttons: [messages.ok.message],
title: messages.cannotUpdate.message,
message: messages.readOnlyVolume.message,
};
return new Promise(resolve => {
dialog.showMessageBox(mainWindow, options, () => {
resolve();
});
});
}

View File

@ -1,112 +0,0 @@
import { createHash } from 'crypto';
import {
createReadStream,
readFile as readFileCallback,
writeFile as writeFileCallback,
} from 'fs';
import { basename, dirname, join, resolve as resolvePath } from 'path';
import pify from 'pify';
import { BinaryType, sign, verify } from './curve';
const readFile = pify(readFileCallback);
const writeFile = pify(writeFileCallback);
export async function generateSignature(
updatePackagePath: string,
version: string,
privateKeyPath: string
) {
const privateKey = await loadHexFromPath(privateKeyPath);
const message = await generateMessage(updatePackagePath, version);
return sign(privateKey, message);
}
export async function verifySignature(
updatePackagePath: string,
version: string,
publicKey: BinaryType
): Promise<boolean> {
const signaturePath = getSignaturePath(updatePackagePath);
const signature = await loadHexFromPath(signaturePath);
const message = await generateMessage(updatePackagePath, version);
return verify(publicKey, message, signature);
}
// Helper methods
async function generateMessage(
updatePackagePath: string,
version: string
): Promise<BinaryType> {
const hash = await _getFileHash(updatePackagePath);
const messageString = `${Buffer.from(hash).toString('hex')}-${version}`;
return Buffer.from(messageString);
}
export async function writeSignature(
updatePackagePath: string,
version: string,
privateKeyPath: string
) {
const signaturePath = getSignaturePath(updatePackagePath);
const signature = await generateSignature(
updatePackagePath,
version,
privateKeyPath
);
await writeHexToPath(signaturePath, signature);
}
export async function _getFileHash(
updatePackagePath: string
): Promise<BinaryType> {
const hash = createHash('sha256');
const stream = createReadStream(updatePackagePath);
return new Promise((resolve, reject) => {
stream.on('data', data => {
hash.update(data);
});
stream.on('close', () => {
resolve(hash.digest());
});
stream.on('error', error => {
reject(error);
});
});
}
export function getSignatureFileName(fileName: string) {
return `${fileName}.sig`;
}
export function getSignaturePath(updatePackagePath: string): string {
const updateFullPath = resolvePath(updatePackagePath);
const updateDir = dirname(updateFullPath);
const updateFileName = basename(updateFullPath);
return join(updateDir, getSignatureFileName(updateFileName));
}
export function hexToBinary(target: string): BinaryType {
return Buffer.from(target, 'hex');
}
export function binaryToHex(data: BinaryType): string {
return Buffer.from(data).toString('hex');
}
export async function loadHexFromPath(target: string): Promise<BinaryType> {
const hexString = await readFile(target, 'utf8');
return hexToBinary(hexString);
}
export async function writeHexToPath(target: string, data: BinaryType) {
await writeFile(target, binaryToHex(data));
}

77
ts/updater/updater.ts Normal file
View File

@ -0,0 +1,77 @@
import { autoUpdater } from 'electron-updater';
import { BrowserWindow } from 'electron';
import { markShouldQuit } from '../../app/window_state';
import {
getPrintableError,
LoggerType,
MessagesType,
showCannotUpdateDialog,
showUpdateDialog,
} from './common';
let isUpdating = false;
const SECOND = 1000;
const MINUTE = SECOND * 60;
const INTERVAL = MINUTE * 30;
export async function start(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
logger.info('auto-update: starting checks...');
autoUpdater.logger = logger;
setInterval(async () => {
try {
await checkForUpdates(getMainWindow, messages, logger);
} catch (error) {
logger.error('auto-update: error:', getPrintableError(error));
}
}, INTERVAL);
await checkForUpdates(getMainWindow, messages, logger);
}
async function checkForUpdates(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
if (isUpdating) {
return;
}
logger.info('auto-update: checking for update...');
try {
// Get the update using electron-updater
try {
const info = await autoUpdater.checkForUpdates();
if (!info.downloadPromise) {
logger.info('auto-update: no update to download');
return;
}
await info.downloadPromise;
} catch (error) {
await showCannotUpdateDialog(getMainWindow(), messages);
throw error;
}
// Update downloaded successfully, we should ask the user to update
logger.info('auto-update: showing update dialog...');
const shouldUpdate = await showUpdateDialog(getMainWindow(), messages);
if (!shouldUpdate) {
return;
}
logger.info('auto-update: calling quitAndInstall...');
markShouldQuit();
autoUpdater.quitAndInstall();
} finally {
isUpdating = false;
}
}

View File

@ -1,231 +0,0 @@
import { dirname, join } from 'path';
import { spawn as spawnEmitter, SpawnOptions } from 'child_process';
import { readdir as readdirCallback, unlink as unlinkCallback } from 'fs';
import { app, BrowserWindow } from 'electron';
import { get as getFromConfig } from 'config';
import { gt } from 'semver';
import pify from 'pify';
import {
checkForUpdates,
deleteTempDir,
downloadUpdate,
getPrintableError,
LoggerType,
MessagesType,
showCannotUpdateDialog,
showUpdateDialog,
} from './common';
import { hexToBinary, verifySignature } from './signature';
import { markShouldQuit } from '../../app/window_state';
const readdir = pify(readdirCallback);
const unlink = pify(unlinkCallback);
let isChecking = false;
const SECOND = 1000;
const MINUTE = SECOND * 60;
const INTERVAL = MINUTE * 30;
export async function start(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
logger.info('windows/start: starting checks...');
loggerForQuitHandler = logger;
app.once('quit', quitHandler);
setInterval(async () => {
try {
await checkDownloadAndInstall(getMainWindow, messages, logger);
} catch (error) {
logger.error('windows/start: error:', getPrintableError(error));
}
}, INTERVAL);
await deletePreviousInstallers(logger);
await checkDownloadAndInstall(getMainWindow, messages, logger);
}
let fileName: string;
let version: string;
let updateFilePath: string;
let installing: boolean;
let loggerForQuitHandler: LoggerType;
async function checkDownloadAndInstall(
getMainWindow: () => BrowserWindow,
messages: MessagesType,
logger: LoggerType
) {
if (isChecking) {
return;
}
try {
isChecking = true;
logger.info('checkDownloadAndInstall: checking for update...');
const result = await checkForUpdates(logger);
if (!result) {
return;
}
const { fileName: newFileName, version: newVersion } = result;
if (fileName !== newFileName || !version || gt(newVersion, version)) {
deleteCache(updateFilePath, logger);
fileName = newFileName;
version = newVersion;
updateFilePath = await downloadUpdate(fileName, logger);
}
const publicKey = hexToBinary(getFromConfig('updatesPublicKey'));
const verified = verifySignature(updateFilePath, version, publicKey);
if (!verified) {
// Note: We don't delete the cache here, because we don't want to continually
// re-download the broken release. We will download it only once per launch.
throw new Error(
`Downloaded update did not pass signature verification (version: '${version}'; fileName: '${fileName}')`
);
}
logger.info('checkDownloadAndInstall: showing dialog...');
const shouldUpdate = await showUpdateDialog(getMainWindow(), messages);
if (!shouldUpdate) {
return;
}
try {
await verifyAndInstall(updateFilePath, version, logger);
installing = true;
} catch (error) {
logger.info(
'checkDownloadAndInstall: showing general update failure dialog...'
);
await showCannotUpdateDialog(getMainWindow(), messages);
throw error;
}
markShouldQuit();
app.quit();
} catch (error) {
logger.error('checkDownloadAndInstall: error', getPrintableError(error));
} finally {
isChecking = false;
}
}
function quitHandler() {
if (updateFilePath && !installing) {
verifyAndInstall(updateFilePath, version, loggerForQuitHandler).catch(
error => {
loggerForQuitHandler.error(
'quitHandler: error installing:',
getPrintableError(error)
);
}
);
}
}
// Helpers
// This is fixed by out new install mechanisms...
// https://github.com/signalapp/Signal-Desktop/issues/2369
// ...but we should also clean up those old installers.
const IS_EXE = /\.exe$/i;
async function deletePreviousInstallers(logger: LoggerType) {
const userDataPath = app.getPath('userData');
const files: Array<string> = await readdir(userDataPath);
await Promise.all(
files.map(async file => {
const isExe = IS_EXE.test(file);
if (!isExe) {
return;
}
const fullPath = join(userDataPath, file);
try {
await unlink(fullPath);
} catch (error) {
logger.error(`deletePreviousInstallers: couldn't delete file ${file}`);
}
})
);
}
async function verifyAndInstall(
filePath: string,
newVersion: string,
logger: LoggerType
) {
const publicKey = hexToBinary(getFromConfig('updatesPublicKey'));
const verified = verifySignature(updateFilePath, newVersion, publicKey);
if (!verified) {
throw new Error(
`Downloaded update did not pass signature verification (version: '${newVersion}'; fileName: '${fileName}')`
);
}
await install(filePath, logger);
}
async function install(filePath: string, logger: LoggerType): Promise<void> {
logger.info('windows/install: installing package...');
const args = ['--updated'];
const options = {
detached: true,
stdio: 'ignore' as 'ignore', // TypeScript considers this a plain string without help
};
try {
await spawn(filePath, args, options);
} catch (error) {
if (error.code === 'UNKNOWN' || error.code === 'EACCES') {
logger.warn(
'windows/install: Error running installer; Trying again with elevate.exe'
);
await spawn(getElevatePath(), [filePath, ...args], options);
return;
}
throw error;
}
}
function deleteCache(filePath: string | null, logger: LoggerType) {
if (filePath) {
const tempDir = dirname(filePath);
deleteTempDir(tempDir).catch(error => {
logger.error(
'deleteCache: error deleting temporary directory',
getPrintableError(error)
);
});
}
}
function getElevatePath() {
const installPath = app.getAppPath();
return join(installPath, 'resources', 'elevate.exe');
}
async function spawn(
exe: string,
args: Array<string>,
options: SpawnOptions
): Promise<void> {
return new Promise((resolve, reject) => {
const emitter = spawnEmitter(exe, args, options);
emitter.on('error', reject);
emitter.unref();
// tslint:disable-next-line no-string-based-set-timeout
setTimeout(resolve, 200);
});
}

View File

@ -191,6 +191,13 @@
dependencies:
"@types/trusted-types" "*"
"@types/electron-is-dev@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/electron-is-dev/-/electron-is-dev-1.1.1.tgz#b48cb249b4615915b16477891160414b57b9a8c5"
integrity sha512-axJ7z6N/FfXHf0Q6MO75Sl7gXCqAeIJMxxYd8n80FNmGev8GPHMcva31zQQX+i4B7aBUzdyVY1UfQeFxph3xVQ==
dependencies:
electron-is-dev "*"
"@types/events@*":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
@ -392,6 +399,13 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
"@types/semver@^7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.1.0.tgz#c8c630d4c18cd326beff77404887596f96408408"
integrity sha512-pOKLaubrAEMUItGNpgwl0HMFPrSAFic8oSVIvfu1UwcgGNmNyK9gyhBHKmBnUTwwVvpZfkzUC0GaMgnL6P86uA==
dependencies:
"@types/node" "*"
"@types/sinon@4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.3.1.tgz#32458f9b166cd44c23844eee4937814276f35199"
@ -3039,12 +3053,7 @@ electron-is-accelerator@^0.1.0:
resolved "https://registry.yarnpkg.com/electron-is-accelerator/-/electron-is-accelerator-0.1.2.tgz#509e510c26a56b55e17f863a4b04e111846ab27b"
integrity sha1-UJ5RDCala1Xhf4Y6SwThEYRqsns=
electron-is-dev@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-0.3.0.tgz#14e6fda5c68e9e4ecbeff9ccf037cbd7c05c5afe"
integrity sha1-FOb9pcaOnk7L7/nM8DfL18BcWv4=
electron-is-dev@^1.0.1:
electron-is-dev@*, electron-is-dev@^1.0.1, electron-is-dev@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.1.0.tgz#b15a2a600bdc48a51a857d460e05f15b19a2522c"
integrity sha512-Z1qA/1oHNowGtSBIcWk0pcLEqYT/j+13xUw/MYOrBUOL4X7VN0i0KCTf5SqyvMPmW5pSPKbo28wkxMxzZ20YnQ==
@ -3099,6 +3108,20 @@ electron-to-chromium@^1.2.7:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.334.tgz#0588359f4ac5c4185ebacdf5fc7e1937e2c99872"
integrity sha512-RcjJhpsVaX0X6ntu/WSBlW9HE9pnCgXS9B8mTUObl1aDxaiOa0Lu+NMveIS5IDC+VELzhM32rFJDCC+AApVwcA==
electron-updater@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-4.2.2.tgz#57e106bffad16f71b1ffa3968a52a1b71c8147e6"
integrity sha512-e/OZhr5tLW0GcgmpR5wD0ImxgKMa8pPoNWRcwRyMzTL9pGej7+ORp0t9DtI5ZBHUbObIoEbrk+6EDGUGtJf+aA==
dependencies:
"@types/semver" "^7.1.0"
builder-util-runtime "8.6.0"
fs-extra "^8.1.0"
js-yaml "^3.13.1"
lazy-val "^1.0.4"
lodash.isequal "^4.5.0"
pako "^1.0.11"
semver "^7.1.3"
electron@4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-4.1.2.tgz#dc8be0f219c73d60a97675d6d3c5b040c4f50513"
@ -6080,6 +6103,11 @@ lodash.isempty@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e"
integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
lodash.isfunction@^3.0.8:
version "3.0.9"
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
@ -7334,6 +7362,11 @@ package-json@^6.3.0:
registry-url "^5.0.0"
semver "^6.2.0"
pako@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
pako@~1.0.5:
version "1.0.10"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732"
@ -9292,6 +9325,11 @@ semver@^7.1.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.2.tgz#847bae5bce68c5d08889824f02667199b70e3d87"
integrity sha512-BJs9T/H8sEVHbeigqzIEo57Iu/3DG6c4QoqTfbQB3BPA4zgzAomh/Fk9E7QtjWQ8mx2dgA9YCfSF4y9k9bHNpQ==
semver@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.3.tgz#e4345ce73071c53f336445cfc19efb1c311df2a6"
integrity sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==
semver@~5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"