From 824a8540ff7891e33af3f1e677f03a055e86e52a Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Tue, 6 Aug 2024 11:05:26 -0700 Subject: [PATCH] Add banner for app updates, clean up updater logic (#200) This improves the app updater so that it doesn't rely on unreliable system notifications. Now, a banner in the tab bar will display when an update is available. Clicking this will prompt the user to restart the app and complete the installation. This also updates the tab bar to move to the smaller tab size earlier so we don't need to make the tab bar scrollable as much. ![image](https://github.com/user-attachments/assets/79e24617-d609-4554-bdb2-979f810a9b66) --- emain/emain.ts | 177 +------------------------------- emain/preload.ts | 3 + emain/updater.ts | 191 +++++++++++++++++++++++++++++++++++ frontend/app/store/global.ts | 10 ++ frontend/app/tab/tabbar.less | 16 ++- frontend/app/tab/tabbar.tsx | 27 ++++- frontend/types/custom.d.ts | 6 ++ 7 files changed, 253 insertions(+), 177 deletions(-) create mode 100644 emain/updater.ts diff --git a/emain/emain.ts b/emain/emain.ts index 9bd7eb0df..b0c77329f 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -1,10 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { initGlobal } from "@/app/store/global"; -import { WaveDevVarName, WaveDevViteVarName } from "@/util/isdev"; import * as electron from "electron"; -import { autoUpdater } from "electron-updater"; import fs from "fs"; import * as child_process from "node:child_process"; import os from "os"; @@ -14,10 +11,13 @@ import { sprintf } from "sprintf-js"; import { debounce } from "throttle-debounce"; import * as util from "util"; import winston from "winston"; +import { initGlobal } from "../frontend/app/store/global"; import * as services from "../frontend/app/store/services"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; +import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget } from "../frontend/util/util"; +import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; @@ -624,12 +624,7 @@ function makeAppMenu() { { label: "Check for Updates", click: () => { - const checkingNotification = new electron.Notification({ - title: "Wave Terminal", - body: "Checking for updates.", - }); - checkingNotification.show(); - fireAndForget(() => checkForUpdates()); + fireAndForget(() => updater?.checkForUpdates(true)); }, }, { @@ -760,170 +755,6 @@ process.on("uncaughtException", (error) => { electronApp.quit(); }); -// ====== AUTO-UPDATER ====== // -let autoUpdateLock = false; -let autoUpdateInterval: NodeJS.Timeout | null = null; -let availableUpdateReleaseName: string | null = null; -let availableUpdateReleaseNotes: string | null = null; -let appUpdateStatus = "unavailable"; -let lastUpdateCheck: Date = null; - -/** - * Sets the app update status and sends it to the main window - * @param status The AppUpdateStatus to set, either "ready" or "unavailable" - */ -function setAppUpdateStatus(status: string) { - appUpdateStatus = status; - electron.BrowserWindow.getAllWindows().forEach((window) => { - window.webContents.send("app-update-status", appUpdateStatus); - }); -} - -/** - * Checks if an hour has passed since the last update check, and if so, checks for updates using the `autoUpdater` object - */ -async function checkForUpdates() { - const autoUpdateOpts = (await services.FileService.GetSettingsConfig()).autoupdate; - - if (!autoUpdateOpts.enabled) { - console.log("Auto update is disabled in settings. Removing the auto update interval."); - clearInterval(autoUpdateInterval); - autoUpdateInterval = null; - return; - } - const now = new Date(); - if (!lastUpdateCheck || Math.abs(now.getTime() - lastUpdateCheck.getTime()) > autoUpdateOpts.intervalms) { - fireAndForget(() => autoUpdater.checkForUpdates()); - lastUpdateCheck = now; - } -} - -/** - * Initializes the updater and sets up event listeners - */ -function initUpdater() { - if (isDev) { - console.log("skipping auto-updater in dev mode"); - return null; - } - - setAppUpdateStatus("unavailable"); - - autoUpdater.removeAllListeners(); - - autoUpdater.on("error", (err) => { - console.log("updater error"); - console.log(err); - }); - - autoUpdater.on("checking-for-update", () => { - console.log("checking-for-update"); - }); - - autoUpdater.on("update-available", () => { - console.log("update-available; downloading..."); - }); - - autoUpdater.on("update-not-available", () => { - console.log("update-not-available"); - }); - - autoUpdater.on("update-downloaded", (event) => { - console.log("update-downloaded", [event]); - availableUpdateReleaseName = event.releaseName; - availableUpdateReleaseNotes = event.releaseNotes as string | null; - - // Display the update banner and create a system notification - setAppUpdateStatus("ready"); - const updateNotification = new electron.Notification({ - title: "Wave Terminal", - body: "A new version of Wave Terminal is ready to install.", - }); - updateNotification.on("click", () => { - fireAndForget(() => installAppUpdate()); - }); - updateNotification.show(); - }); -} - -/** - * Starts the auto update check interval. - * @returns The timeout object for the auto update checker. - */ -function startAutoUpdateInterval(): NodeJS.Timeout { - // check for updates right away and keep checking later - checkForUpdates(); - return setInterval(() => { - checkForUpdates(); - }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if an hour has passed. -} - -/** - * Prompts the user to install the downloaded application update and restarts the application - */ -async function installAppUpdate() { - const dialogOpts: Electron.MessageBoxOptions = { - type: "info", - buttons: ["Restart", "Later"], - title: "Application Update", - message: process.platform === "win32" ? availableUpdateReleaseNotes : availableUpdateReleaseName, - detail: "A new version has been downloaded. Restart the application to apply the updates.", - }; - - const allWindows = electron.BrowserWindow.getAllWindows(); - if (allWindows.length > 0) { - await electron.dialog - .showMessageBox(electron.BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) - .then(({ response }) => { - if (response === 0) autoUpdater.quitAndInstall(); - }); - } -} - -electron.ipcMain.on("install-app-update", () => fireAndForget(() => installAppUpdate())); -electron.ipcMain.on("get-app-update-status", (event) => { - event.returnValue = appUpdateStatus; -}); - -/** - * Configures the auto-updater based on the user's preference - * @param enabled Whether the auto-updater should be enabled - */ -async function configureAutoUpdater() { - // simple lock to prevent multiple auto-update configuration attempts, this should be very rare - if (autoUpdateLock) { - console.log("auto-update configuration already in progress, skipping"); - return; - } - - autoUpdateLock = true; - - const autoUpdateEnabled = (await services.FileService.GetSettingsConfig()).autoupdate.enabled; - - try { - console.log("Configuring updater"); - initUpdater(); - } catch (e) { - console.warn("error configuring updater", e.toString()); - } - - if (autoUpdateEnabled && autoUpdateInterval == null) { - lastUpdateCheck = null; - try { - console.log("configuring auto update interval"); - autoUpdateInterval = startAutoUpdateInterval(); - } catch (e) { - console.log("error configuring auto update interval", e.toString()); - } - } else if (!autoUpdateEnabled && autoUpdateInterval != null) { - console.log("disabling auto updater"); - clearInterval(autoUpdateInterval); - autoUpdateInterval = null; - } - autoUpdateLock = false; -} -// ====== AUTO-UPDATER ====== // - async function relaunchBrowserWindows() { globalIsRelaunching = true; const windows = electron.BrowserWindow.getAllWindows(); diff --git a/emain/preload.ts b/emain/preload.ts index 875b5f0bd..674b3bd32 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -21,6 +21,9 @@ contextBridge.exposeInMainWorld("api", { getEnv: (varName) => ipcRenderer.sendSync("getEnv", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), + onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), + getUpdaterStatus: () => ipcRenderer.sendSync("get-app-update-status"), + installAppUpdate: () => ipcRenderer.send("install-app-update"), }); // Custom event for "new-window" diff --git a/emain/updater.ts b/emain/updater.ts new file mode 100644 index 000000000..6f6f17afd --- /dev/null +++ b/emain/updater.ts @@ -0,0 +1,191 @@ +import * as electron from "electron"; +import { autoUpdater } from "electron-updater"; +import * as services from "../frontend/app/store/services"; +import { isDev } from "../frontend/util/isdev"; +import { fireAndForget } from "../frontend/util/util"; + +let autoUpdateLock = false; + +export let updater: Updater; + +export class Updater { + interval: NodeJS.Timeout | null; + availableUpdateReleaseName: string | null; + availableUpdateReleaseNotes: string | null; + _status: UpdaterStatus; + lastUpdateCheck: Date; + + constructor() { + if (isDev) { + console.log("skipping auto-updater in dev mode"); + return null; + } + + autoUpdater.removeAllListeners(); + + autoUpdater.on("error", (err) => { + console.log("updater error"); + console.log(err); + this.status = "error"; + }); + + autoUpdater.on("checking-for-update", () => { + console.log("checking-for-update"); + this.status = "checking"; + }); + + autoUpdater.on("update-available", () => { + console.log("update-available; downloading..."); + }); + + autoUpdater.on("update-not-available", () => { + console.log("update-not-available"); + }); + + autoUpdater.on("update-downloaded", (event) => { + console.log("update-downloaded", [event]); + this.availableUpdateReleaseName = event.releaseName; + this.availableUpdateReleaseNotes = event.releaseNotes as string | null; + + // Display the update banner and create a system notification + this.status = "ready"; + const updateNotification = new electron.Notification({ + title: "Wave Terminal", + body: "A new version of Wave Terminal is ready to install.", + }); + updateNotification.on("click", () => { + fireAndForget(() => this.installAppUpdate()); + }); + updateNotification.show(); + }); + + this._status = "up-to-date"; + this.lastUpdateCheck = new Date(0); + this.interval = null; + this.availableUpdateReleaseName = null; + } + + get status(): UpdaterStatus { + return this._status; + } + + /** + * Sets the app update status and sends it to the main window + * @param value The AppUpdateStatus to set, either "ready" or "unavailable" + */ + private set status(value: UpdaterStatus) { + this._status = value; + electron.BrowserWindow.getAllWindows().forEach((window) => { + window.webContents.send("app-update-status", value); + }); + } + + /** + * Starts the auto update check interval. + * @returns The timeout object for the auto update checker. + */ + startAutoUpdateInterval(): NodeJS.Timeout { + // check for updates right away and keep checking later + this.checkForUpdates(false); + return setInterval(() => { + this.checkForUpdates(false); + }, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if an hour has passed. + } + + /** + * Checks if an hour has passed since the last update check, and if so, checks for updates using the `autoUpdater` object + * @param userInput Whether the user is requesting this. If so, an alert will report the result of the check. + */ + async checkForUpdates(userInput: boolean) { + const autoUpdateOpts = (await services.FileService.GetSettingsConfig()).autoupdate; + + if (!autoUpdateOpts.enabled) { + console.log("Auto update is disabled in settings. Removing the auto update interval."); + clearInterval(this.interval); + this.interval = null; + return; + } + const now = new Date(); + if ( + !this.lastUpdateCheck || + Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > autoUpdateOpts.intervalms + ) { + fireAndForget(() => + autoUpdater.checkForUpdates().then(() => { + if (userInput && this.status === "up-to-date") { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + message: "There are currently no updates available.", + }; + electron.dialog.showMessageBox(electron.BrowserWindow.getFocusedWindow(), dialogOpts); + } + }) + ); + this.lastUpdateCheck = now; + } + } + + /** + * Prompts the user to install the downloaded application update and restarts the application + */ + async installAppUpdate() { + const dialogOpts: Electron.MessageBoxOptions = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: process.platform === "win32" ? this.availableUpdateReleaseNotes : this.availableUpdateReleaseName, + detail: "A new version has been downloaded. Restart the application to apply the updates.", + }; + + const allWindows = electron.BrowserWindow.getAllWindows(); + if (allWindows.length > 0) { + await electron.dialog + .showMessageBox(electron.BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts) + .then(({ response }) => { + if (response === 0) autoUpdater.quitAndInstall(); + }); + } + } +} + +electron.ipcMain.on("install-app-update", () => fireAndForget(() => updater?.installAppUpdate())); +electron.ipcMain.on("get-app-update-status", (event) => { + event.returnValue = updater?.status; +}); + +/** + * Configures the auto-updater based on the user's preference + * @param enabled Whether the auto-updater should be enabled + */ +export async function configureAutoUpdater() { + // simple lock to prevent multiple auto-update configuration attempts, this should be very rare + if (autoUpdateLock) { + console.log("auto-update configuration already in progress, skipping"); + return; + } + + autoUpdateLock = true; + + const autoUpdateEnabled = (await services.FileService.GetSettingsConfig()).autoupdate.enabled; + + try { + console.log("Configuring updater"); + updater = new Updater(); + } catch (e) { + console.warn("error configuring updater", e.toString()); + } + + if (autoUpdateEnabled && updater?.interval == null) { + try { + console.log("configuring auto update interval"); + updater?.startAutoUpdateInterval(); + } catch (e) { + console.log("error configuring auto update interval", e.toString()); + } + } else if (!autoUpdateEnabled && updater?.interval != null) { + console.log("disabling auto updater"); + clearInterval(updater.interval); + updater = null; + } + autoUpdateLock = false; +} diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 330fc08c8..94d8f4267 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -104,6 +104,15 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { return windowData.activetabid; }); const cmdShiftDelayAtom = jotai.atom(false); + const updateStatusAtom = jotai.atom("up-to-date") as jotai.PrimitiveAtom; + try { + getApi().onUpdaterStatusChange((status) => { + console.log("updater status change", status); + globalStore.set(updateStatusAtom, status); + }); + } catch (_) { + // do nothing + } atoms = { // initialized in wave.ts (will not be null inside of application) windowId: windowIdAtom, @@ -117,6 +126,7 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) { activeTabId: activeTabIdAtom, isFullScreen: isFullScreenAtom, cmdShiftDelayAtom: cmdShiftDelayAtom, + updaterStatusAtom: updateStatusAtom, }; } diff --git a/frontend/app/tab/tabbar.less b/frontend/app/tab/tabbar.less index ef2d70bc8..220796686 100644 --- a/frontend/app/tab/tabbar.less +++ b/frontend/app/tab/tabbar.less @@ -70,7 +70,6 @@ &.right { flex-grow: 1; - min-width: 74px; } } @@ -85,4 +84,19 @@ --os-handle-interactive-area-offset: 0px; --os-handle-border-radius: 2px; } + + .update-available-label { + height: 80%; + opacity: 0.7; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + margin: auto 4px; + color: black; + background-color: var(--accent-color); + padding: 0 5px; + border-radius: var(--block-border-radius); + flex: 0 0 fit-content; + } } diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index ee4af08d0..47991ea42 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { WindowDrag } from "@/element/windowdrag"; -import { atoms, isDev } from "@/store/global"; +import { atoms, getApi, isDev } from "@/store/global"; import * as services from "@/store/services"; import { deleteLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom"; import { useAtomValue } from "jotai"; @@ -70,12 +70,15 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const draggerLeftRef = useRef(null); const tabWidthRef = useRef(TAB_DEFAULT_WIDTH); const scrollableRef = useRef(false); + const updateStatusLabelRef = useRef(null); const windowData = useAtomValue(atoms.waveWindow); const { activetabid } = windowData; const isFullScreen = useAtomValue(atoms.isFullScreen); + const appUpdateStatus = useAtomValue(atoms.updaterStatusAtom); + let prevDelta: number; let prevDragDirection: string; @@ -125,7 +128,9 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width; const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width; const addBtnWidth = addBtnRef.current.getBoundingClientRect().width; - const spaceForTabs = tabbarWrapperWidth - (windowDragLeftWidth + DRAGGER_RIGHT_MIN_WIDTH + addBtnWidth); + const updateStatusLabelWidth = updateStatusLabelRef.current?.getBoundingClientRect().width ?? 0; + const spaceForTabs = + tabbarWrapperWidth - (windowDragLeftWidth + DRAGGER_RIGHT_MIN_WIDTH + addBtnWidth + updateStatusLabelWidth); const numberOfTabs = tabIds.length; const totalDefaultTabWidth = numberOfTabs * TAB_DEFAULT_WIDTH; @@ -137,7 +142,9 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { console.log("spaceForTabs", spaceForTabs, minTotalTabWidth); - if (minTotalTabWidth > spaceForTabs) { + if (spaceForTabs < totalDefaultTabWidth && spaceForTabs > minTotalTabWidth) { + newTabWidth = TAB_MIN_WIDTH; + } else if (minTotalTabWidth > spaceForTabs) { // Case where tabs cannot shrink further, make the tab bar scrollable newTabWidth = TAB_MIN_WIDTH; newScrollable = true; @@ -487,6 +494,19 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => { ); } + function onUpdateAvailableClick() { + getApi().installAppUpdate(); + } + + let updateAvailableLabel: React.ReactNode = null; + if (appUpdateStatus === "ready") { + updateAvailableLabel = ( +
+ Update Available: Click to Install +
+ ); + } + return (
@@ -518,6 +538,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
+ {updateAvailableLabel} ); }); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 508df4989..e26cbab2d 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -17,6 +17,7 @@ declare global { activeTabId: jotai.Atom; // derrived from windowDataAtom isFullScreen: jotai.PrimitiveAtom; cmdShiftDelayAtom: jotai.PrimitiveAtom; + updaterStatusAtom: jotai.PrimitiveAtom; }; type TabLayoutData = { @@ -44,6 +45,9 @@ declare global { downloadFile: (path: string) => void; openExternal: (url: string) => void; onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; + onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; + getUpdaterStatus: () => UpdaterStatus; + installAppUpdate: () => void; }; type ElectronContextMenuItem = { @@ -178,6 +182,8 @@ declare global { giveFocus?: () => boolean; } + type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error"; + // jotai doesn't export this type :/ type Loadable = { state: "loading" } | { state: "hasData"; data: T } | { state: "hasError"; error: unknown }; }