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

New updater flow and UI

This commit is contained in:
Michael Hollister 2024-11-17 23:12:24 -06:00
parent a967b7983a
commit 568c972492
6 changed files with 358 additions and 146 deletions

View file

@ -53,41 +53,36 @@ export default class Main {
{ {
label: 'Check for updates', label: 'Check for updates',
click: async () => { click: async () => {
if (!Updater.isDownloading) { if (Updater.updateDownloaded) {
try { Main.mainWindow.webContents.send("download-complete");
if (await Updater.update()) { return;
const restartPrompt = await dialog.showMessageBox({ }
type: 'info',
title: 'Update ready',
message: 'Update downloaded, restart now to apply the changes.',
buttons: ['Restart'],
defaultId: 0
});
// Restart the app if the user clicks the 'Restart' button try {
if (restartPrompt.response === 0) { const updateAvailable = await Updater.checkForUpdates();
Updater.restart();
} if (updateAvailable) {
} else { Main.mainWindow.webContents.send("update-available");
await dialog.showMessageBox({ }
type: 'info', else {
title: 'Already up-to-date',
message: 'The application is already on the latest version.',
buttons: ['OK'],
defaultId: 0
});
}
} catch (err) {
await dialog.showMessageBox({ await dialog.showMessageBox({
type: 'error', type: 'info',
title: 'Failed to update', title: 'Already up-to-date',
message: err, message: 'The application is already on the latest version.',
buttons: ['OK'], buttons: ['OK'],
defaultId: 0 defaultId: 0
}); });
Main.logger.error('Failed to update:', err);
} }
} catch (err) {
await dialog.showMessageBox({
type: 'error',
title: 'Failed to check for updates',
message: err,
buttons: ['OK'],
defaultId: 0
});
Main.logger.error('Failed to check for updates:', err);
} }
}, },
}, },
@ -182,8 +177,34 @@ export default class Main {
ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => { ipcMain.on('send-volume-update', (event: IpcMainEvent, value: VolumeUpdateMessage) => {
l.send(Opcode.VolumeUpdate, value); l.send(Opcode.VolumeUpdate, value);
}); });
ipcMain.on('send-download-request', async () => {
if (!Updater.isDownloading) {
try {
await Updater.downloadUpdate();
Main.mainWindow.webContents.send("download-complete");
} catch (err) {
await dialog.showMessageBox({
type: 'error',
title: 'Failed to download update',
message: err,
buttons: ['OK'],
defaultId: 0
});
Main.logger.error('Failed to download update:', err);
Main.mainWindow.webContents.send("download-failed");
}
}
});
ipcMain.on('send-restart-request', async () => {
Updater.restart();
});
}); });
ipcMain.handle('updater-progress', async () => { return Updater.updateProgress; });
ipcMain.handle('is-full-screen', async () => { ipcMain.handle('is-full-screen', async () => {
const window = Main.playerWindow; const window = Main.playerWindow;
if (!window) { if (!window) {
@ -214,6 +235,16 @@ export default class Main {
if (Main.shouldOpenMainWindow) { if (Main.shouldOpenMainWindow) {
Main.openMainWindow(); Main.openMainWindow();
} }
if (Updater.updateError) {
dialog.showMessageBox({
type: 'error',
title: 'Error applying update',
message: 'Please try again later or visit https://fcast.org to update.',
buttons: ['OK'],
defaultId: 0
});
}
} }

View file

@ -50,9 +50,17 @@ export class Updater {
private static updateDataPath: string = path.join(app.getPath('userData'), 'updater'); private static updateDataPath: string = path.join(app.getPath('userData'), 'updater');
private static updateMetadataPath = path.join(Updater.updateDataPath, './update.json'); private static updateMetadataPath = path.join(Updater.updateDataPath, './update.json');
private static baseUrl: string = 'https://dl.fcast.org/electron'; private static baseUrl: string = 'https://dl.fcast.org/electron';
private static channelVersion: string = null; 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;
public static isDownloading: boolean = false; public static isDownloading: boolean = false;
public static updateError: boolean = false;
public static updateDownloaded: boolean = false;
public static updateProgress: number = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private static async fetchJSON(url: string): Promise<any> { private static async fetchJSON(url: string): Promise<any> {
@ -80,7 +88,15 @@ export class Updater {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination); const file = fs.createWriteStream(destination);
https.get(url, (response) => { https.get(url, (response) => {
const downloadSize = Number(response.headers['content-length']);
logger.info(`Update size: ${downloadSize} bytes`);
response.pipe(file); response.pipe(file);
let downloadedBytes = 0;
response.on('data', (chunk) => {
downloadedBytes += chunk.length;
Updater.updateProgress = downloadedBytes / downloadSize;
});
file.on('finish', () => { file.on('finish', () => {
file.close(); file.close();
resolve(); resolve();
@ -169,19 +185,23 @@ export class Updater {
} }
public static restart() { public static restart() {
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8')); if (!Updater.isRestarting) {
const extractionDir = process.platform === 'darwin' ? 'FCast Receiver.app' : `fcast-receiver-${process.platform}-${process.arch}`; Updater.isRestarting = true;
const binaryName = process.platform === 'win32' ? 'fcast-receiver.exe' : 'fcast-receiver'; const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
const updateBinPath = process.platform === 'darwin' ? path.join(updateInfo.tempPath, extractionDir) : path.join(updateInfo.tempPath, extractionDir, binaryName); 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);
}
Updater.relaunch(updateBinPath);
return; return;
} }
public static isUpdating(): boolean { public static isUpdating(): boolean {
try { try {
const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8')); const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8'));
// TODO: In case of error inform user Updater.updateError = true;
return updateInfo.updateState !== 'error'; return updateInfo.updateState !== 'error';
} }
catch { catch {
@ -190,12 +210,12 @@ export class Updater {
} }
public static getChannelVersion(): string { public static getChannelVersion(): string {
if (Updater.channelVersion === null) { if (Updater.localPackageJson === null) {
const localPackage = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8')); Updater.localPackageJson = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
Updater.channelVersion = localPackage.channelVersion ? localPackage.channelVersion : 0 Updater.localPackageJson.channelVersion = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0
} }
return Updater.channelVersion; return Updater.localPackageJson.channelVersion;
} }
public static async processUpdate(): Promise<void> { public static async processUpdate(): Promise<void> {
@ -215,9 +235,6 @@ export class Updater {
await Updater.applyUpdate(src, updateInfo.installPath); await Updater.applyUpdate(src, updateInfo.installPath);
updateInfo.updateState = UpdateState.Cleanup; updateInfo.updateState = UpdateState.Cleanup;
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo)); fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
Updater.relaunch(installBinPath);
return;
} }
catch (err) { catch (err) {
logger.error('Error while applying update...'); logger.error('Error while applying update...');
@ -226,10 +243,9 @@ export class Updater {
updateInfo.updateState = UpdateState.Error; updateInfo.updateState = UpdateState.Error;
updateInfo.error = JSON.stringify(err); updateInfo.error = JSON.stringify(err);
fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo)); fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
log4js.shutdown();
app.exit();
} }
Updater.relaunch(installBinPath);
return; return;
} }
@ -272,8 +288,44 @@ export class Updater {
} }
} }
public static async update(): Promise<boolean> { public static async checkForUpdates(): Promise<boolean> {
logger.info('Updater invoked'); logger.info('Checking for updates...');
Updater.localPackageJson = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
try {
Updater.releasesJson = await Updater.fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo;
let updaterSettings = Store.get('updater');
if (updaterSettings === null) {
updaterSettings = {
'channel': Updater.localPackageJson.channel,
}
Store.set('updater', updaterSettings);
}
else {
Updater.localPackageJson.channel = updaterSettings.channel;
}
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;
logger.info('Update check', { channel: Updater.localPackageJson.channel, channel_version: localChannelVersion, localVersion: Updater.localPackageJson.version,
currentVersion: Updater.releasesJson.currentVersion, currentChannelVersion: currentChannelVersion });
if (Updater.localPackageJson.version !== Updater.releasesJson.currentVersion || (Updater.localPackageJson.channel !== 'stable' && localChannelVersion < currentChannelVersion)) {
logger.info('Update available...');
return true;
}
}
catch (err) {
logger.error(`Failed to check for updates: ${err}`);
throw 'Please try again later or visit https://fcast.org for updates.';
}
return false;
}
public static async downloadUpdate(): Promise<boolean> {
try { try {
fs.accessSync(Updater.updateDataPath, fs.constants.F_OK); fs.accessSync(Updater.updateDataPath, fs.constants.F_OK);
} }
@ -282,70 +334,50 @@ export class Updater {
fs.mkdirSync(Updater.updateDataPath); fs.mkdirSync(Updater.updateDataPath);
} }
const localPackage = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8'));
try { try {
const releases = await Updater.fetchJSON(`${Updater.baseUrl}/releases_v${Updater.supportedReleasesJsonVersion}.json`.toString()) as ReleaseInfo; const channel = Updater.localPackageJson.version !== Updater.releasesJson.currentVersion ? 'stable' : Updater.localPackageJson.channel;
const fileInfo = Updater.releasesJson.currentReleases[channel][process.platform][process.arch]
const file = fileInfo.url.toString().split('/').pop();
let updaterSettings = Store.get('updater'); const destination = path.join(Updater.updateDataPath, file);
if (updaterSettings === null) { logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`);
updaterSettings = { Updater.isDownloading = true;
'channel': localPackage.channel, await Updater.downloadFile(fileInfo.url.toString(), destination);
}
Store.set('updater', updaterSettings); 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;
} }
const localChannelVersion: number = localPackage.channelVersion ? localPackage.channelVersion : 0 // Electron runtime sees .asar file as directory and causes errors during extraction
const currentChannelVersion: number = releases.channelCurrentVersions[localPackage.channel] ? releases.channelCurrentVersions[localPackage.channel] : 0 logger.info('Extracting update...');
logger.info('Update check', { channel: localPackage.channel, channel_version: localChannelVersion, localVersion: localPackage.version, process.noAsar = true;
currentVersion: releases.currentVersion, currentChannelVersion: currentChannelVersion }); await extract(destination, { dir: path.dirname(destination) });
process.noAsar = false;
if (localPackage.version !== releases.currentVersion || (localPackage.channel !== 'stable' && localChannelVersion < currentChannelVersion)) { logger.info('Extraction complete.');
const channel = localPackage.version !== releases.currentVersion ? 'stable' : localPackage.channel; const updateInfo: UpdateInfo = {
const fileInfo = releases.currentReleases[channel][process.platform][process.arch] updateState: UpdateState.Copy,
const file = fileInfo.url.toString().split('/').pop(); installPath: Updater.installPath,
tempPath: path.dirname(destination),
currentVersion: Updater.releasesJson.currentVersion,
downloadFile: file,
};
const destination = path.join(Updater.updateDataPath, file); fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo));
logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`); logger.info('Written update metadata.');
Updater.isDownloading = true; Updater.isDownloading = false;
await Updater.downloadFile(fileInfo.url.toString(), destination); Updater.updateDownloaded = true;
return true;
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;
}
// 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) { catch (err) {
Updater.isDownloading = false; Updater.isDownloading = false;
process.noAsar = false; process.noAsar = false;
logger.error(`Failed to check for updates: ${err}`); logger.error(`Failed to download update: ${err}`);
throw 'Failed to check for updates. Please try again later or visit https://fcast.org for updates.'; throw 'Failed to download update. Please try again later or visit https://fcast.org to download.';
} }
return false;
} }
} }

View file

@ -6,6 +6,12 @@ ipcRenderer.on("device-info", (_event, value) => {
}) })
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
updaterProgress: () => ipcRenderer.invoke('updater-progress'),
onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback), onDeviceInfo: (callback) => ipcRenderer.on("device-info", callback),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
sendDownloadRequest: () => ipcRenderer.send('send-download-request'),
onDownloadComplete: (callback) => ipcRenderer.on("download-complete", callback),
onDownloadFailed: (callback) => ipcRenderer.on("download-failed", callback),
sendRestartRequest: () => ipcRenderer.send('send-restart-request'),
getDeviceInfo: () => deviceInfo, getDeviceInfo: () => deviceInfo,
}); });

