From 568c9724928a0d162c5db1dad5bdc561a2d6c53f Mon Sep 17 00:00:00 2001 From: Michael Hollister Date: Sun, 17 Nov 2024 23:12:24 -0600 Subject: [PATCH] New updater flow and UI --- receivers/electron/src/Main.ts | 89 ++++++++---- receivers/electron/src/Updater.ts | 174 ++++++++++++++---------- receivers/electron/src/main/Preload.ts | 6 + receivers/electron/src/main/Renderer.ts | 64 +++++++++ receivers/electron/src/main/index.html | 28 ++-- receivers/electron/src/main/style.css | 143 ++++++++++++++----- 6 files changed, 358 insertions(+), 146 deletions(-) diff --git a/receivers/electron/src/Main.ts b/receivers/electron/src/Main.ts index c7abead..15a73eb 100644 --- a/receivers/electron/src/Main.ts +++ b/receivers/electron/src/Main.ts @@ -53,41 +53,36 @@ export default class Main { { label: 'Check for updates', click: async () => { - if (!Updater.isDownloading) { - try { - if (await Updater.update()) { - const restartPrompt = await dialog.showMessageBox({ - type: 'info', - title: 'Update ready', - message: 'Update downloaded, restart now to apply the changes.', - buttons: ['Restart'], - defaultId: 0 - }); + if (Updater.updateDownloaded) { + Main.mainWindow.webContents.send("download-complete"); + return; + } - // Restart the app if the user clicks the 'Restart' button - if (restartPrompt.response === 0) { - Updater.restart(); - } - } else { - await dialog.showMessageBox({ - type: 'info', - title: 'Already up-to-date', - message: 'The application is already on the latest version.', - buttons: ['OK'], - defaultId: 0 - }); - } - } catch (err) { + try { + const updateAvailable = await Updater.checkForUpdates(); + + if (updateAvailable) { + Main.mainWindow.webContents.send("update-available"); + } + else { await dialog.showMessageBox({ - type: 'error', - title: 'Failed to update', - message: err, + type: 'info', + title: 'Already up-to-date', + message: 'The application is already on the latest version.', buttons: ['OK'], 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) => { 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 () => { const window = Main.playerWindow; if (!window) { @@ -214,6 +235,16 @@ export default class Main { if (Main.shouldOpenMainWindow) { 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 + }); + } } diff --git a/receivers/electron/src/Updater.ts b/receivers/electron/src/Updater.ts index 9548b09..3af032a 100644 --- a/receivers/electron/src/Updater.ts +++ b/receivers/electron/src/Updater.ts @@ -50,9 +50,17 @@ export class Updater { 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'; - 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 updateError: boolean = false; + public static updateDownloaded: boolean = false; + public static updateProgress: number = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any private static async fetchJSON(url: string): Promise { @@ -80,7 +88,15 @@ export class Updater { return new Promise((resolve, reject) => { const file = fs.createWriteStream(destination); https.get(url, (response) => { + const downloadSize = Number(response.headers['content-length']); + logger.info(`Update size: ${downloadSize} bytes`); response.pipe(file); + let downloadedBytes = 0; + + response.on('data', (chunk) => { + downloadedBytes += chunk.length; + Updater.updateProgress = downloadedBytes / downloadSize; + }); file.on('finish', () => { file.close(); resolve(); @@ -169,19 +185,23 @@ export class Updater { } public static restart() { - 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); + 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); + } - Updater.relaunch(updateBinPath); return; } public static isUpdating(): boolean { try { const updateInfo: UpdateInfo = JSON.parse(fs.readFileSync(Updater.updateMetadataPath, 'utf8')); - // TODO: In case of error inform user + Updater.updateError = true; return updateInfo.updateState !== 'error'; } catch { @@ -190,12 +210,12 @@ export class Updater { } 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 + if (Updater.localPackageJson === null) { + Updater.localPackageJson = JSON.parse(fs.readFileSync(path.join(Updater.appPath, './package.json'), 'utf8')); + Updater.localPackageJson.channelVersion = Updater.localPackageJson.channelVersion ? Updater.localPackageJson.channelVersion : 0 } - return Updater.channelVersion; + return Updater.localPackageJson.channelVersion; } public static async processUpdate(): Promise { @@ -215,9 +235,6 @@ export class Updater { await Updater.applyUpdate(src, updateInfo.installPath); updateInfo.updateState = UpdateState.Cleanup; fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo)); - - Updater.relaunch(installBinPath); - return; } catch (err) { logger.error('Error while applying update...'); @@ -226,10 +243,9 @@ export class Updater { updateInfo.updateState = UpdateState.Error; updateInfo.error = JSON.stringify(err); fs.writeFileSync(Updater.updateMetadataPath, JSON.stringify(updateInfo)); - log4js.shutdown(); - app.exit(); } + Updater.relaunch(installBinPath); return; } @@ -272,8 +288,44 @@ export class Updater { } } - public static async update(): Promise { - logger.info('Updater invoked'); + public static async checkForUpdates(): Promise { + 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 { try { fs.accessSync(Updater.updateDataPath, fs.constants.F_OK); } @@ -282,70 +334,50 @@ export class Updater { fs.mkdirSync(Updater.updateDataPath); } - 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; + 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'); - if (updaterSettings === null) { - updaterSettings = { - 'channel': localPackage.channel, - } + const destination = path.join(Updater.updateDataPath, file); + logger.info(`Downloading '${fileInfo.url}' to '${destination}'.`); + Updater.isDownloading = true; + 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 - 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 }); + // 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; - 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(); + logger.info('Extraction complete.'); + const updateInfo: UpdateInfo = { + updateState: UpdateState.Copy, + installPath: Updater.installPath, + tempPath: path.dirname(destination), + currentVersion: Updater.releasesJson.currentVersion, + downloadFile: file, + }; - 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; - } - - // 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; - } + 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 check for updates: ${err}`); - throw 'Failed to check for updates. Please try again later or visit https://fcast.org for updates.'; + logger.error(`Failed to download update: ${err}`); + throw 'Failed to download update. Please try again later or visit https://fcast.org to download.'; } - - return false; } } diff --git a/receivers/electron/src/main/Preload.ts b/receivers/electron/src/main/Preload.ts index 017c2ae..cd8ea7b 100644 --- a/receivers/electron/src/main/Preload.ts +++ b/receivers/electron/src/main/Preload.ts @@ -6,6 +6,12 @@ ipcRenderer.on("device-info", (_event, value) => { }) contextBridge.exposeInMainWorld('electronAPI', { + updaterProgress: () => ipcRenderer.invoke('updater-progress'), 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, }); diff --git a/receivers/electron/src/main/Renderer.ts b/receivers/electron/src/main/Renderer.ts index eb0b7ce..11d3865 100644 --- a/receivers/electron/src/main/Renderer.ts +++ b/receivers/electron/src/main/Renderer.ts @@ -1,5 +1,15 @@ 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); if(window.electronAPI.getDeviceInfo()) { @@ -45,3 +55,57 @@ function renderIPsAndQRCode() { 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(); }; diff --git a/receivers/electron/src/main/index.html b/receivers/electron/src/main/index.html index 103516a..2f51074 100644 --- a/receivers/electron/src/main/index.html +++ b/receivers/electron/src/main/index.html @@ -25,17 +25,25 @@
- +
+
FCast update available
+
+ +
Do you wish to update now?
+
+
Update
+
Restart
+
Later
+
+ +
+
+
+
-
-
Manual connection information
-
+
+
Manual connection information
+
IPs

Port
46899 (TCP), 46898 (WS)
diff --git a/receivers/electron/src/main/style.css b/receivers/electron/src/main/style.css index 97287df..134f951 100644 --- a/receivers/electron/src/main/style.css +++ b/receivers/electron/src/main/style.css @@ -19,6 +19,70 @@ body, html { 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 { display: flex; flex-direction: column; @@ -80,17 +144,6 @@ body, html { 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 { font-weight: 700; line-height: 24px; @@ -118,41 +171,59 @@ body, html { font-weight: bold; } -#update-dialog, #waiting-for-connection, #ips, #automatic-discovery { +#waiting-for-connection, #ips, #automatic-discovery { margin-top: 20px; } +#update-text { + margin-top: 20px; + width: 320px; +} + +#update-view { + display: none; +} + +#restart-button { + display: none; +} + #spinner { padding: 20px; } -#update-button { - 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 { +#update-button-container { display: flex; - align-items: center; - margin-top: 8px; + flex-direction: row; } -#progress-text { - margin-left: 8px; +#progress-bar { + 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 {