waveterm/emain/updater.ts

242 lines
9.0 KiB
TypeScript
Raw Normal View History

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { RpcApi } from "@/app/store/wshclientapi";
2024-09-18 21:06:34 +02:00
import { BrowserWindow, dialog, ipcMain, Notification } from "electron";
import { autoUpdater } from "electron-updater";
import { readFileSync } from "fs";
import path from "path";
import YAML from "yaml";
2024-09-18 21:06:34 +02:00
import { FileService } from "../frontend/app/store/services";
import { isDev } from "../frontend/util/isdev";
import { fireAndForget } from "../frontend/util/util";
import { ElectronWshClient } from "./emain-wsh";
export let updater: Updater;
function getUpdateChannel(settings: SettingsType): string {
const updaterConfigPath = path.join(process.resourcesPath!, "app-update.yml");
const updaterConfig = YAML.parse(readFileSync(updaterConfigPath, { encoding: "utf8" }).toString());
console.log("Updater config from binary:", updaterConfig);
const updaterChannel: string = updaterConfig.channel ?? "latest";
const settingsChannel = settings["autoupdate:channel"];
let retVal = settingsChannel;
// If the user setting doesn't exist yet, set it to the value of the updater config.
// If the user was previously on the `latest` channel and has downloaded a `beta` version, update their configured channel to `beta` to prevent downgrading.
if (!settingsChannel || (settingsChannel == "latest" && updaterChannel == "beta")) {
console.log("Update channel setting does not exist, setting to value from updater config.");
RpcApi.SetConfigCommand(ElectronWshClient, { "autoupdate:channel": updaterChannel });
retVal = updaterChannel;
}
console.log("Update channel:", retVal);
return retVal;
}
export class Updater {
autoCheckInterval: NodeJS.Timeout | null;
intervalms: number;
autoCheckEnabled: boolean;
availableUpdateReleaseName: string | null;
availableUpdateReleaseNotes: string | null;
private _status: UpdaterStatus;
lastUpdateCheck: Date;
2024-09-04 04:16:04 +02:00
constructor(settings: SettingsType) {
this.intervalms = settings["autoupdate:intervalms"];
console.log("Update check interval in milliseconds:", this.intervalms);
2024-09-04 04:16:04 +02:00
this.autoCheckEnabled = settings["autoupdate:enabled"];
console.log("Update check enabled:", this.autoCheckEnabled);
this._status = "up-to-date";
this.lastUpdateCheck = new Date(0);
this.autoCheckInterval = null;
this.availableUpdateReleaseName = null;
2024-09-04 04:16:04 +02:00
autoUpdater.autoInstallOnAppQuit = settings["autoupdate:installonquit"];
console.log("Install update on quit:", settings["autoupdate:installonquit"]);
// Only update the release channel if it's specified, otherwise use the one configured in the updater.
autoUpdater.channel = getUpdateChannel(settings);
Add release channels (#385) ## New release flow 1. Run "Bump Version" workflow with the desired version bump and the prerelease flag set to `true`. This will push a new version bump to the target branch and create a new git tag. - See below for more info on how the version bumping works. 2. A new "Build Helper" workflow run will kick off automatically for the new tag. Once it is complete, test the new build locally by downloading with the [download script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/download-staged-artifact.sh). 3. Release the new build using the [publish script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/publish-from-staging.sh). This will trigger electron-updater to distribute the package to beta users. 4. Run "Bump Version" again with a release bump (either `major`, `minor`, or `patch`) and the prerelease flag set to `false`. 6. Release the new build to all channels using the [publish script](https://github.com/wavetermdev/thenextwave/blob/main/scripts/artifacts/publish-from-staging.sh). This will trigger electron-updater to distribute the package to all users. ## Change Summary Creates a new "Bump Version" workflow to manage versioning and tag creation. Build Helper is now automated. ### Version bumps Updates the `version.cjs` script so that an argument can be passed to trigger a version bump. Under the hood, this utilizes NPM's `semver` package. If arguments are present, the version will be bumped. If only a single argument is given, the following are valid inputs: - `none`: No-op. - `patch`: Bumps the patch version. - `minor`: Bumps the minor version. - `major`: Bumps the major version. - '1', 'true': Bumps the prerelease version. If two arguments are given, the first argument must be either `none`, `patch`, `minor`, or `major`. The second argument must be `1` or `true` to bump the prerelease version. ### electron-builder We are now using the release channels support in electron-builder. This will automatically detect the channel being built based on the package version to determine which channel update files need to be generated. See [here](https://www.electron.build/tutorials/release-using-channels.html) for more information. ### Github Actions #### Bump Version This adds a new "Bump Version" workflow for managing versioning and queuing new builds. When run, this workflow will bump the version, create a new tag, and push the changes to the target branch. There is a new dropdown when queuing the "Bump Version" workflow to select what kind of version bump to perform. A bump must always be performed when running a new build to ensure consistency. I had to create a GitHub App to grant write permissions to our main branch for the version bump commits. I've made a separate workflow file to manage the version bump commits, which should help prevent tampering. Thanks to using the GitHub API directly, I am able to make these commits signed! #### Build Helper Build Helper is now triggered when new tags are created, rather than being triggered automatically. This ensures we're always creating artifacts from known checkpoints. ### Settings Adds a new `autoupdate:channel` configuration to the settings file. If unset, the default from the artifact will be used (should correspond to the channel of the artifact when downloaded). ## Future Work I want to add a release workflow that will automatically copy over the corresponding version artifacts to the release bucket when a new GitHub Release is created. I also want to separate versions into separate subdirectories in the release bucket so we can clean them up more-easily. --------- Co-authored-by: wave-builder <builds@commandline.dev> Co-authored-by: wave-builder[bot] <181805596+wave-builder[bot]@users.noreply.github.com>
2024-09-17 22:10:35 +02:00
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...");
2024-09-18 23:30:45 +02:00
this.status = "downloading";
});
autoUpdater.on("update-not-available", () => {
console.log("update-not-available");
2024-09-18 23:30:45 +02:00
this.status = "up-to-date";
});
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";
2024-09-18 21:06:34 +02:00
const updateNotification = new Notification({
title: "Wave Terminal",
body: "A new version of Wave Terminal is ready to install.",
});
updateNotification.on("click", () => {
fireAndForget(() => this.promptToInstallUpdate());
});
updateNotification.show();
});
}
/**
* The status of the Updater.
*/
get status(): UpdaterStatus {
return this._status;
}
private set status(value: UpdaterStatus) {
this._status = value;
2024-09-18 21:06:34 +02:00
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send("app-update-status", value);
});
}
/**
* Check for updates and start the background update check, if configured.
*/
async start() {
if (this.autoCheckEnabled) {
console.log("starting updater");
this.autoCheckInterval = setInterval(() => {
fireAndForget(() => this.checkForUpdates(false));
}, 600000); // intervals are unreliable when an app is suspended so we will check every 10 mins if the interval has passed.
await this.checkForUpdates(false);
}
}
/**
* Stop the background update check, if configured.
*/
stop() {
console.log("stopping updater");
if (this.autoCheckInterval) {
clearInterval(this.autoCheckInterval);
this.autoCheckInterval = null;
}
}
/**
2024-08-07 01:09:24 +02:00
* Checks if the configured interval time 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 now = new Date();
// Run an update check always if the user requests it, otherwise only if there's an active update check interval and enough time has elapsed.
if (
userInput ||
(this.autoCheckInterval &&
(!this.lastUpdateCheck || Math.abs(now.getTime() - this.lastUpdateCheck.getTime()) > this.intervalms))
) {
2024-08-06 23:51:06 +02:00
const result = await autoUpdater.checkForUpdates();
// If the user requested this check and we do not have an available update, let them know with a popup dialog. No need to tell them if there is an update, because we show a banner once the update is ready to install.
2024-08-06 23:51:06 +02:00
if (userInput && !result.downloadPromise) {
const dialogOpts: Electron.MessageBoxOptions = {
type: "info",
message: "There are currently no updates available.",
};
2024-09-18 21:06:34 +02:00
dialog.showMessageBox(BrowserWindow.getFocusedWindow(), dialogOpts);
2024-08-06 23:51:06 +02:00
}
// Only update the last check time if this is an automatic check. This ensures the interval remains consistent.
if (!userInput) this.lastUpdateCheck = now;
}
}
/**
* Prompts the user to install the downloaded application update and restarts the application
*/
async promptToInstallUpdate() {
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.",
};
2024-09-18 21:06:34 +02:00
const allWindows = BrowserWindow.getAllWindows();
if (allWindows.length > 0) {
2024-09-18 21:06:34 +02:00
await dialog
.showMessageBox(BrowserWindow.getFocusedWindow() ?? allWindows[0], dialogOpts)
.then(({ response }) => {
if (response === 0) {
this.installUpdate();
}
});
}
}
/**
* Restarts the app and installs an update if it is available.
*/
installUpdate() {
if (this.status == "ready") {
this.status = "installing";
autoUpdater.quitAndInstall();
}
}
}
2024-09-18 21:06:34 +02:00
ipcMain.on("install-app-update", () => fireAndForget(() => updater?.promptToInstallUpdate()));
ipcMain.on("get-app-update-status", (event) => {
event.returnValue = updater?.status;
});
ipcMain.on("get-updater-channel", (event) => {
event.returnValue = isDev() ? "dev" : (autoUpdater.channel ?? "latest");
});
let autoUpdateLock = false;
/**
* Configures the auto-updater based on the user's preference
*/
export async function configureAutoUpdater() {
2024-08-06 23:25:30 +02:00
if (isDev()) {
console.log("skipping auto-updater in dev mode");
return;
2024-08-06 23:25:30 +02:00
}
// 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;
try {
console.log("Configuring updater");
2024-09-18 21:06:34 +02:00
const settings = (await FileService.GetFullConfig()).settings;
2024-09-04 04:16:04 +02:00
updater = new Updater(settings);
await updater.start();
} catch (e) {
console.warn("error configuring updater", e.toString());
}
autoUpdateLock = false;
}