Enable automatic updates on macOS using Electron's autoUpdater (#342)

* clean up emain

* add restart on disable auto update

* save work

* add zip

* save

* fix zip command

* make build-universal more generic

* clean up script

* fix autoupdate config

* update feed

* fix update feed path

* switch to custom update flow

* show notification

* remove enum

* test change

* debug sidebar

* save work

* remove weird import

* remove listeners if present

* save debug

* fixed banner

* fix sending of appupdatestatus, add comments

* Change to primary feed

* more comments, less debugs

* rename to app update

* code cleanup, add fireAndForget

* update paths for objects
This commit is contained in:
Evan Simkowitz 2024-02-27 19:42:29 -08:00 committed by GitHub
parent bdfb80e62f
commit 98385b1e0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 415 additions and 127 deletions

View File

@ -0,0 +1,86 @@
#!/bin/bash
# This script is used to upload signed and notarized releases to S3 and update the Electron auto-update release feeds.
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
BUILDS_DIR=$SCRIPT_DIR/builds
TEMP2_DIR=$SCRIPT_DIR/temp2
MAIN_RELEASE_PATH="dl.waveterm.dev/build"
AUTOUPDATE_RELEASE_PATH="dl.waveterm.dev/autoupdate"
# Copy the builds to the temp2 directory
echo "Copying builds to temp2"
rm -rf $TEMP2_DIR
mkdir -p $TEMP2_DIR
cp -r $BUILDS_DIR/* $TEMP2_DIR
UVERSION=$(cat $TEMP2_DIR/version.txt)
if [ -z "$UVERSION" ]; then
echo "version.txt is empty"
exit 1
fi
# Find the DMG file
echo "Finding DMG"
DMG=$(find $TEMP2_DIR -type f -iname "*.dmg")
# Ensure there is only one
NUM_DMGS=$(echo $DMG | wc -l)
if [ "0" -eq "$NUM_DMGS" ]; then
echo "no DMG found in $TEMP2_DIR"
exit 1
elif [ "1" -lt "$NUM_DMGS" ]; then
echo "multiple DMGs found in $TEMP2_DIR"
exit 1
fi
# Find the Mac zip
echo "Finding Mac zip"
MAC_ZIP=$(find $TEMP2_DIR -type f -iname "*mac*.zip")
# Ensure there is only one
NUM_MAC_ZIPS=$(echo $MAC_ZIP | wc -l)
if [ "0" -eq "$NUM_MAC_ZIPS" ]; then
echo "no Mac zip found in $TEMP2_DIR"
exit 1
elif [ "1" -lt "$NUM_MAC_ZIPS" ]; then
echo "multiple Mac zips found in $TEMP2_DIR"
exit 1
fi
# Find the Linux zips
echo "Finding Linux zips"
LINUX_ZIPS=$(find $TEMP2_DIR -type f -iname "*linux*.zip")
# Ensure there is at least one
NUM_LINUX_ZIPS=$(echo $LINUX_ZIPS | wc -l)
if [ "0" -eq "$NUM_LINUX_ZIPS" ]; then
echo "no Linux zips found in $TEMP2_DIR"
exit 1
fi
# Upload the DMG
echo "Uploading DMG"
DMG_NAME=$(basename $DMG)
aws s3 cp $DMG s3:/$MAIN_RELEASE_PATH/$DMG_NAME
# Upload the Linux zips
echo "Uploading Linux zips"
for LINUX_ZIP in $LINUX_ZIPS; do
LINUX_ZIP_NAME=$(basename $LINUX_ZIP)
aws s3 cp $LINUX_ZIP s3://$MAIN_RELEASE_PATH/$LINUX_ZIP_NAME
done
# Upload the autoupdate Mac zip
echo "Uploading Mac zip"
MAC_ZIP_NAME=$(basename $MAC_ZIP)
aws s3 cp $MAC_ZIP s3://$AUTOUPDATE_RELEASE_PATH/$MAC_ZIP_NAME
# Update the autoupdate feeds
echo "Updating autoupdate feeds"
RELEASES_CONTENTS="{\"name\":\"$UVERSION\",\"notes\":\"\",\"url\":\"https://$AUTOUPDATE_RELEASE_PATH/$MAC_ZIP_NAME\"}"
aws s3 cp - s3://$AUTOUPDATE_RELEASE_PATH/darwin/arm64/RELEASES.json <<< $RELEASES_CONTENTS
aws s3 cp - s3://$AUTOUPDATE_RELEASE_PATH/darwin/x64/RELEASES.json <<< $RELEASES_CONTENTS
# Clean up
echo "Cleaning up"
rm -rf $TEMP2_DIR

View File

@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models";
import { GlobalModel, GlobalCommandRunner, RemotesModel, getApi } from "@/models";
import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "@/common/elements";
import { commandRtnHandler, isBlank } from "@/util/util";
import * as appconst from "@/app/appconst";
@ -64,6 +64,7 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
prtn = GlobalCommandRunner.releaseCheckAutoOff(false);
}
commandRtnHandler(prtn, this.errorMessage);
getApi().changeAutoUpdate(val);
}
getFontSizes(): DropdownItem[] {

View File

@ -169,6 +169,44 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
GlobalModel.modalsModel.pushModal(appconst.SESSION_SETTINGS);
}
/**
* Get the update banner for the app, if we need to show it.
* @returns Either a banner to install the ready update, a link to the download page, or null if no update is available.
*/
@boundMethod
getUpdateAppBanner(): React.ReactNode {
if (GlobalModel.platform == "darwin") {
const status = GlobalModel.appUpdateStatus.get();
if (status == "ready") {
return (
<SideBarItem
key="update-ready"
className="update-banner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Click to Install Update"
onClick={() => GlobalModel.installAppUpdate()}
/>
);
}
} else {
const clientData = this.props.clientData;
if (!clientData?.clientopts.noreleasecheck && !isBlank(clientData?.releaseinfo?.latestversion)) {
if (compareLoose(appconst.VERSION, clientData.releaseinfo.latestversion) < 0) {
return (
<SideBarItem
key="update-available"
className="update-banner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
/>
);
}
}
}
return null;
}
getSessions() {
if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>;
const sessionList: Session[] = [];
@ -227,11 +265,6 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
}
render() {
const clientData = this.props.clientData;
let needsUpdate = false;
if (!clientData?.clientopts.noreleasecheck && !isBlank(clientData?.releaseinfo?.latestversion)) {
needsUpdate = compareLoose(appconst.VERSION, clientData.releaseinfo.latestversion) < 0;
}
const mainSidebar = GlobalModel.mainSidebarModel;
const isCollapsed = mainSidebar.getCollapsed();
return (
@ -291,15 +324,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
{this.getSessions()}
</div>
<div className="bottom" id="sidebar-bottom">
<If condition={needsUpdate}>
<SideBarItem
key="update-available"
className="update-banner"
frontIcon={<i className="fa-sharp fa-regular fa-circle-up icon" />}
contents="Update Available"
onClick={() => openLink("https://www.waveterm.dev/download?ref=upgrade")}
/>
</If>
{this.getUpdateAppBanner()}
<If condition={GlobalModel.isDev}>
<SideBarItem
key="apps"

View File

@ -11,7 +11,7 @@ import * as winston from "winston";
import * as util from "util";
import * as waveutil from "../util/util";
import { sprintf } from "sprintf-js";
import { handleJsonFetchResponse } from "@/util/util";
import { handleJsonFetchResponse, fireAndForget } from "@/util/util";
import { v4 as uuidv4 } from "uuid";
import { checkKeyPressed, adaptFromElectronKeyEvent, setKeyUtilPlatform } from "@/util/keyutil";
import { platform } from "os";
@ -22,12 +22,13 @@ const AuthKeyFile = "waveterm.authkey";
const DevServerEndpoint = "http://127.0.0.1:8090";
const ProdServerEndpoint = "http://127.0.0.1:1619";
let isDev = process.env[WaveDevVarName] != null;
let waveHome = getWaveHomeDir();
let DistDir = isDev ? "dist-dev" : "dist";
const isDev = process.env[WaveDevVarName] != null;
const waveHome = getWaveHomeDir();
const DistDir = isDev ? "dist-dev" : "dist";
const instanceId = uuidv4();
const oldConsoleLog = console.log;
let GlobalAuthKey = "";
let instanceId = uuidv4();
let oldConsoleLog = console.log;
let wasActive = true;
let wasInFg = true;
let currentGlobalShortcut: string | null = null;
@ -38,18 +39,18 @@ ensureDir(waveHome);
// these are either "darwin/amd64" or "darwin/arm64"
// normalize darwin/x64 to darwin/amd64 for GOARCH compatibility
let unamePlatform = process.platform;
const unamePlatform = process.platform;
let unameArch: string = process.arch;
if (unameArch == "x64") {
unameArch = "amd64";
}
let loggerTransports: winston.transport[] = [
const loggerTransports: winston.transport[] = [
new winston.transports.File({ filename: path.join(waveHome, "waveterm-app.log"), level: "info" }),
];
if (isDev) {
loggerTransports.push(new winston.transports.Console());
}
let loggerConfig = {
const loggerConfig = {
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
@ -57,8 +58,8 @@ let loggerConfig = {
),
transports: loggerTransports,
};
let logger = winston.createLogger(loggerConfig);
function log(...msg) {
const logger = winston.createLogger(loggerConfig);
function log(...msg: any[]) {
try {
logger.info(util.format(...msg));
} catch (e) {
@ -78,7 +79,7 @@ console.log(
if (isDev) {
console.log("waveterm-app WAVETERM_DEV set");
}
let app = electron.app;
const app = electron.app;
app.setName(isDev ? "Wave (Dev)" : "Wave");
let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null;
let waveSrvShouldRestart = false;
@ -101,7 +102,7 @@ function getWaveHomeDir() {
}
function checkPromptMigrate() {
let waveHome = getWaveHomeDir();
const waveHome = getWaveHomeDir();
if (isDev || fs.existsSync(waveHome)) {
// don't migrate if we're running dev version or if wave home directory already exists
return;
@ -109,8 +110,8 @@ function checkPromptMigrate() {
if (process.env.HOME == null) {
return;
}
let homeDir: string = process.env.HOME;
let promptHome: string = path.join(homeDir, "prompt");
const homeDir: string = process.env.HOME;
const promptHome: string = path.join(homeDir, "prompt");
if (!fs.existsSync(promptHome) || !fs.existsSync(path.join(promptHome, "prompt.db"))) {
// make sure we have a valid prompt home directory (prompt.db must exist inside)
return;
@ -147,39 +148,38 @@ function getWaveSrvPath() {
}
function getWaveSrvCmd() {
let waveSrvPath = getWaveSrvPath();
let waveHome = getWaveHomeDir();
let logFile = path.join(waveHome, "wavesrv.log");
const waveSrvPath = getWaveSrvPath();
const waveHome = getWaveHomeDir();
const logFile = path.join(waveHome, "wavesrv.log");
return `"${waveSrvPath}" >> "${logFile}" 2>&1`;
}
function getWaveSrvCwd() {
let waveHome = getWaveHomeDir();
return waveHome;
return getWaveHomeDir();
}
function ensureDir(dir) {
function ensureDir(dir: fs.PathLike) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
function readAuthKey() {
let homeDir = getWaveHomeDir();
let authKeyFileName = path.join(homeDir, AuthKeyFile);
const homeDir = getWaveHomeDir();
const authKeyFileName = path.join(homeDir, AuthKeyFile);
if (!fs.existsSync(authKeyFileName)) {
let authKeyStr = String(uuidv4());
const authKeyStr = String(uuidv4());
fs.writeFileSync(authKeyFileName, authKeyStr, { mode: 0o600 });
return authKeyStr;
}
let authKeyData = fs.readFileSync(authKeyFileName);
let authKeyStr = String(authKeyData);
const authKeyData = fs.readFileSync(authKeyFileName);
const authKeyStr = String(authKeyData);
if (authKeyStr == null || authKeyStr == "") {
throw new Error("cannot read authkey");
}
return authKeyStr.trim();
}
const reloadAcceleratorKey = unamePlatform == "darwin" ? "Option+R" : "Super+R";
let cmdOrAlt = process.platform === "darwin" ? "Cmd" : "Alt";
let menuTemplate: Electron.MenuItemConstructorOptions[] = [
const cmdOrAlt = process.platform === "darwin" ? "Cmd" : "Alt";
const menuTemplate: Electron.MenuItemConstructorOptions[] = [
{
role: "appMenu",
submenu: [
@ -254,7 +254,7 @@ let menuTemplate: Electron.MenuItemConstructorOptions[] = [
},
];
let menu = electron.Menu.buildFromTemplate(menuTemplate);
const menu = electron.Menu.buildFromTemplate(menuTemplate);
electron.Menu.setApplicationMenu(menu);
let MainWindow: Electron.BrowserWindow | null = null;
@ -274,12 +274,12 @@ function shNavHandler(event: Electron.Event<Electron.WebContentsWillNavigateEven
}
function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNavigateEventParams>) {
if (!event.frame || event.frame.parent == null) {
if (!event.frame?.parent) {
// only use this handler to process iframe events (non-iframe events go to shNavHandler)
return;
}
event.preventDefault();
let url = event.url;
const url = event.url;
console.log(`frame-navigation url=${url} frame=${event.frame.name}`);
if (event.frame.name == "webview") {
// "webview" links always open in new window
@ -289,13 +289,12 @@ function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNa
return;
}
console.log("frame navigation canceled");
return;
}
function createMainWindow(clientData: ClientDataType | null) {
let bounds = calcBounds(clientData);
const bounds = calcBounds(clientData);
setKeyUtilPlatform(platform());
let win = new electron.BrowserWindow({
const win = new electron.BrowserWindow({
x: bounds.x,
y: bounds.y,
titleBarStyle: "hiddenInset",
@ -309,17 +308,17 @@ function createMainWindow(clientData: ClientDataType | null) {
preload: path.join(getAppBasePath(), DistDir, "preload.js"),
},
});
let indexHtml = isDev ? "index-dev.html" : "index.html";
const indexHtml = isDev ? "index-dev.html" : "index.html";
win.loadFile(path.join(getAppBasePath(), "public", indexHtml));
win.webContents.on("before-input-event", (e, input) => {
let waveEvent = adaptFromElectronKeyEvent(input);
const waveEvent = adaptFromElectronKeyEvent(input);
if (win.isFocused()) {
wasActive = true;
}
if (input.type != "keyDown") {
return;
}
let mods = getMods(input);
const mods = getMods(input);
if (checkKeyPressed(waveEvent, "Cmd:t")) {
win.webContents.send("t-cmd", mods);
e.preventDefault();
@ -378,7 +377,7 @@ function createMainWindow(clientData: ClientDataType | null) {
return;
}
if (input.code.startsWith("Digit") && input.meta) {
let digitNum = parseInt(input.code.substr(5));
const digitNum = parseInt(input.code.substring(5));
if (isNaN(digitNum) || digitNum < 1 || digitNum > 9) {
return;
}
@ -386,7 +385,7 @@ function createMainWindow(clientData: ClientDataType | null) {
win.webContents.send("digit-cmd", { digit: digitNum }, mods);
}
if (checkKeyPressed(waveEvent, "Cmd:[") || checkKeyPressed(waveEvent, "Cmd:]")) {
let rel = checkKeyPressed(waveEvent, "Cmd:]") ? 1 : -1;
const rel = checkKeyPressed(waveEvent, "Cmd:]") ? 1 : -1;
win.webContents.send("bracket-cmd", { relative: rel }, mods);
e.preventDefault();
return;
@ -420,9 +419,9 @@ function createMainWindow(clientData: ClientDataType | null) {
console.log("openExternal discord", url);
electron.shell.openExternal(url);
} else if (url.startsWith("https://extern/?")) {
let qmark = url.indexOf("?");
let param = url.substr(qmark + 1);
let newUrl = decodeURIComponent(param);
const qmark = url.indexOf("?");
const param = url.substring(qmark + 1);
const newUrl = decodeURIComponent(param);
console.log("openExternal extern", newUrl);
electron.shell.openExternal(newUrl);
} else if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) {
@ -432,18 +431,18 @@ function createMainWindow(clientData: ClientDataType | null) {
console.log("window-open denied", url);
return { action: "deny" };
});
return win;
}
function mainResizeHandler(e, win) {
function mainResizeHandler(_: any, win: Electron.BrowserWindow) {
if (win == null || win.isDestroyed() || win.fullScreen) {
return;
}
let bounds = win.getBounds();
// console.log("resize/move", win.getBounds());
let winSize = { width: bounds.width, height: bounds.height, top: bounds.y, left: bounds.x };
let url = new URL(getBaseHostPort() + "/api/set-winsize");
let fetchHeaders = getFetchHeaders();
const bounds = win.getBounds();
const winSize = { width: bounds.width, height: bounds.height, top: bounds.y, left: bounds.x };
const url = new URL(getBaseHostPort() + "/api/set-winsize");
const fetchHeaders = getFetchHeaders();
fetch(url, { method: "post", body: JSON.stringify(winSize), headers: fetchHeaders })
.then((resp) => handleJsonFetchResponse(url, resp))
.catch((err) => {
@ -452,11 +451,11 @@ function mainResizeHandler(e, win) {
}
function calcBounds(clientData: ClientDataType) {
let primaryDisplay = electron.screen.getPrimaryDisplay();
let pdBounds = primaryDisplay.bounds;
let size = { x: 100, y: 100, width: pdBounds.width - 200, height: pdBounds.height - 200 };
if (clientData != null && clientData.winsize != null && clientData.winsize.width > 0) {
let cwinSize = clientData.winsize;
const primaryDisplay = electron.screen.getPrimaryDisplay();
const pdBounds = primaryDisplay.bounds;
const size = { x: 100, y: 100, width: pdBounds.width - 200, height: pdBounds.height - 200 };
if (clientData?.winsize?.width > 0) {
const cwinSize = clientData.winsize;
if (cwinSize.width > 0) {
size.width = cwinSize.width;
}
@ -497,32 +496,26 @@ app.on("window-all-closed", () => {
electron.ipcMain.on("get-id", (event) => {
event.returnValue = instanceId + ":" + event.processId;
return;
});
electron.ipcMain.on("get-platform", (event) => {
event.returnValue = unamePlatform;
return;
});
electron.ipcMain.on("get-isdev", (event) => {
event.returnValue = isDev;
return;
});
electron.ipcMain.on("get-authkey", (event) => {
event.returnValue = GlobalAuthKey;
return;
});
electron.ipcMain.on("wavesrv-status", (event) => {
event.returnValue = waveSrvProc != null;
return;
});
electron.ipcMain.on("get-initial-termfontfamily", (event) => {
event.returnValue = initialClientData?.feopts?.termfontfamily;
return;
});
electron.ipcMain.on("restart-server", (event) => {
@ -534,7 +527,6 @@ electron.ipcMain.on("restart-server", (event) => {
runWaveSrv();
}
event.returnValue = true;
return;
});
electron.ipcMain.on("reload-window", (event) => {
@ -542,35 +534,29 @@ electron.ipcMain.on("reload-window", (event) => {
MainWindow.reload();
}
event.returnValue = true;
return;
});
electron.ipcMain.on("open-external-link", async (_, url) => {
try {
await electron.shell.openExternal(url);
} catch (err) {
console.warn("error opening external link", err);
}
});
electron.ipcMain.on("open-external-link", (_, url) => fireAndForget(() => electron.shell.openExternal(url)));
electron.ipcMain.on("reregister-global-shortcut", (event, shortcut: string) => {
reregisterGlobalShortcut(shortcut);
event.returnValue = true;
return;
});
electron.ipcMain.on("get-last-logs", async (event, numberOfLines) => {
try {
const logPath = path.join(getWaveHomeDir(), "wavesrv.log");
const lastLines = await readLastLinesOfFile(logPath, numberOfLines);
event.reply("last-logs", lastLines);
} catch (err) {
console.error("Error reading log file:", err);
event.reply("last-logs", "Error reading log file.");
}
electron.ipcMain.on("get-last-logs", (event, numberOfLines) => {
fireAndForget(async () => {
try {
const logPath = path.join(getWaveHomeDir(), "wavesrv.log");
const lastLines = await readLastLinesOfFile(logPath, numberOfLines);
event.reply("last-logs", lastLines);
} catch (err) {
console.error("Error reading log file:", err);
event.reply("last-logs", "Error reading log file.");
}
});
});
function readLastLinesOfFile(filePath, lineCount) {
function readLastLinesOfFile(filePath: string, lineCount: number) {
return new Promise((resolve, reject) => {
child_process.exec(`tail -n ${lineCount} "${filePath}"`, (err, stdout, stderr) => {
if (err) {
@ -587,8 +573,8 @@ function readLastLinesOfFile(filePath, lineCount) {
}
function getContextMenu(): any {
let menu = new electron.Menu();
let menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") });
const menu = new electron.Menu();
const menuItem = new electron.MenuItem({ label: "Testing", click: () => console.log("click testing!") });
menu.append(menuItem);
return menu;
}
@ -600,8 +586,8 @@ function getFetchHeaders() {
}
async function getClientDataPoll(loopNum: number) {
let lastTime = loopNum >= 6;
let cdata = await getClientData(!lastTime, loopNum);
const lastTime = loopNum >= 6;
const cdata = await getClientData(!lastTime, loopNum);
if (lastTime || cdata != null) {
return cdata;
}
@ -609,9 +595,9 @@ async function getClientDataPoll(loopNum: number) {
return getClientDataPoll(loopNum + 1);
}
function getClientData(willRetry: boolean, retryNum: number) {
let url = new URL(getBaseHostPort() + "/api/get-client-data");
let fetchHeaders = getFetchHeaders();
async function getClientData(willRetry: boolean, retryNum: number) {
const url = new URL(getBaseHostPort() + "/api/get-client-data");
const fetchHeaders = getFetchHeaders();
return fetch(url, { headers: fetchHeaders })
.then((resp) => handleJsonFetchResponse(url, resp))
.then((data) => {
@ -623,9 +609,9 @@ function getClientData(willRetry: boolean, retryNum: number) {
.catch((err) => {
if (willRetry) {
console.log("error getting client-data from wavesrv, will retry", "(" + retryNum + ")");
return null;
} else {
console.log("error getting client-data from wavesrv, failed: ", err);
}
console.log("error getting client-data from wavesrv, failed: ", err);
return null;
});
}
@ -643,18 +629,18 @@ function sendWSSC() {
function runWaveSrv() {
let pResolve: (value: unknown) => void;
let pReject: (reason?: any) => void;
let rtnPromise = new Promise((argResolve, argReject) => {
const rtnPromise = new Promise((argResolve, argReject) => {
pResolve = argResolve;
pReject = argReject;
});
let envCopy = Object.assign({}, process.env);
const envCopy = { ...process.env };
envCopy[WaveAppPathVarName] = getAppBasePath();
if (isDev) {
envCopy[WaveDevVarName] = "1";
}
let waveSrvCmd = getWaveSrvCmd();
const waveSrvCmd = getWaveSrvCmd();
console.log("trying to run local server", waveSrvCmd);
let proc = child_process.spawn("bash", ["-c", waveSrvCmd], {
const proc = child_process.spawn("bash", ["-c", waveSrvCmd], {
cwd: getWaveSrvCwd(),
env: envCopy,
});
@ -679,29 +665,29 @@ function runWaveSrv() {
proc.on("error", (e) => {
console.log("error running wavesrv", e);
});
proc.stdout.on("data", (output) => {
proc.stdout.on("data", (_) => {
return;
});
proc.stderr.on("data", (output) => {
proc.stderr.on("data", (_) => {
return;
});
return rtnPromise;
}
electron.ipcMain.on("context-screen", (event, { screenId }, { x, y }) => {
electron.ipcMain.on("context-screen", (_, { screenId }, { x, y }) => {
console.log("context-screen", screenId);
let menu = getContextMenu();
const menu = getContextMenu();
menu.popup({ x, y });
});
electron.ipcMain.on("context-editmenu", (event, { x, y }, opts) => {
electron.ipcMain.on("context-editmenu", (_, { x, y }, opts) => {
if (opts == null) {
opts = {};
}
console.log("context-editmenu");
let menu = new electron.Menu();
const menu = new electron.Menu();
if (opts.showCut) {
let menuItem = new electron.MenuItem({ label: "Cut", role: "cut" });
const menuItem = new electron.MenuItem({ label: "Cut", role: "cut" });
menu.append(menuItem);
}
let menuItem = new electron.MenuItem({ label: "Copy", role: "copy" });
@ -723,16 +709,17 @@ async function createMainWindowWrap() {
if (clientData && clientData.winsize.fullscreen) {
MainWindow.setFullScreen(true);
}
configureAutoUpdaterStartup(clientData);
}
async function sleep(ms) {
return new Promise((resolve, reject) => setTimeout(resolve, ms));
async function sleep(ms: number) {
return new Promise((resolve, _) => setTimeout(resolve, ms));
}
function logActiveState() {
let activeState = { fg: wasInFg, active: wasActive, open: true };
let url = new URL(getBaseHostPort() + "/api/log-active-state");
let fetchHeaders = getFetchHeaders();
const activeState = { fg: wasInFg, active: wasActive, open: true };
const url = new URL(getBaseHostPort() + "/api/log-active-state");
const fetchHeaders = getFetchHeaders();
fetch(url, { method: "post", body: JSON.stringify(activeState), headers: fetchHeaders })
.then((resp) => handleJsonFetchResponse(url, resp))
.catch((err) => {
@ -765,7 +752,7 @@ function reregisterGlobalShortcut(shortcut: string) {
currentGlobalShortcut = null;
return;
}
let ok = electron.globalShortcut.register(shortcut, () => {
const ok = electron.globalShortcut.register(shortcut, () => {
console.log("global shortcut triggered, showing window");
MainWindow?.show();
});
@ -777,10 +764,161 @@ function reregisterGlobalShortcut(shortcut: string) {
currentGlobalShortcut = shortcut;
}
// ====== AUTO-UPDATER ====== //
let autoUpdateLock = false;
let autoUpdateInterval: NodeJS.Timeout | null = null;
let availableUpdateReleaseName: string | null = null;
let availableUpdateReleaseNotes: string | null = null;
let appUpdateStatus = "unavailable";
/**
* 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;
if (MainWindow != null) {
MainWindow.webContents.send("app-update-status", appUpdateStatus);
}
}
/**
* Initializes the auto-updater and sets up event listeners
* @returns The interval at which the auto-updater checks for updates
*/
function initUpdater(): NodeJS.Timeout {
const { autoUpdater } = electron;
if (isDev) {
console.log("skipping auto-updater in dev mode");
return null;
}
setAppUpdateStatus("unavailable");
let feedURL = `https://dl.waveterm.dev/autoupdate/${unamePlatform}/${unameArch}`;
let serverType: "default" | "json" = "default";
if (unamePlatform == "darwin") {
feedURL += "/RELEASES.json";
serverType = "json";
}
autoUpdater.setFeedURL({
url: feedURL,
headers: { "User-Agent": "Wave Auto-Update" },
serverType,
});
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, releaseNotes, releaseName, releaseDate, updateURL) => {
console.log("update-downloaded", [event, releaseNotes, releaseName, releaseDate, updateURL]);
availableUpdateReleaseName = releaseName;
availableUpdateReleaseNotes = releaseNotes;
// 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();
});
// check for updates right away and keep checking later
autoUpdater.checkForUpdates();
return setInterval(autoUpdater.checkForUpdates, 600000); // 10 minutes in ms
}
/**
* 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.",
};
await electron.dialog.showMessageBox(MainWindow, dialogOpts).then(({ response }) => {
if (response === 0) electron.autoUpdater.quitAndInstall();
});
}
electron.ipcMain.on("install-app-update", () => fireAndForget(installAppUpdate));
electron.ipcMain.on("get-app-update-status", (event) => {
event.returnValue = appUpdateStatus;
});
electron.ipcMain.on("change-auto-update", (_, enable: boolean) => {
configureAutoUpdater(enable);
});
/**
* Configures the auto-updater based on the client data
* @param clientData The client data to use to configure the auto-updater. If the clientData has noreleasecheck set to true, the auto-updater will be disabled.
*/
function configureAutoUpdaterStartup(clientData: ClientDataType) {
configureAutoUpdater(!clientData.clientopts.noreleasecheck);
}
/**
* Configures the auto-updater based on the user's preference
* @param enabled Whether the auto-updater should be enabled
*/
function configureAutoUpdater(enabled: boolean) {
// 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;
// only configure auto-updater on macOS
if (unamePlatform == "darwin") {
if (enabled && autoUpdateInterval == null) {
try {
console.log("configuring auto updater");
autoUpdateInterval = initUpdater();
} catch (e) {
console.log("error configuring auto updater", e.toString());
}
} else if (autoUpdateInterval != null) {
console.log("user has disabled auto-updates, stopping updater");
clearInterval(autoUpdateInterval);
autoUpdateInterval = null;
}
}
autoUpdateLock = false;
}
// ====== AUTO-UPDATER ====== //
// ====== MAIN ====== //
(async () => {
let instanceLock = app.requestSingleInstanceLock();
const instanceLock = app.requestSingleInstanceLock();
if (!instanceLock) {
console.log("waveterm-app could not get single-instance-lock, shutting down");
app.quit();

View File

@ -15,6 +15,10 @@ contextBridge.exposeInMainWorld("api", {
reloadWindow: () => ipcRenderer.sendSync("reload-window"),
reregisterGlobalShortcut: (shortcut) => ipcRenderer.sendSync("reregister-global-shortcut", shortcut),
openExternalLink: (url) => ipcRenderer.send("open-external-link", url),
changeAutoUpdate: (enabled) => ipcRenderer.send("change-auto-update", enabled),
installAppUpdate: () => ipcRenderer.send("install-app-update"),
getAppUpdateStatus: () => ipcRenderer.sendSync("get-app-update-status"),
onAppUpdateStatus: (callback) => ipcRenderer.on("app-update-status", (_, val) => callback(val)),
onTCmd: (callback) => ipcRenderer.on("t-cmd", callback),
onICmd: (callback) => ipcRenderer.on("i-cmd", callback),
onLCmd: (callback) => ipcRenderer.on("l-cmd", callback),

View File

@ -125,6 +125,10 @@ class Model {
name: "renderVersion",
});
appUpdateStatus = mobx.observable.box(getApi().getAppUpdateStatus(), {
name: "appUpdateStatus",
});
private constructor() {
this.clientId = getApi().getId();
this.isDev = getApi().getIsDev();
@ -178,6 +182,7 @@ class Model {
getApi().onBracketCmd(this.onBracketCmd.bind(this));
getApi().onDigitCmd(this.onDigitCmd.bind(this));
getApi().onWaveSrvStatusChange(this.onWaveSrvStatusChange.bind(this));
getApi().onAppUpdateStatus(this.onAppUpdateStatus.bind(this));
document.addEventListener("keydown", this.docKeyDownHandler.bind(this));
document.addEventListener("selectionchange", this.docSelectionChangeHandler.bind(this));
setTimeout(() => this.getClientDataLoop(1), 10);
@ -756,8 +761,7 @@ class Model {
if (oldContext.sessionid != newContext.sessionid || oldContext.screenid != newContext.screenid) {
this.inputModel.resetInput();
if (genUpdate.type == "model") {
const modelUpdate = genUpdate as ModelUpdatePacket;
const reversedGenUpdate = modelUpdate.data.slice().reverse();
const reversedGenUpdate = genUpdate.data.slice().reverse();
const lastCmdLine = reversedGenUpdate.find((update) => "cmdline" in update);
if (lastCmdLine) {
// TODO a bit of a hack since this update gets applied in runUpdate_internal.
@ -819,7 +823,7 @@ class Model {
runUpdate_internal(genUpdate: UpdatePacket, uiContext: UIContextType, interactive: boolean) {
if (genUpdate.type == "pty") {
const ptyMsg = genUpdate.data as PtyDataUpdateType;
const ptyMsg = genUpdate.data;
if (isBlank(ptyMsg.remoteid)) {
// regular update
this.updatePtyData(ptyMsg);
@ -829,7 +833,7 @@ class Model {
this.remotesModel.receiveData(ptyMsg.remoteid, ptyMsg.ptypos, ptyData);
}
} else if (genUpdate.type == "model") {
const modelUpdateItems = genUpdate.data as ModelUpdateItemType[];
const modelUpdateItems = genUpdate.data;
let showedRemotesModal = false;
const [oldActiveSessionId, oldActiveScreenId] = this.getActiveIds();
@ -1100,8 +1104,7 @@ class Model {
isInfoUpdate(update: UpdatePacket): boolean {
if (update.type == "model") {
const modelUpdate = update as ModelUpdatePacket;
return modelUpdate.data.some((u) => u.info != null || u.history != null);
return update.data.some((u) => u.info != null || u.history != null);
} else {
return false;
}
@ -1506,6 +1509,21 @@ class Model {
const resp = await prtn;
const _ = await handleJsonFetchResponse(url, resp);
}
/**
* Tell Electron to install the waiting app update. Will prompt for user input before restarting.
*/
installAppUpdate(): void {
if (this.appUpdateStatus.get() == "ready") {
getApi().installAppUpdate();
}
}
onAppUpdateStatus(status: AppUpdateStatusType) {
mobx.action(() => {
this.appUpdateStatus.set(status);
})();
}
}
export { Model, getApi };

View File

@ -11,6 +11,7 @@ declare global {
type HistoryTypeStrs = "global" | "session" | "screen";
type RemoteStatusTypeStrs = "connected" | "connecting" | "disconnected" | "error";
type LineContainerStrs = "main" | "sidebar" | "history";
type AppUpdateStatusType = "unavailable" | "ready";
type OV<V> = mobx.IObservableValue<V>;
type OArr<V> = mobx.IObservableArray<V>;
@ -869,6 +870,10 @@ declare global {
reloadWindow: () => void;
openExternalLink: (url: string) => void;
reregisterGlobalShortcut: (shortcut: string) => void;
changeAutoUpdate: (enabled: boolean) => void;
installAppUpdate: () => void;
getAppUpdateStatus: () => AppUpdateStatusType;
onAppUpdateStatus: (callback: (status: AppUpdateStatusType) => void) => void;
onTCmd: (callback: (mods: KeyModsType) => void) => void;
onICmd: (callback: (mods: KeyModsType) => void) => void;
onLCmd: (callback: (mods: KeyModsType) => void) => void;

View File

@ -363,6 +363,16 @@ function ces(s: string) {
return s;
}
/**
* A wrapper function for running a promise and catching any errors
* @param f The promise to run
*/
function fireAndForget(f: () => Promise<void>) {
f().catch((e) => {
console.log("fireAndForget error", e);
});
}
export {
handleJsonFetchResponse,
base64ToString,
@ -389,4 +399,5 @@ export {
getRemoteConnVal,
getRemoteName,
ces,
fireAndForget,
};