View file

@ -1,5 +1,15 @@
import QRCode from 'qrcode'; import QRCode from 'qrcode';
const updateView = document.getElementById("update-view");
const updateViewTitle = document.getElementById("update-view-title");
const updateText = document.getElementById("update-text");
const updateButton = document.getElementById("update-button");
const restartButton = document.getElementById("restart-button");
const updateLaterButton = document.getElementById("update-later-button");
const progressBar = document.getElementById("progress-bar");
const progressBarProgress = document.getElementById("progress-bar-progress");
let updaterProgressUIUpdateTimer = null;
window.electronAPI.onDeviceInfo(renderIPsAndQRCode); window.electronAPI.onDeviceInfo(renderIPsAndQRCode);
if(window.electronAPI.getDeviceInfo()) { if(window.electronAPI.getDeviceInfo()) {
@ -45,3 +55,57 @@ function renderIPsAndQRCode() {
console.log(`Error rendering QR Code: ${e}`) console.log(`Error rendering QR Code: ${e}`)
}); });
} }
window.electronAPI.onUpdateAvailable(() => {
console.log(`Received UpdateAvailable event`);
updateViewTitle.textContent = 'FCast update available';
updateText.textContent = 'Do you wish to update now?';
updateButton.setAttribute("style", "display: block");
updateLaterButton.setAttribute("style", "display: block");
restartButton.setAttribute("style", "display: none");
progressBar.setAttribute("style", "display: none");
updateView.setAttribute("style", "display: flex");
});
window.electronAPI.onDownloadComplete(() => {
console.log(`Received DownloadComplete event`);
window.clearTimeout(updaterProgressUIUpdateTimer);
updateViewTitle.textContent = 'FCast update ready';
updateText.textContent = 'Restart now to apply the changes?';
updateButton.setAttribute("style", "display: none");
progressBar.setAttribute("style", "display: none");
restartButton.setAttribute("style", "display: block");
updateLaterButton.setAttribute("style", "display: block");
updateView.setAttribute("style", "display: flex");
});
window.electronAPI.onDownloadFailed(() => {
console.log(`Received DownloadFailed event`);
window.clearTimeout(updaterProgressUIUpdateTimer);
updateView.setAttribute("style", "display: none");
});
updateLaterButton.onclick = () => { updateView.setAttribute("style", "display: none"); };
updateButton.onclick = () => {
updaterProgressUIUpdateTimer = window.setInterval( async () => {
const updateProgress = await window.electronAPI.updaterProgress();
if (updateProgress >= 1.0) {
updateText.textContent = "Preparing update...";
progressBarProgress.setAttribute("style", `width: 100%`);
}
else {
progressBarProgress.setAttribute("style", `width: ${Math.max(12, updateProgress * 100)}%`);
}
}, 500);
updateText.textContent = 'Downloading...';
updateButton.setAttribute("style", "display: none");
updateLaterButton.setAttribute("style", "display: none");
progressBarProgress.setAttribute("style", "width: 12%");
progressBar.setAttribute("style", "display: block");
window.electronAPI.sendDownloadRequest();
};
restartButton.onclick = () => { window.electronAPI.sendRestartRequest(); };

