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

View File

@ -169,6 +169,44 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
GlobalModel.modalsModel.pushModal(appconst.SESSION_SETTINGS); 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() { getSessions() {
if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>; if (!GlobalModel.sessionListLoaded.get()) return <div className="item">loading ...</div>;
const sessionList: Session[] = []; const sessionList: Session[] = [];
@ -227,11 +265,6 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
} }
render() { 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 mainSidebar = GlobalModel.mainSidebarModel;
const isCollapsed = mainSidebar.getCollapsed(); const isCollapsed = mainSidebar.getCollapsed();
return ( return (
@ -291,15 +324,7 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
{this.getSessions()} {this.getSessions()}
</div> </div>
<div className="bottom" id="sidebar-bottom"> <div className="bottom" id="sidebar-bottom">
<If condition={needsUpdate}> {this.getUpdateAppBanner()}
<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>
<If condition={GlobalModel.isDev}> <If condition={GlobalModel.isDev}>
<SideBarItem <SideBarItem
key="apps" key="apps"

View File

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

View File

@ -15,6 +15,10 @@ contextBridge.exposeInMainWorld("api", {
reloadWindow: () => ipcRenderer.sendSync("reload-window"), reloadWindow: () => ipcRenderer.sendSync("reload-window"),
reregisterGlobalShortcut: (shortcut) => ipcRenderer.sendSync("reregister-global-shortcut", shortcut), reregisterGlobalShortcut: (shortcut) => ipcRenderer.sendSync("reregister-global-shortcut", shortcut),
openExternalLink: (url) => ipcRenderer.send("open-external-link", url), 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), onTCmd: (callback) => ipcRenderer.on("t-cmd", callback),
onICmd: (callback) => ipcRenderer.on("i-cmd", callback), onICmd: (callback) => ipcRenderer.on("i-cmd", callback),
onLCmd: (callback) => ipcRenderer.on("l-cmd", callback), onLCmd: (callback) => ipcRenderer.on("l-cmd", callback),

View File

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

View File

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

View File

@ -363,6 +363,16 @@ function ces(s: string) {
return s; 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 { export {
handleJsonFetchResponse, handleJsonFetchResponse,
base64ToString, base64ToString,
@ -389,4 +399,5 @@ export {
getRemoteConnVal, getRemoteConnVal,
getRemoteName, getRemoteName,
ces, ces,
fireAndForget,
}; };