mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
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:
parent
bdfb80e62f
commit
98385b1e0d
86
buildres/upload-release.sh
Normal file
86
buildres/upload-release.sh
Normal 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
|
@ -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[] {
|
||||
|
@ -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"
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
|
@ -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 };
|
||||
|
5
src/types/custom.d.ts
vendored
5
src/types/custom.d.ts
vendored
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user