diff --git a/src/GameOptions/ui/ConnectionBauble.tsx b/src/GameOptions/ui/ConnectionBauble.tsx
new file mode 100644
index 000000000..599a3389d
--- /dev/null
+++ b/src/GameOptions/ui/ConnectionBauble.tsx
@@ -0,0 +1,18 @@
+import React, { useState, useEffect } from "react";
+
+interface baubleProps {
+ callback: () => boolean;
+}
+
+export const ConnectionBauble = (props: baubleProps): React.ReactElement => {
+ const [connection, setConnection] = useState(props.callback());
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setConnection(props.callback());
+ }, 1000);
+ return () => clearInterval(timer);
+ });
+
+ return
{connection ? "Connected" : "Disconnected"}
;
+};
diff --git a/src/GameOptions/ui/CurrentOptionsPage.tsx b/src/GameOptions/ui/CurrentOptionsPage.tsx
index 639ba21a8..7ab6ec88c 100644
--- a/src/GameOptions/ui/CurrentOptionsPage.tsx
+++ b/src/GameOptions/ui/CurrentOptionsPage.tsx
@@ -1,12 +1,15 @@
-import { MenuItem, Select, SelectChangeEvent, TextField, Tooltip, Typography } from "@mui/material";
+import { MenuItem, Select, SelectChangeEvent, TextField, Tooltip, Typography, Box } from "@mui/material";
import React, { useState } from "react";
import { IPlayer } from "../../PersonObjects/IPlayer";
+import { isRemoteFileApiConnectionLive, newRemoteFileApiConnection } from "../../RemoteFileAPI/RemoteFileAPI";
import { Settings } from "../../Settings/Settings";
import { OptionSwitch } from "../../ui/React/OptionSwitch";
import { formatTime } from "../../utils/helpers/formatTime";
import { GameOptionsTab } from "../GameOptionsTab";
import { GameOptionsPage } from "./GameOptionsPage";
import { OptionsSlider } from "./OptionsSlider";
+import Button from "@mui/material/Button";
+import { ConnectionBauble } from "./ConnectionBauble";
interface IProps {
currentTab: GameOptionsTab;
@@ -21,6 +24,7 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
const [terminalSize, setTerminalSize] = useState(Settings.MaxTerminalCapacity);
const [autosaveInterval, setAutosaveInterval] = useState(Settings.AutosaveInterval);
const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat);
+ const [remoteFileApiPort, setRemoteFileApiPort] = useState(Settings.RemoteFileApiPort);
const [locale, setLocale] = useState(Settings.Locale);
function handleExecTimeChange(
@@ -81,6 +85,11 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
Settings.TimestampsFormat = event.target.value;
}
+ function handleRemoteFileApiPortChange(event: React.ChangeEvent): void {
+ setRemoteFileApiPort(Number(event.target.value) as number);
+ Settings.RemoteFileApiPort = Number(event.target.value);
+ }
+
const pages = {
[GameOptionsTab.SYSTEM]: (
@@ -361,6 +370,34 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
>
}
/>
+
+ This port number is used to connect to a Remote File API port, please ensure that it matches with the port
+ the Remote File API server is publishing on (12525 by default). Click the reconnect button to try and
+ re-establish connection. The little colored bauble shows whether the connection is live or not.
+
+ }
+ >
+ 0 && remoteFileApiPort <= 65535 ? "success" : "error"}>
+ Remote File API port:
+
+ ),
+ endAdornment: (
+
+
+
+
+ ),
+ }}
+ value={remoteFileApiPort}
+ onChange={handleRemoteFileApiPortChange}
+ placeholder="12525"
+ />
+
),
};
diff --git a/src/RemoteFileAPI/MessageDefinitions.ts b/src/RemoteFileAPI/MessageDefinitions.ts
new file mode 100644
index 000000000..f113e1a2c
--- /dev/null
+++ b/src/RemoteFileAPI/MessageDefinitions.ts
@@ -0,0 +1,58 @@
+export class RFAMessage {
+ jsonrpc = "2.0"; // Transmits version of JSON-RPC. Compliance maybe allows some funky interaction with external tools?
+ public method?: string; // Is defined when it's a request/notification, otherwise undefined
+ public result?: string; // Is defined when it's a response, otherwise undefined
+ public params?: FileMetadata; // Optional parameters to method
+ public error?: string; // Only defined on error
+ public id?: number; // ID to keep track of request -> response interaction, undefined with notifications, defined with request/response
+
+ constructor(obj: { method?: string; result?: string; params?: FileMetadata; error?: string; id?: number } = {}) {
+ this.method = obj.method;
+ this.result = obj.result;
+ this.params = obj.params;
+ this.error = obj.error;
+ this.id = obj.id;
+ }
+}
+
+type FileMetadata = FileData | FileContent | FileLocation | FileServer;
+
+export interface FileData {
+ filename: string;
+ content: string;
+ server: string;
+}
+
+export interface FileContent {
+ filename: string;
+ content: string;
+}
+
+export interface FileLocation {
+ filename: string;
+ server: string;
+}
+
+export interface FileServer {
+ server: string;
+}
+
+export function isFileData(p: unknown): p is FileData {
+ const pf = p as FileData;
+ return typeof pf.server === "string" && typeof pf.filename === "string" && typeof pf.content === "string";
+}
+
+export function isFileLocation(p: unknown): p is FileLocation {
+ const pf = p as FileLocation;
+ return typeof pf.server === "string" && typeof pf.filename === "string";
+}
+
+export function isFileContent(p: unknown): p is FileContent {
+ const pf = p as FileContent;
+ return typeof pf.filename === "string" && typeof pf.content === "string";
+}
+
+export function isFileServer(p: unknown): p is FileServer {
+ const pf = p as FileServer;
+ return typeof pf.server === "string";
+}
diff --git a/src/RemoteFileAPI/MessageHandlers.ts b/src/RemoteFileAPI/MessageHandlers.ts
new file mode 100644
index 000000000..82297dbc1
--- /dev/null
+++ b/src/RemoteFileAPI/MessageHandlers.ts
@@ -0,0 +1,140 @@
+import { Player } from "../Player";
+import { isScriptFilename } from "../Script/isScriptFilename";
+import { GetServer } from "../Server/AllServers";
+import { isValidFilePath } from "../Terminal/DirectoryHelpers";
+import { TextFile } from "../TextFile";
+import {
+ RFAMessage,
+ FileData,
+ FileContent,
+ isFileServer,
+ isFileLocation,
+ FileLocation,
+ isFileData,
+} from "./MessageDefinitions";
+//@ts-ignore: Complaint of import ending with .d.ts
+import libSource from "!!raw-loader!../ScriptEditor/NetscriptDefinitions.d.ts";
+import { RFALogger } from "./RFALogger";
+
+function error(errorMsg: string, { id }: RFAMessage): RFAMessage {
+ RFALogger.error((typeof id === "undefined" ? "" : `Request ${id}: `) + errorMsg);
+ return new RFAMessage({ error: errorMsg, id: id });
+}
+
+export const RFARequestHandler: Record void | RFAMessage> = {
+ pushFile: function (msg: RFAMessage): RFAMessage {
+ if (!isFileData(msg.params)) return error("Misses parameters", msg);
+
+ const fileData: FileData = msg.params;
+ if (!isValidFilePath(fileData.filename)) return error("Invalid filename", msg);
+
+ const server = GetServer(fileData.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ if (isScriptFilename(fileData.filename)) server.writeToScriptFile(Player, fileData.filename, fileData.content);
+ // Assume it's a text file
+ else server.writeToTextFile(fileData.filename, fileData.content);
+
+ // If and only if the content is actually changed correctly, send back an OK.
+ const savedCorrectly =
+ server.getScript(fileData.filename)?.code === fileData.content ||
+ server.textFiles.filter((t: TextFile) => t.filename == fileData.filename).at(0)?.text === fileData.content;
+
+ if (!savedCorrectly) return error("File wasn't saved correctly", msg);
+
+ return new RFAMessage({ result: "OK", id: msg.id });
+ },
+
+ getFile: function (msg: RFAMessage): RFAMessage {
+ if (!isFileLocation(msg.params)) return error("Message misses parameters", msg);
+
+ const fileData: FileLocation = msg.params;
+ if (!isValidFilePath(fileData.filename)) return error("Invalid filename", msg);
+
+ const server = GetServer(fileData.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ if (isScriptFilename(fileData.filename)) {
+ const scriptContent = server.getScript(fileData.filename);
+ if (!scriptContent) return error("File doesn't exist", msg);
+ return new RFAMessage({ result: scriptContent.code, id: msg.id });
+ } else {
+ // Assume it's a text file
+ const file = server.textFiles.filter((t: TextFile) => t.filename == fileData.filename).at(0);
+ if (!file) return error("File doesn't exist", msg);
+ return new RFAMessage({ result: file.text, id: msg.id });
+ }
+ },
+
+ deleteFile: function (msg: RFAMessage): RFAMessage {
+ if (!isFileLocation(msg.params)) return error("Message misses parameters", msg);
+ const fileData: FileLocation = msg.params;
+ if (!isValidFilePath(fileData.filename)) return error("Invalid filename", msg);
+
+ const server = GetServer(fileData.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ const fileExists = (): boolean =>
+ !!server.getScript(fileData.filename) || server.textFiles.some((t: TextFile) => t.filename === fileData.filename);
+
+ if (!fileExists()) return error("File doesn't exist", msg);
+ server.removeFile(fileData.filename);
+ if (fileExists()) return error("Failed to delete the file", msg);
+
+ return new RFAMessage({ result: "OK", id: msg.id });
+ },
+
+ getFileNames: function (msg: RFAMessage): RFAMessage {
+ if (!isFileServer(msg.params)) return error("Message misses parameters", msg);
+
+ const server = GetServer(msg.params.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ const fileNameList: string[] = [
+ ...server.textFiles.map((txt): string => txt.filename),
+ ...server.scripts.map((scr): string => scr.filename),
+ ];
+
+ return new RFAMessage({ result: JSON.stringify(fileNameList), id: msg.id });
+ },
+
+ getAllFiles: function (msg: RFAMessage): RFAMessage {
+ if (!isFileServer(msg.params)) return error("Message misses parameters", msg);
+
+ const server = GetServer(msg.params.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ const fileList: FileContent[] = [
+ ...server.textFiles.map((txt): FileContent => {
+ return { filename: txt.filename, content: txt.text };
+ }),
+ ...server.scripts.map((scr): FileContent => {
+ return { filename: scr.filename, content: scr.code };
+ }),
+ ];
+
+ return new RFAMessage({ result: JSON.stringify(fileList), id: msg.id });
+ },
+
+ calculateRam: function (msg: RFAMessage): RFAMessage {
+ if (!isFileLocation(msg.params)) return error("Message misses parameters", msg);
+ const fileData: FileLocation = msg.params;
+ if (!isValidFilePath(fileData.filename)) return error("Invalid filename", msg);
+
+ const server = GetServer(fileData.server);
+ if (!server) return error("Server hostname invalid", msg);
+
+ if (!isScriptFilename(fileData.filename)) return error("Filename isn't a script filename", msg);
+ const script = server.getScript(fileData.filename);
+ if (!script) return error("File doesn't exist", msg);
+ const ramUsage = script.ramUsage;
+
+ return new RFAMessage({ result: String(ramUsage), id: msg.id });
+ },
+
+ getDefinitionFile: function (msg: RFAMessage): RFAMessage {
+ const source = (libSource + "").replace(/export /g, "");
+ console.log(source);
+ return new RFAMessage({ result: source, id: msg.id });
+ },
+};
diff --git a/src/RemoteFileAPI/RFALogger.ts b/src/RemoteFileAPI/RFALogger.ts
new file mode 100644
index 000000000..872d1dc26
--- /dev/null
+++ b/src/RemoteFileAPI/RFALogger.ts
@@ -0,0 +1,27 @@
+class RemoteFileAPILogger {
+ _enabled = true;
+ _prefix = "[RFA]";
+ _error_prefix = "[RFA-ERROR]";
+
+ constructor(enabled: boolean) {
+ this._enabled = enabled;
+ }
+
+ public error(...message: any[]): void {
+ if (this._enabled) console.error(this._error_prefix, ...message);
+ }
+
+ public log(...message: any[]): void {
+ if (this._enabled) console.log(this._prefix, ...message);
+ }
+
+ public disable(): void {
+ this._enabled = false;
+ }
+
+ public enable(): void {
+ this._enabled = true;
+ }
+}
+
+export const RFALogger = new RemoteFileAPILogger(true);
diff --git a/src/RemoteFileAPI/Remote.ts b/src/RemoteFileAPI/Remote.ts
new file mode 100644
index 000000000..d864db8df
--- /dev/null
+++ b/src/RemoteFileAPI/Remote.ts
@@ -0,0 +1,49 @@
+import { RFALogger } from "./RFALogger";
+import { RFAMessage } from "./MessageDefinitions";
+import { RFARequestHandler } from "./MessageHandlers";
+
+export class Remote {
+ connection?: WebSocket;
+ protocol = "ws";
+ ipaddr: string;
+ port: number;
+
+ constructor(ip: string, port: number) {
+ this.ipaddr = ip;
+ this.port = port;
+ }
+
+ public stopConnection(): void {
+ this.connection?.close();
+ }
+
+ public startConnection(): void {
+ RFALogger.log("Trying to connect.");
+ this.connection = new WebSocket(this.protocol + "://" + this.ipaddr + ":" + this.port);
+
+ this.connection.addEventListener("error", (e: Event) => RFALogger.error(e));
+ this.connection.addEventListener("message", handleMessageEvent);
+ this.connection.addEventListener("open", () =>
+ RFALogger.log("Connection established: ", this.ipaddr, ":", this.port),
+ );
+ this.connection.addEventListener("close", () => RFALogger.log("Connection closed"));
+ }
+}
+
+function handleMessageEvent(this: WebSocket, e: MessageEvent): void {
+ const msg: RFAMessage = JSON.parse(e.data);
+ RFALogger.log("Message received:", msg);
+
+ if (msg.method) {
+ if (!RFARequestHandler[msg.method]) {
+ const response = new RFAMessage({ error: "Unknown message received", id: msg.id });
+ this.send(JSON.stringify(response));
+ return;
+ }
+ const response = RFARequestHandler[msg.method](msg);
+ RFALogger.log("Sending response: ", response);
+ if (response) this.send(JSON.stringify(response));
+ } else if (msg.result) RFALogger.log("Somehow retrieved a result message.");
+ else if (msg.error) RFALogger.error("Received an error from server", msg);
+ else RFALogger.error("Incorrect Message", msg);
+}
diff --git a/src/RemoteFileAPI/RemoteFileAPI.ts b/src/RemoteFileAPI/RemoteFileAPI.ts
new file mode 100644
index 000000000..1dccab591
--- /dev/null
+++ b/src/RemoteFileAPI/RemoteFileAPI.ts
@@ -0,0 +1,19 @@
+import { Settings } from "../Settings/Settings";
+import { Remote } from "./Remote";
+
+let server: Remote;
+
+export function newRemoteFileApiConnection(): void {
+ if (server == undefined) {
+ server = new Remote("localhost", Settings.RemoteFileApiPort);
+ server.startConnection();
+ } else {
+ server.stopConnection();
+ server = new Remote("localhost", Settings.RemoteFileApiPort);
+ server.startConnection();
+ }
+}
+
+export function isRemoteFileApiConnectionLive(): boolean {
+ return server.connection != undefined && server.connection.readyState == 1;
+}
diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts
index c8d1cfd0c..3160dfa3c 100644
--- a/src/Settings/Settings.ts
+++ b/src/Settings/Settings.ts
@@ -84,6 +84,11 @@ interface IDefaultSettings {
*/
MaxTerminalCapacity: number;
+ /**
+ * Port the Remote File API client will try to connect to.
+ */
+ RemoteFileApiPort: number;
+
/**
* Save the game when you save any file.
*/
@@ -206,6 +211,7 @@ export const defaultSettings: IDefaultSettings = {
MaxLogCapacity: 50,
MaxPortCapacity: 50,
MaxTerminalCapacity: 500,
+ RemoteFileApiPort: 12525,
SaveGameOnFileSave: true,
SuppressBuyAugmentationConfirmation: false,
SuppressFactionInvites: false,
@@ -248,6 +254,7 @@ export const Settings: ISettings & ISelfInitializer & ISelfLoading = {
MaxTerminalCapacity: defaultSettings.MaxTerminalCapacity,
OwnedAugmentationsOrder: OwnedAugmentationsOrderSetting.AcquirementTime,
PurchaseAugmentationsOrder: PurchaseAugmentationsOrderSetting.Default,
+ RemoteFileApiPort: defaultSettings.RemoteFileApiPort,
SaveGameOnFileSave: defaultSettings.SaveGameOnFileSave,
SuppressBuyAugmentationConfirmation: defaultSettings.SuppressBuyAugmentationConfirmation,
SuppressFactionInvites: defaultSettings.SuppressFactionInvites,
diff --git a/src/index.tsx b/src/index.tsx
index 3720f3eca..83b2692f6 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,6 +4,9 @@ import ReactDOM from "react-dom";
import { TTheme as Theme, ThemeEvents, refreshTheme } from "./Themes/ui/Theme";
import { LoadingScreen } from "./ui/LoadingScreen";
import { initElectron } from "./Electron";
+
+import { newRemoteFileApiConnection } from "./RemoteFileAPI/RemoteFileAPI";
+
initElectron();
globalThis["React"] = React;
globalThis["ReactDOM"] = ReactDOM;
@@ -14,6 +17,8 @@ ReactDOM.render(
document.getElementById("root"),
);
+newRemoteFileApiConnection();
+
function rerender(): void {
refreshTheme();
ReactDOM.render(