View file

@ -25,17 +25,25 @@
<div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div> <div id="spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div> </div>
<!-- <div id="update-dialog">There is an update available. Do you wish to update?</div> <div id="update-view" class="card">
<div id="update-button">Update</div> <div id="update-view-title" class="non-selectable card-title">FCast update available</div>
<div id="update-button">Later</div> <div class="card-title-separator"></div>
<div id="progress-container">
<div id="update-spinner" class="lds-ring"><div></div><div></div><div></div><div></div></div> <div id="update-text">Do you wish to update now?</div>
<div id="progress-text"></div> <div id="update-button-container">
</div> --> <div id="update-button" class="button button-primary">Update</div>
<div id="restart-button" class="button button-primary">Restart</div>
<div id="update-later-button" class="button button-secondary">Later</div>
</div>
<div id="progress-bar">
<div id="progress-bar-progress"></div>
</div>
</div>
</div> </div>
<div id="detail-view"> <div id="detail-view" class="card">
<div id="manual-connection-info" class="non-selectable">Manual connection information</div> <div class="non-selectable card-title">Manual connection information</div>
<div id="manual-connection-info-separator"></div> <div class="card-title-separator"></div>
<div> <div>
<div id="ips">IPs</div><br /> <div id="ips">IPs</div><br />
<div>Port<br>46899 (TCP), 46898 (WS)</div> <div>Port<br>46899 (TCP), 46898 (WS)</div>

