Merge pull request #3972 from Hoekstraa/rfa

RFA: New Remote File API addition for transmitting files to the game
This commit is contained in:
hydroflame 2022-08-23 16:09:29 -03:00 committed by GitHub
commit 895944aa19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 361 additions and 1 deletions

@ -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 <div className="ConnectionBauble">{connection ? "Connected" : "Disconnected"}</div>;
};

@ -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 React, { useState } from "react";
import { IPlayer } from "../../PersonObjects/IPlayer"; import { IPlayer } from "../../PersonObjects/IPlayer";
import { isRemoteFileApiConnectionLive, newRemoteFileApiConnection } from "../../RemoteFileAPI/RemoteFileAPI";
import { Settings } from "../../Settings/Settings"; import { Settings } from "../../Settings/Settings";
import { OptionSwitch } from "../../ui/React/OptionSwitch"; import { OptionSwitch } from "../../ui/React/OptionSwitch";
import { formatTime } from "../../utils/helpers/formatTime"; import { formatTime } from "../../utils/helpers/formatTime";
import { GameOptionsTab } from "../GameOptionsTab"; import { GameOptionsTab } from "../GameOptionsTab";
import { GameOptionsPage } from "./GameOptionsPage"; import { GameOptionsPage } from "./GameOptionsPage";
import { OptionsSlider } from "./OptionsSlider"; import { OptionsSlider } from "./OptionsSlider";
import Button from "@mui/material/Button";
import { ConnectionBauble } from "./ConnectionBauble";
interface IProps { interface IProps {
currentTab: GameOptionsTab; currentTab: GameOptionsTab;
@ -21,6 +24,7 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
const [terminalSize, setTerminalSize] = useState(Settings.MaxTerminalCapacity); const [terminalSize, setTerminalSize] = useState(Settings.MaxTerminalCapacity);
const [autosaveInterval, setAutosaveInterval] = useState(Settings.AutosaveInterval); const [autosaveInterval, setAutosaveInterval] = useState(Settings.AutosaveInterval);
const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat); const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat);
const [remoteFileApiPort, setRemoteFileApiPort] = useState(Settings.RemoteFileApiPort);
const [locale, setLocale] = useState(Settings.Locale); const [locale, setLocale] = useState(Settings.Locale);
function handleExecTimeChange( function handleExecTimeChange(
@ -81,6 +85,11 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
Settings.TimestampsFormat = event.target.value; Settings.TimestampsFormat = event.target.value;
} }
function handleRemoteFileApiPortChange(event: React.ChangeEvent<HTMLInputElement>): void {
setRemoteFileApiPort(Number(event.target.value) as number);
Settings.RemoteFileApiPort = Number(event.target.value);
}
const pages = { const pages = {
[GameOptionsTab.SYSTEM]: ( [GameOptionsTab.SYSTEM]: (
<GameOptionsPage title="System"> <GameOptionsPage title="System">
@ -361,6 +370,34 @@ export const CurrentOptionsPage = (props: IProps): React.ReactElement => {
</> </>
} }
/> />
<Tooltip
title={
<Typography>
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.
</Typography>
}
>
<TextField
InputProps={{
startAdornment: (
<Typography color={remoteFileApiPort > 0 && remoteFileApiPort <= 65535 ? "success" : "error"}>
Remote File API port:
</Typography>
),
endAdornment: (
<Box>
<Button onClick={newRemoteFileApiConnection}>Reconnect</Button>
<ConnectionBauble callback={isRemoteFileApiConnectionLive} />
</Box>
),
}}
value={remoteFileApiPort}
onChange={handleRemoteFileApiPortChange}
placeholder="12525"
/>
</Tooltip>
</GameOptionsPage> </GameOptionsPage>
), ),
}; };

@ -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";
}

@ -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<string, (message: RFAMessage) => 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 });
},
};

@ -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);

@ -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);
}

@ -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;
}

@ -84,6 +84,11 @@ interface IDefaultSettings {
*/ */
MaxTerminalCapacity: number; MaxTerminalCapacity: number;
/**
* Port the Remote File API client will try to connect to.
*/
RemoteFileApiPort: number;
/** /**
* Save the game when you save any file. * Save the game when you save any file.
*/ */
@ -206,6 +211,7 @@ export const defaultSettings: IDefaultSettings = {
MaxLogCapacity: 50, MaxLogCapacity: 50,
MaxPortCapacity: 50, MaxPortCapacity: 50,
MaxTerminalCapacity: 500, MaxTerminalCapacity: 500,
RemoteFileApiPort: 12525,
SaveGameOnFileSave: true, SaveGameOnFileSave: true,
SuppressBuyAugmentationConfirmation: false, SuppressBuyAugmentationConfirmation: false,
SuppressFactionInvites: false, SuppressFactionInvites: false,
@ -248,6 +254,7 @@ export const Settings: ISettings & ISelfInitializer & ISelfLoading = {
MaxTerminalCapacity: defaultSettings.MaxTerminalCapacity, MaxTerminalCapacity: defaultSettings.MaxTerminalCapacity,
OwnedAugmentationsOrder: OwnedAugmentationsOrderSetting.AcquirementTime, OwnedAugmentationsOrder: OwnedAugmentationsOrderSetting.AcquirementTime,
PurchaseAugmentationsOrder: PurchaseAugmentationsOrderSetting.Default, PurchaseAugmentationsOrder: PurchaseAugmentationsOrderSetting.Default,
RemoteFileApiPort: defaultSettings.RemoteFileApiPort,
SaveGameOnFileSave: defaultSettings.SaveGameOnFileSave, SaveGameOnFileSave: defaultSettings.SaveGameOnFileSave,
SuppressBuyAugmentationConfirmation: defaultSettings.SuppressBuyAugmentationConfirmation, SuppressBuyAugmentationConfirmation: defaultSettings.SuppressBuyAugmentationConfirmation,
SuppressFactionInvites: defaultSettings.SuppressFactionInvites, SuppressFactionInvites: defaultSettings.SuppressFactionInvites,

@ -4,6 +4,9 @@ import ReactDOM from "react-dom";
import { TTheme as Theme, ThemeEvents, refreshTheme } from "./Themes/ui/Theme"; import { TTheme as Theme, ThemeEvents, refreshTheme } from "./Themes/ui/Theme";
import { LoadingScreen } from "./ui/LoadingScreen"; import { LoadingScreen } from "./ui/LoadingScreen";
import { initElectron } from "./Electron"; import { initElectron } from "./Electron";
import { newRemoteFileApiConnection } from "./RemoteFileAPI/RemoteFileAPI";
initElectron(); initElectron();
globalThis["React"] = React; globalThis["React"] = React;
globalThis["ReactDOM"] = ReactDOM; globalThis["ReactDOM"] = ReactDOM;
@ -14,6 +17,8 @@ ReactDOM.render(
document.getElementById("root"), document.getElementById("root"),
); );
newRemoteFileApiConnection();
function rerender(): void { function rerender(): void {
refreshTheme(); refreshTheme();
ReactDOM.render( ReactDOM.render(