2023-06-20 08:45:01 +02:00
|
|
|
import * as fs from 'fs';
|
|
|
|
import * as https from 'https';
|
|
|
|
import * as path from 'path';
|
2024-11-11 12:24:17 -06:00
|
|
|
import * as crypto from 'crypto';
|
|
|
|
import * as log4js from "log4js";
|
|
|
|
import { app } from 'electron';
|
|
|
|
import { Store } from './Store';
|
2024-11-15 00:43:01 -06:00
|
|
|
import sudo from 'sudo-prompt';
|
2024-11-11 12:24:17 -06:00
|
|
|
const extract = require('extract-zip');
|
|
|
|
const logger = log4js.getLogger();
|
|
|
|
|
|
|
|
enum UpdateState {
|
2024-11-15 00:43:01 -06:00
|
|
|
Copy = 'copy',
|
|
|
|
Cleanup = 'cleanup',
|
|
|
|
Error = 'error',
|
2024-11-11 12:24:17 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
interface ReleaseInfo {
|
|
|
|
previousVersions: [string];
|
|
|
|
currentVersion: string;
|
|
|
|
currentReleases: [
|
|
|
|
string: [ // channel
|
|
|
|
string: [ // os
|
|
|
|
string: [ // arch
|
|
|
|
string: []
|
|
|
|
]
|
|
|
|
]
|
|
|
|
]
|
|
|
|
];
|
|
|
|
channelCurrentVersions: [string: number];
|
|
|
|
allVersions: [string];
|
2024-11-15 00:43:01 -06:00
|
|
|
fileVersion: string;
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
interface UpdateInfo {
|
|
|
|
updateState: UpdateState;
|
|
|
|
installPath: string;
|
|
|
|
tempPath: string;
|
|
|
|
currentVersion: string;
|
2024-11-15 00:43:01 -06:00
|
|
|
downloadFile: string;
|
|
|
|
error?: string
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
2023-06-20 08:45:01 +02:00
|
|
|
|
|
|
|
export class Updater {
|
2024-11-15 00:43:01 -06:00
|
|
|
private static readonly supportedReleasesJsonVersion = '1';
|
|
|
|
|
2024-11-11 12:24:17 -06:00
|
|
|
private static appPath: string = app.getAppPath();
|
|
|
|
private static installPath: string = path.join(Updater.appPath, '../../');
|
|
|
|
private static updateDataPath: string = path.join(app.getPath('userData'), 'updater');
|
|
|
|
private static updateMetadataPath = path.join(Updater.updateDataPath, './update.json');
|
|
|
|
private static baseUrl: string = 'https://dl.fcast.org/electron';
|
2024-11-15 00:43:01 -06:00
|
|
|
private static channelVersion: string = null;
|
|
|
|
|
|
|
|
public static isDownloading: boolean = false;
|
|
|
|
public static updateApplied: boolean = false;
|
2024-11-11 12:24:17 -06:00
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
private static async fetchJSON(url: string): Promise<any> {
|
2023-06-20 08:45:01 +02:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
https.get(url, (res) => {
|
|
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
|
|
data += chunk;
|
|
|
|
});
|
|
|
|
|
|
|
|
res.on('end', () => {
|
|
|
|
try {
|
|
|
|
resolve(JSON.parse(data));
|
|
|
|
} catch (err) {
|
|
|
|
reject(err);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}).on('error', (err) => {
|
|
|
|
reject(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-11-11 12:24:17 -06:00
|
|
|
private static async downloadFile(url: string, destination: string): Promise<void> {
|
2023-06-20 08:45:01 +02:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const file = fs.createWriteStream(destination);
|
|
|
|
https.get(url, (response) => {
|
|
|
|
response.pipe(file);
|
|
|
|
file.on('finish', () => {
|
|
|
|
file.close();
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
}).on('error', (err) => {
|
|
|
|
file.close();
|
|
|
|
reject(err);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
private static async applyUpdate(src: string, dst: string) {
|
|
|
|
// Sanity removal protection check (especially under admin)
|
|
|
|
if (!dst.includes('fcast-receiver')) {
|
|
|
|
throw `Aborting update applying due to possible malformed path: ${dst}`;
|
2023-06-20 08:45:01 +02:00
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
try {
|
|
|
|
fs.accessSync(dst, fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
// Electron runtime sees .asar file as directory and causes errors during copy/remove operations
|
|
|
|
process.noAsar = true
|
|
|
|
fs.rmSync(dst, { recursive: true, force: true });
|
|
|
|
fs.cpSync(src, dst, { recursive: true, force: true });
|
|
|
|
process.noAsar = false
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
if (err.code === 'EACCES') {
|
|
|
|
logger.info('Update requires admin privileges. Escalating...');
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
const shell = process.platform === 'win32' ? 'powershell' : '';
|
|
|
|
const command = `${shell} rm -rf ${dst}; ${shell} cp -rf ${src} ${dst}`
|
|
|
|
|
|
|
|
sudo.exec(command, { name: 'FCast Receiver' }, (error, stdout, stderr) => {
|
|
|
|
if (error) {
|
|
|
|
logger.error(error);
|
|
|
|
logger.warn(`stdout: ${stdout}`);
|
|
|
|
logger.warn(`stderr: ${stderr}`);
|
|
|
|
reject('User did not authorize the operation...');
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.info('stdout', stdout);
|
|
|
|
logger.info('stderr', stderr);
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
logger.error(err);
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
public static restart() {
|
|
|
|
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
|
2024-11-11 12:24:17 -06:00
|
|
|
const extractionDir = process.platform === 'darwin' ? 'FCast Receiver.app' : `fcast-receiver-${process.platform}-${process.arch}`;
|
2024-11-15 00:43:01 -06:00
|
|
|
const binaryName = process.platform === 'win32' ? 'fcast-receiver.exe' : 'fcast-receiver';
|
|
|
|
const updateBinPath = process.platform === 'darwin' ? path.join(updateInfo.tempPath, extractionDir) : path.join(updateInfo.tempPath, extractionDir, binaryName);
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
app.relaunch({ execPath: updateBinPath });
|
|
|
|
app.exit();
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
public static isUpdating(): boolean {
|
|
|
|
try {
|
|
|
|
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
|
|
|
|
Updater.updateApplied = updateInfo.updateState === 'cleanup' ? true : false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
catch {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
public static getChannelVersion(): string {
|
|
|
|
if (Updater.channelVersion === null) {
|
|
|
|
const localPackage = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
|
|
|
|
Updater.channelVersion = localPackage.channelVersion ? localPackage.channelVersion : 0
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
return Updater.channelVersion;
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
public static async processUpdate(): Promise<void> {
|
|
|
|
try {
|
|
|
|
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
|
|
|
|
const extractionDir = process.platform === 'darwin' ? 'FCast Receiver.app' : `fcast-receiver-${process.platform}-${process.arch}`;
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
switch (updateInfo.updateState) {
|
|
|
|
case UpdateState.Copy: {
|
|
|
|
const binaryName = process.platform === 'win32' ? 'fcast-receiver.exe' : 'fcast-receiver';
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
try {
|
|
|
|
logger.info('Updater process started...');
|
|
|
|
const src = path.join(updateInfo.tempPath, extractionDir);
|
|
|
|
logger.info(`Copying files from update directory ${src} to install directory ${updateInfo.installPath}`);
|
|
|
|
|
|
|
|
Updater.applyUpdate(src, updateInfo.installPath);
|
|
|
|
updateInfo.updateState = UpdateState.Cleanup;
|
|
|
|
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
|
|
|
|
|
|
|
|
const installBinPath = path.join(updateInfo.installPath, binaryName);
|
|
|
|
log4js.shutdown();
|
|
|
|
app.relaunch({ execPath: installBinPath });
|
|
|
|
app.exit();
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
logger.error('Error while applying update...');
|
|
|
|
logger.error(err);
|
|
|
|
|
|
|
|
updateInfo.updateState = UpdateState.Error;
|
|
|
|
updateInfo.error = JSON.stringify(err);
|
|
|
|
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
|
|
|
|
log4js.shutdown();
|
|
|
|
app.exit();
|
|
|
|
}
|
|
|
|
|
|
|
|
return;
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
case UpdateState.Cleanup: {
|
|
|
|
try {
|
|
|
|
logger.info('Performing update cleanup...')
|
|
|
|
// Electron runtime sees .asar file as directory and causes errors during copy
|
|
|
|
process.noAsar = true
|
|
|
|
logger.info(`rm dir ${path.join(Updater.updateDataPath, extractionDir)}`)
|
|
|
|
fs.rmSync(path.join(Updater.updateDataPath, extractionDir), { recursive: true, force: true });
|
|
|
|
process.noAsar = false
|
|
|
|
|
|
|
|
fs.rmSync(path.join(Updater.updateDataPath, updateInfo.downloadFile));
|
|
|
|
fs.rmSync(Updater.updateMetadataPath);
|
|
|
|
|
|
|
|
// Removing the install directory causes an 'ENOENT: no such file or directory, uv_cwd' when calling process.cwd()
|
|
|
|
// Need to fix the working directory to the update directory that overwritten the install directory
|
|
|
|
process.chdir(Updater.installPath);
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
logger.error('Error while performing update cleanup...');
|
|
|
|
logger.error(err);
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
updateInfo.updateState = UpdateState.Error;
|
|
|
|
updateInfo.error = JSON.stringify(err);
|
|
|
|
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
return;
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
case UpdateState.Error:
|
|
|
|
logger.warn(`Update operation did not complete successfully: ${updateInfo.error}`);
|
|
|
|
break;
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|
|
|
|
}
|
2024-11-15 00:43:01 -06:00
|
|
|
catch (err) {
|
|
|
|
logger.warn(`Error reading update metadata file, ignoring pending update: ${err}`);
|
|
|
|
}
|
2023-06-20 08:45:01 +02:00
|
|
|
}
|
|
|
|
|
2024-11-11 12:24:17 -06:00
|
|
|
public static async update(): Promise<boolean> {
|
|
|
|
logger.info('Updater invoked');
|
2024-11-15 00:43:01 -06:00
|
|
|
try {
|
|
|
|
fs.accessSync(Updater.updateDataPath, fs.constants.F_OK);
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
logger.info(`Directory does not exist: ${err}`);
|
2024-11-11 12:24:17 -06:00
|
|
|
fs.mkdirSync(Updater.updateDataPath);
|
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
const localPackage = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
|
|
|
|
try {
|
|
|
|
const releases = await Updater.fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo;
|
2023-06-20 08:45:01 +02:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
let updaterSettings = Store.get('updater');
|
|
|
|
if (updaterSettings === null) {
|
|
|
|
updaterSettings = {
|
|
|
|
'channel': localPackage.channel,
|
|
|
|
}
|
2023-06-20 08:45:01 +02:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
Store.set('updater', updaterSettings);
|
2023-06-20 08:45:01 +02:00
|
|
|
}
|
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
const localChannelVersion: number = localPackage.channelVersion ? localPackage.channelVersion : 0
|
|
|
|
const currentChannelVersion: number = releases.channelCurrentVersions[localPackage.channel] ? releases.channelCurrentVersions[localPackage.channel] : 0
|
|
|
|
logger.info('Update check', { channel: localPackage.channel, channel_version: localChannelVersion, localVersion: localPackage.version,
|
|
|
|
currentVersion: releases.currentVersion, currentChannelVersion: currentChannelVersion });
|
|
|
|
|
|
|
|
if (localPackage.version !== releases.currentVersion || (localPackage.channel !== 'stable' && localChannelVersion < currentChannelVersion)) {
|
|
|
|
const channel = localPackage.version !== releases.currentVersion ? 'stable' : localPackage.channel;
|
|
|
|
const fileInfo = releases.currentReleases[channel][process.platform][process.arch]
|
|
|
|
const file = fileInfo.url.toString().split('/').pop();
|
|
|
|
|
|
|
|
const destination = path.join(Updater.updateDataPath, file);
|
|
|
|
logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`);
|
|
|
|
Updater.isDownloading = true;
|
|
|
|
await Updater.downloadFile(fileInfo.url.toString(), destination);
|
|
|
|
|
|
|
|
const downloadedFile = await fs.promises.readFile(destination);
|
|
|
|
const hash = crypto.createHash('sha256').end(downloadedFile).digest('hex');
|
|
|
|
if (fileInfo.sha256Digest !== hash) {
|
|
|
|
const message = 'Update failed integrity check. Please try checking for updates again or downloading the update manually.';
|
|
|
|
logger.error(`Update failed integrity check. Expected hash: ${fileInfo.sha256Digest}, actual hash: ${hash}`);
|
|
|
|
throw message;
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
|
2024-11-15 00:43:01 -06:00
|
|
|
// Electron runtime sees .asar file as directory and causes errors during extraction
|
|
|
|
logger.info('Extracting update...');
|
|
|
|
process.noAsar = true;
|
|
|
|
await extract(destination, { dir: path.dirname(destination) });
|
|
|
|
process.noAsar = false;
|
|
|
|
|
|
|
|
logger.info('Extraction complete.');
|
|
|
|
const updateInfo: UpdateInfo = {
|
|
|
|
updateState: UpdateState.Copy,
|
|
|
|
installPath: Updater.installPath,
|
|
|
|
tempPath: path.dirname(destination),
|
|
|
|
currentVersion: releases.currentVersion,
|
|
|
|
downloadFile: file,
|
|
|
|
};
|
|
|
|
|
|
|
|
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
|
|
|
|
logger.info('Written update metadata.');
|
|
|
|
Updater.isDownloading = false;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
catch (err) {
|
|
|
|
Updater.isDownloading = false;
|
|
|
|
process.noAsar = false;
|
|
|
|
logger.error(`Failed to check for updates: ${err}`);
|
|
|
|
throw 'Failed to check for updates. Please try again later or visit https://fcast.org for updates.';
|
2023-06-20 08:45:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
2024-11-11 12:24:17 -06:00
|
|
|
}
|