2019-03-28 17:09:26 +00:00
|
|
|
import {
|
|
|
|
createWriteStream,
|
|
|
|
statSync,
|
|
|
|
writeFile as writeFileCallback,
|
|
|
|
} from 'fs';
|
2019-08-02 21:11:10 +00:00
|
|
|
import { join, normalize } from 'path';
|
2019-03-28 17:09:26 +00:00
|
|
|
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';
|
2020-02-12 21:30:58 +00:00
|
|
|
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
|
2019-03-28 17:09:26 +00:00
|
|
|
|
2019-05-24 01:27:42 +00:00
|
|
|
import { getTempPath } from '../../app/attachments';
|
2020-02-12 21:30:58 +00:00
|
|
|
import { Dialogs } from '../types/Dialogs';
|
2019-05-24 01:27:42 +00:00
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
// @ts-ignore
|
|
|
|
import * as packageJson from '../../package.json';
|
|
|
|
import { getSignatureFileName } from './signature';
|
|
|
|
|
2020-02-13 18:14:08 +00:00
|
|
|
export type LocaleType = {
|
|
|
|
i18n: (key: string, placeholders: Array<string>) => string;
|
|
|
|
messages: {
|
|
|
|
[key: string]: {
|
|
|
|
message: string;
|
|
|
|
description?: string;
|
|
|
|
};
|
2019-03-28 17:09:26 +00:00
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
type LogFunction = (...args: Array<any>) => void;
|
|
|
|
|
|
|
|
export type LoggerType = {
|
|
|
|
fatal: LogFunction;
|
|
|
|
error: LogFunction;
|
|
|
|
warn: LogFunction;
|
|
|
|
info: LogFunction;
|
|
|
|
debug: LogFunction;
|
|
|
|
trace: LogFunction;
|
|
|
|
};
|
|
|
|
|
|
|
|
const writeFile = pify(writeFileCallback);
|
|
|
|
const mkdirpPromise = pify(mkdirp);
|
|
|
|
const rimrafPromise = pify(rimraf);
|
|
|
|
const { platform } = process;
|
|
|
|
|
2020-02-12 21:30:58 +00:00
|
|
|
export const ACK_RENDER_TIMEOUT = 10000;
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2019-08-02 21:11:10 +00:00
|
|
|
export function validatePath(basePath: string, targetPath: string) {
|
|
|
|
const normalized = normalize(targetPath);
|
|
|
|
|
|
|
|
if (!normalized.startsWith(basePath)) {
|
|
|
|
throw new Error(
|
|
|
|
`validatePath: Path ${normalized} is not under base path ${basePath}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
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));
|
|
|
|
|
2019-08-02 21:11:10 +00:00
|
|
|
validatePath(tempDir, targetUpdatePath);
|
|
|
|
validatePath(tempDir, targetSignaturePath);
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-21 23:41:05 +00:00
|
|
|
let showingUpdateDialog = false;
|
|
|
|
|
2020-02-12 21:30:58 +00:00
|
|
|
async function showFallbackUpdateDialog(
|
2019-03-28 17:09:26 +00:00
|
|
|
mainWindow: BrowserWindow,
|
2020-02-13 18:14:08 +00:00
|
|
|
locale: LocaleType
|
2020-02-21 23:41:05 +00:00
|
|
|
): Promise<boolean> {
|
|
|
|
if (showingUpdateDialog) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
const RESTART_BUTTON = 0;
|
|
|
|
const LATER_BUTTON = 1;
|
|
|
|
const options = {
|
|
|
|
type: 'info',
|
|
|
|
buttons: [
|
2020-02-13 18:14:08 +00:00
|
|
|
locale.messages.autoUpdateRestartButtonLabel.message,
|
|
|
|
locale.messages.autoUpdateLaterButtonLabel.message,
|
2019-03-28 17:09:26 +00:00
|
|
|
],
|
2020-02-13 18:14:08 +00:00
|
|
|
title: locale.messages.autoUpdateNewVersionTitle.message,
|
|
|
|
message: locale.messages.autoUpdateNewVersionMessage.message,
|
|
|
|
detail: locale.messages.autoUpdateNewVersionInstructions.message,
|
2019-03-28 17:09:26 +00:00
|
|
|
defaultId: LATER_BUTTON,
|
2020-01-23 22:52:47 +00:00
|
|
|
cancelId: LATER_BUTTON,
|
2019-03-28 17:09:26 +00:00
|
|
|
};
|
|
|
|
|
2020-02-21 23:41:05 +00:00
|
|
|
showingUpdateDialog = true;
|
|
|
|
|
2019-08-19 17:59:30 +00:00
|
|
|
const { response } = await dialog.showMessageBox(mainWindow, options);
|
|
|
|
|
2020-02-21 23:41:05 +00:00
|
|
|
showingUpdateDialog = false;
|
|
|
|
|
2019-08-19 17:59:30 +00:00
|
|
|
return response === RESTART_BUTTON;
|
2019-03-28 17:09:26 +00:00
|
|
|
}
|
|
|
|
|
2020-02-12 21:30:58 +00:00
|
|
|
export function showUpdateDialog(
|
|
|
|
mainWindow: BrowserWindow,
|
2020-02-13 18:14:08 +00:00
|
|
|
locale: LocaleType,
|
2020-02-12 21:30:58 +00:00
|
|
|
performUpdateCallback: () => void
|
|
|
|
): void {
|
|
|
|
let ack = false;
|
|
|
|
|
|
|
|
ipcMain.once('start-update', performUpdateCallback);
|
|
|
|
|
|
|
|
ipcMain.once('show-update-dialog-ack', () => {
|
|
|
|
ack = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
mainWindow.webContents.send('show-update-dialog', Dialogs.Update);
|
|
|
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
if (!ack) {
|
2020-02-21 23:41:05 +00:00
|
|
|
const shouldUpdate = await showFallbackUpdateDialog(mainWindow, locale);
|
|
|
|
if (shouldUpdate) {
|
|
|
|
performUpdateCallback();
|
|
|
|
}
|
2020-02-12 21:30:58 +00:00
|
|
|
}
|
|
|
|
}, ACK_RENDER_TIMEOUT);
|
|
|
|
}
|
|
|
|
|
2020-02-21 23:41:05 +00:00
|
|
|
let showingCannotUpdateDialog = false;
|
|
|
|
|
2020-02-12 21:30:58 +00:00
|
|
|
async function showFallbackCannotUpdateDialog(
|
2019-03-28 17:09:26 +00:00
|
|
|
mainWindow: BrowserWindow,
|
2020-02-13 18:14:08 +00:00
|
|
|
locale: LocaleType
|
2020-02-21 23:41:05 +00:00
|
|
|
): Promise<void> {
|
|
|
|
if (showingCannotUpdateDialog) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
const options = {
|
|
|
|
type: 'error',
|
2020-02-13 18:14:08 +00:00
|
|
|
buttons: [locale.messages.ok.message],
|
|
|
|
title: locale.messages.cannotUpdate.message,
|
|
|
|
message: locale.i18n('cannotUpdateDetail', ['https://signal.org/download']),
|
2019-03-28 17:09:26 +00:00
|
|
|
};
|
|
|
|
|
2020-02-21 23:41:05 +00:00
|
|
|
showingCannotUpdateDialog = true;
|
|
|
|
|
2019-08-19 17:59:30 +00:00
|
|
|
await dialog.showMessageBox(mainWindow, options);
|
2020-02-21 23:41:05 +00:00
|
|
|
|
|
|
|
showingCannotUpdateDialog = false;
|
2019-03-28 17:09:26 +00:00
|
|
|
}
|
|
|
|
|
2020-02-12 21:30:58 +00:00
|
|
|
export function showCannotUpdateDialog(
|
|
|
|
mainWindow: BrowserWindow,
|
2020-02-13 18:14:08 +00:00
|
|
|
locale: LocaleType
|
2020-02-12 21:30:58 +00:00
|
|
|
): void {
|
|
|
|
let ack = false;
|
|
|
|
|
|
|
|
ipcMain.once('show-update-dialog-ack', () => {
|
|
|
|
ack = true;
|
|
|
|
});
|
|
|
|
|
|
|
|
mainWindow.webContents.send('show-update-dialog', Dialogs.Cannot_Update);
|
|
|
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
if (!ack) {
|
2020-02-13 18:14:08 +00:00
|
|
|
await showFallbackCannotUpdateDialog(mainWindow, locale);
|
2020-02-12 21:30:58 +00:00
|
|
|
}
|
|
|
|
}, ACK_RENDER_TIMEOUT);
|
|
|
|
}
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2019-08-02 21:11:10 +00:00
|
|
|
const validFile = /^[A-Za-z0-9\.\-]+$/;
|
|
|
|
export function isUpdateFileNameValid(name: string) {
|
|
|
|
return validFile.test(name);
|
|
|
|
}
|
|
|
|
|
2019-03-28 17:09:26 +00:00
|
|
|
export function getUpdateFileName(yaml: string) {
|
|
|
|
const info = parseYaml(yaml);
|
|
|
|
|
2019-08-02 21:11:10 +00:00
|
|
|
if (!info || !info.path) {
|
|
|
|
throw new Error('getUpdateFileName: No path present in YAML file');
|
2019-03-28 17:09:26 +00:00
|
|
|
}
|
|
|
|
|
2019-08-02 21:11:10 +00:00
|
|
|
const path = info.path;
|
|
|
|
if (!isUpdateFileNameValid(path)) {
|
|
|
|
throw new Error(
|
|
|
|
`getUpdateFileName: Path '${path}' contains invalid characters`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return path;
|
2019-03-28 17:09:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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',
|
2020-01-17 00:53:35 +00:00
|
|
|
'User-Agent': `Signal Desktop ${packageJson.version}`,
|
2019-03-28 17:09:26 +00:00
|
|
|
},
|
|
|
|
useElectronNet: false,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function getBaseTempDir() {
|
|
|
|
// We only use tmpdir() when this code is run outside of an Electron app (as in: tests)
|
2019-05-24 01:27:42 +00:00
|
|
|
return app ? getTempPath(app.getPath('userData')) : tmpdir();
|
2019-03-28 17:09:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 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;
|
|
|
|
}
|