1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00
fcast/receivers/electron/src/Updater.ts

414 lines
18 KiB
TypeScript
Raw Normal View History

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';
const cp = require('child_process');
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();
2024-11-17 14:42:46 -06:00
private static installPath: string = process.platform === 'darwin' ? path.join(Updater.appPath, '../../../') : path.join(Updater.appPath, '../../');
2024-11-11 12:24:17 -06:00
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-17 23:12:24 -06:00
private static isRestarting: boolean = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static localPackageJson: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static releasesJson: any = null;
2024-11-15 00:43:01 -06:00
public static isDownloading: boolean = false;
2024-11-17 23:12:24 -06:00
public static updateError: boolean = false;
public static updateDownloaded: boolean = false;
public static updateProgress: number = 0;
2024-11-17 23:36:16 -06:00
public static checkForUpdatesOnStart: boolean = true;
2024-11-19 09:54:50 -06:00
public static releaseChannel = 'stable';
2024-11-17 23:36:16 -06:00
static {
Updater.localPackageJson = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
let updaterSettings = Store.get('updater');
if (updaterSettings !== null) {
2024-11-19 09:54:50 -06:00
Updater.localPackageJson.channel = updaterSettings.channel === undefined ? Updater.localPackageJson.channel : updaterSettings.channel;
2024-11-17 23:36:16 -06:00
Updater.checkForUpdatesOnStart = updaterSettings.checkForUpdatesOnStart === undefined ? true : updaterSettings.checkForUpdatesOnStart;
}
updaterSettings = {
'channel': Updater.localPackageJson.channel,
'checkForUpdatesOnStart': Updater.checkForUpdatesOnStart,
}
2024-11-19 09:54:50 -06:00
Updater.releaseChannel = Updater.localPackageJson.channel;
2024-11-17 23:36:16 -06:00
Store.set('updater', updaterSettings);
}
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) => {
2024-11-17 23:12:24 -06:00
const downloadSize = Number(response.headers['content-length']);
logger.info(`Update size: ${downloadSize} bytes`);
2023-06-20 08:45:01 +02:00
response.pipe(file);
2024-11-17 23:12:24 -06:00
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
Updater.updateProgress = downloadedBytes / downloadSize;
});
2023-06-20 08:45:01 +02:00
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) {
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
if (process.platform === 'win32') {
// Cannot remove top-level directory since it might still be locked...
fs.rmSync(`${dst}\\*`, { maxRetries: 5, retryDelay: 1000, recursive: true, force: true });
}
else {
fs.rmSync(dst, { maxRetries: 5, retryDelay: 1000, recursive: true, force: true });
}
2024-11-17 14:42:46 -06:00
if (process.platform === 'darwin') {
// Electron framework libraries break otherwise on Mac
fs.cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
}
else {
fs.cpSync(src, dst, { recursive: true, force: true });
}
2024-11-15 00:43:01 -06:00
}
catch (err) {
if (err.code === 'EACCES' || err.code === 'EPERM') {
2024-11-15 00:43:01 -06:00
logger.info('Update requires admin privileges. Escalating...');
await new Promise<void>((resolve, reject) => {
let command: string;
if (process.platform === 'win32') {
// Using native cmd.exe seems to create less issues than using powershell...
command = `rmdir /S /Q "${dst}" & xcopy /Y /E "${src}" "${dst}"`;
}
else {
command = `rm -rf '${dst}'; cp -rf '${src}' '${dst}'; chmod 755 '${dst}'`;
}
2024-11-15 00:43:01 -06:00
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;
}
}
finally {
process.noAsar = false;
}
}
// Cannot use app.relaunch(...) since it breaks privilege escalation on Linux...
2024-11-17 14:42:46 -06:00
// Also does not work very well on Mac...
private static relaunch(binPath: string) {
2024-11-17 14:42:46 -06:00
logger.info(`Relaunching app binary: ${binPath}`);
log4js.shutdown();
let proc;
if (process.platform === 'win32') {
// cwd is bugged on Windows, perhaps due to needing to be in system32 to launch cmd.exe
proc = cp.spawn(`"${binPath}"`, [], { stdio: 'ignore', shell: true, detached: true, windowsHide: true });
}
2024-11-17 14:42:46 -06:00
else if (process.platform === 'darwin') {
proc = cp.spawn(`open '${binPath}'`, [], { cwd: path.dirname(binPath), shell: true, stdio: 'ignore', detached: true });
}
else {
2024-11-17 14:42:46 -06:00
proc = cp.spawn(binPath, [], { cwd: path.dirname(binPath), shell: true, stdio: 'ignore', detached: true });
}
proc.unref();
app.exit();
return;
2024-11-11 12:24:17 -06:00
}
2024-11-15 00:43:01 -06:00
public static restart() {
2024-11-17 23:12:24 -06:00
if (!Updater.isRestarting) {
Updater.isRestarting = true;
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
const extractionDir = process.platform === 'darwin' ? 'FCast Receiver.app' : `fcast-receiver-${process.platform}-${process.arch}`;
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);
Updater.relaunch(updateBinPath);
}
2024-11-11 12:24:17 -06:00
return;
2024-11-15 00:43:01 -06:00
}
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'));
2024-11-17 23:12:24 -06:00
Updater.updateError = true;
return updateInfo.updateState !== 'error';
2024-11-15 00:43:01 -06:00
}
catch {
return false;
}
}
2024-11-11 12:24:17 -06:00
2024-11-15 00:43:01 -06:00
public static getChannelVersion(): string {
2024-11-17 23:36:16 -06:00
Updater.localPackageJson.channelVersion = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0
2024-11-17 23:12:24 -06:00
return Updater.localPackageJson.channelVersion;
2024-11-15 00:43:01 -06:00
}
2024-11-11 12:24:17 -06:00
2024-11-21 11:51:46 -06:00
public static getCommit(): string {
Updater.localPackageJson.commit = Updater.localPackageJson.commit ? Updater.localPackageJson.commit : null
return Updater.localPackageJson.commit;
}
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}`;
const binaryName = process.platform === 'win32' ? 'fcast-receiver.exe' : 'fcast-receiver';
2024-11-17 14:42:46 -06:00
const installBinPath = process.platform === 'darwin' ? updateInfo.installPath : path.join(updateInfo.installPath, binaryName);
2024-11-11 12:24:17 -06:00
2024-11-15 00:43:01 -06:00
switch (updateInfo.updateState) {
case UpdateState.Copy: {
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}`);
await Updater.applyUpdate(src, updateInfo.installPath);
2024-11-15 00:43:01 -06:00
updateInfo.updateState = UpdateState.Cleanup;
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
}
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));
}
2024-11-17 23:12:24 -06:00
Updater.relaunch(installBinPath);
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.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
Updater.relaunch(installBinPath);
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-17 23:12:24 -06:00
public static async checkForUpdates(): Promise<boolean> {
logger.info('Checking for updates...');
2024-11-11 12:24:17 -06:00
2024-11-15 00:43:01 -06:00
try {
2024-11-17 23:12:24 -06:00
Updater.releasesJson = await Updater.fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo;
2023-06-20 08:45:01 +02:00
2024-11-17 23:12:24 -06:00
const localChannelVersion: number = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0;
const currentChannelVersion: number = Updater.releasesJson.channelCurrentVersions[Updater.localPackageJson.channel] ? Updater.releasesJson.channelCurrentVersions[Updater.localPackageJson.channel] : 0;
2024-11-21 11:51:46 -06:00
logger.info('Update check', {
channel: Updater.localPackageJson.channel,
channel_version: localChannelVersion,
localVersion: Updater.localPackageJson.version,
currentVersion: Updater.releasesJson.currentVersion,
currentCommit: Updater.releasesJson.currentCommit,
currentChannelVersion: currentChannelVersion
});
const newVersion = Updater.localPackageJson.version !== Updater.releasesJson.currentVersion;
const newChannelVersion = (Updater.localPackageJson.channel !== 'stable' && localChannelVersion < currentChannelVersion);
// Allow for update promotion to stable, while still getting updates from the subscribed channel
const newCommit = (Updater.localPackageJson.channel !== 'stable' && Updater.localPackageJson.commit !== Updater.releasesJson.currentCommit);
2024-11-11 12:24:17 -06:00
2024-11-21 11:51:46 -06:00
if (newVersion || newChannelVersion || newCommit) {
2024-11-17 23:12:24 -06:00
logger.info('Update available...');
2024-11-15 00:43:01 -06:00
return true;
}
}
catch (err) {
logger.error(`Failed to check for updates: ${err}`);
2024-11-17 23:12:24 -06:00
throw 'Please try again later or visit https://fcast.org for updates.';
2023-06-20 08:45:01 +02:00
}
return false;
}
2024-11-17 23:12:24 -06:00
public static async downloadUpdate(): Promise<boolean> {
try {
fs.accessSync(Updater.updateDataPath, fs.constants.F_OK);
}
catch (err) {
logger.info(`Directory does not exist: ${err}`);
fs.mkdirSync(Updater.updateDataPath);
}
try {
const newCommit = (Updater.localPackageJson.channel !== 'stable' && Updater.localPackageJson.commit !== Updater.releasesJson.currentCommit);
let channel = Updater.localPackageJson.version !== Updater.releasesJson.currentVersion ? 'stable' : Updater.localPackageJson.channel;
channel = newCommit ? 'stable' : channel;
2024-11-17 23:12:24 -06:00
const fileInfo = Updater.releasesJson.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 again later or visit https://fcast.org to for updates.';
logger.error(`Update failed integrity check. Expected hash: ${fileInfo.sha256Digest}, actual hash: ${hash}`);
throw message;
}
// 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: Updater.releasesJson.currentVersion,
downloadFile: file,
};
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
logger.info('Written update metadata.');
Updater.isDownloading = false;
Updater.updateDownloaded = true;
return true;
}
catch (err) {
Updater.isDownloading = false;
process.noAsar = false;
logger.error(`Failed to download update: ${err}`);
throw 'Failed to download update. Please try again later or visit https://fcast.org to download.';
}
}
2024-11-11 12:24:17 -06:00
}