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(