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)
This commit is contained in:
Evan Simkowitz 2024-08-06 11:05:26 -07:00 committed by GitHub
parent 11db01ebde
commit 824a8540ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 253 additions and 177 deletions

View File

@ -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();

View File

@ -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"

191
emain/updater.ts Normal file
View File

@ -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;
}

View File

@ -104,6 +104,15 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
return windowData.activetabid;
});
const cmdShiftDelayAtom = jotai.atom(false);
const updateStatusAtom = jotai.atom<UpdaterStatus>("up-to-date") as jotai.PrimitiveAtom<UpdaterStatus>;
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,
};
}

View File

@ -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;
}
}

View File

@ -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<HTMLDivElement>(null);
const tabWidthRef = useRef<number>(TAB_DEFAULT_WIDTH);
const scrollableRef = useRef<boolean>(false);
const updateStatusLabelRef = useRef<HTMLDivElement>(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 = (
<div ref={updateStatusLabelRef} className="update-available-label" onClick={onUpdateAvailableClick}>
Update Available: Click to Install
</div>
);
}
return (
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
<WindowDrag ref={draggerLeftRef} className="left" />
@ -518,6 +538,7 @@ const TabBar = React.memo(({ workspace }: TabBarProps) => {
<i className="fa fa-solid fa-plus fa-fw" />
</div>
<WindowDrag ref={draggerRightRef} className="right" />
{updateAvailableLabel}
</div>
);
});

View File

@ -17,6 +17,7 @@ declare global {
activeTabId: jotai.Atom<string>; // derrived from windowDataAtom
isFullScreen: jotai.PrimitiveAtom<boolean>;
cmdShiftDelayAtom: jotai.PrimitiveAtom<boolean>;
updaterStatusAtom: jotai.PrimitiveAtom<UpdaterStatus>;
};
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<T> = { state: "loading" } | { state: "hasData"; data: T } | { state: "hasError"; error: unknown };
}