View file

@ -19,6 +19,70 @@ body, html {
user-select: none; user-select: none;
} }
.card {
display: flex;
flex-direction: column;
text-align: center;
background-color: rgba(20, 20, 20, 0.5);
padding: 25px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
}
.card-title {
font-weight: 700;
line-height: 24px;
margin: 10px;
}
.card-title-separator {
height: 1px;
background: #2E2E2E;
margin-top: 3px;
margin-bottom: 3px;
}
.button {
display: inline-block;
align-items: center;
justify-content: center;
padding: 16px;
gap: 6px;
flex: 1 0 0;
border-radius: 6px;
margin: 20px 10px;
cursor: pointer;
user-select: none;
}
.button-primary {
background: #008BD7;
}
.button-primary:hover {
background: #0D9DDF;
}
.button-primary:active {
background: #0069AA;
}
.button-secondary {
background: #3E3E3E;
}
.button-secondary:hover {
background: #555555;
}
.button-secondary:active {
background: #3E3E3E;
}
#ui-container { #ui-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -80,17 +144,6 @@ body, html {
padding: 25px; padding: 25px;
} }
#detail-view {
text-align: center;
background-color: rgba(20, 20, 20, 0.5);
padding: 25px;
border-radius: 10px;
border: 1px solid #2E2E2E;
scrollbar-width: thin;
overflow: auto;
}
#manual-connection-info { #manual-connection-info {
font-weight: 700; font-weight: 700;
line-height: 24px; line-height: 24px;
@ -118,41 +171,59 @@ body, html {
font-weight: bold; font-weight: bold;
} }
#update-dialog, #waiting-for-connection, #ips, #automatic-discovery { #waiting-for-connection, #ips, #automatic-discovery {
margin-top: 20px; margin-top: 20px;
} }
#update-text {
margin-top: 20px;
width: 320px;
}
#update-view {
display: none;
}
#restart-button {
display: none;
}
#spinner { #spinner {
padding: 20px; padding: 20px;
} }
#update-button { #update-button-container {
background: blue;
padding: 10px 28px;
margin-top: 20px;
cursor: pointer;
}
/* .button {
display: inline-block;
align-items: center;
justify-content: center;
min-width: 100px;
padding: 18px 16px;
gap: 6px;
flex: 1 0 0;
border-radius: 6px;
} */
#progress-container {
display: flex; display: flex;
align-items: center; flex-direction: row;
margin-top: 8px;
} }
#progress-text { #progress-bar {
margin-left: 8px; display: none;
width: 320px;
height: 40px;
margin-top: 20px;
border-radius: 50px;
border: 1px solid #4E4E4E;
background: linear-gradient(rgba(20, 20, 20, 0.5), rgba(80, 80, 80, 0.5));
/* background-size: cover; */
}
#progress-bar-progress {
width: 12%;
height: 40px;
border-radius: 50px;
background-image: linear-gradient(to bottom, #008BD7 35%, #0069AA);
transition: width .6s ease;
}
@keyframes progress-bar-stripes {
from {
background-position: 1rem 0;
}
to {
background-position: 0 0;
}
} }
#window-can-be-closed { #window-can-be-closed {