diff --git a/src/GameOptions/ui/RemoteAPIPage.tsx b/src/GameOptions/ui/RemoteAPIPage.tsx index e0056f0cd..de7ff8f40 100644 --- a/src/GameOptions/ui/RemoteAPIPage.tsx +++ b/src/GameOptions/ui/RemoteAPIPage.tsx @@ -7,34 +7,38 @@ import { isRemoteFileApiConnectionLive, newRemoteFileApiConnection } from "../.. export const RemoteAPIPage = (): React.ReactElement => { const [remoteFileApiHostname, setRemoteFileApiHostname] = useState(Settings.RemoteFileApiAddress); - const [remoteFileApiPort, setRemoteFileApiPort] = useState(Settings.RemoteFileApiPort); + const [hostnameError, setHostnameError] = useState( + isValidConnectionHostname(Settings.RemoteFileApiAddress).message ?? "", + ); + const [remoteFileApiPort, setRemoteFileApiPort] = useState(Settings.RemoteFileApiPort.toString()); + const [portError, setPortError] = useState(isValidConnectionPort(Settings.RemoteFileApiPort).message ?? ""); + + const isValidHostname = hostnameError === ""; + const isValidPort = portError === ""; function handleRemoteFileApiHostnameChange(event: React.ChangeEvent): void { - let newValue = event.target.value.trim(); - // Empty string will be automatically changed to "localhost". - if (newValue === "") { - newValue = "localhost"; - } - if (!isValidConnectionHostname(newValue)) { + const newValue = event.target.value.trim(); + setRemoteFileApiHostname(newValue); + const result = isValidConnectionHostname(newValue); + if (!result.success) { + setHostnameError(result.message); return; } - setRemoteFileApiHostname(newValue); Settings.RemoteFileApiAddress = newValue; + setHostnameError(""); } function handleRemoteFileApiPortChange(event: React.ChangeEvent): void { - let newValue = event.target.value.trim(); - // Empty string will be automatically changed to "0". - if (newValue === "") { - newValue = "0"; - } + const newValue = event.target.value.trim(); + setRemoteFileApiPort(newValue); const port = Number.parseInt(newValue); - // Disallow invalid ports but still allow the player to set port to 0. Setting it to 0 means that RFA is disabled. - if (port !== 0 && !isValidConnectionPort(port)) { + const result = isValidConnectionPort(port); + if (!result.success) { + setPortError(result.message); return; } - setRemoteFileApiPort(port); Settings.RemoteFileApiPort = port; + setPortError(""); } return ( @@ -56,45 +60,55 @@ export const RemoteAPIPage = (): React.ReactElement => { title={ This hostname is used to connect to a Remote API, please ensure that it matches with your Remote API - hostname. Default: localhost. + hostname. +
+ If you use IPv6, you need to wrap it in square brackets. For example: [::1] +
+ Default: localhost.
} > - Hostname: , - }} - value={remoteFileApiHostname} - onChange={handleRemoteFileApiHostnameChange} - placeholder="localhost" - size={"medium"} - /> +
+ Hostname: , + }} + value={remoteFileApiHostname} + onChange={handleRemoteFileApiHostnameChange} + placeholder="localhost" + size={"medium"} + /> + {hostnameError && {hostnameError}} +
-
- This port number is used to connect to a Remote API, please ensure that it matches with your Remote API - server port. Set to 0 to disable the feature. + This port number is used to connect to the Remote API. Please ensure that it matches with your Remote API + server port. +
+ The value must be in the range of [0, 65535]. Set it to 0 to disable the feature. } > - - Port:  - - ), - }} - value={remoteFileApiPort} - onChange={handleRemoteFileApiPortChange} - placeholder="12525" - size={"medium"} - /> +
+ Port: , + }} + value={remoteFileApiPort} + onChange={handleRemoteFileApiPortChange} + placeholder="12525" + size={"medium"} + /> + {portError && {portError}} +
-
- + ); }; diff --git a/src/RemoteFileAPI/Remote.ts b/src/RemoteFileAPI/Remote.ts index 4a69484fd..b3f7d6b7e 100644 --- a/src/RemoteFileAPI/Remote.ts +++ b/src/RemoteFileAPI/Remote.ts @@ -3,6 +3,10 @@ import { RFARequestHandler } from "./MessageHandlers"; import { SnackbarEvents } from "../ui/React/Snackbar"; import { ToastVariant } from "@enums"; +function showErrorMessage(address: string, detail: string) { + SnackbarEvents.emit(`Error with websocket ${address}, details: ${detail}`, ToastVariant.ERROR, 5000); +} + export class Remote { connection?: WebSocket; static protocol = "ws"; @@ -20,11 +24,14 @@ export class Remote { public startConnection(): void { const address = Remote.protocol + "://" + this.ipaddr + ":" + this.port; - this.connection = new WebSocket(address); - - this.connection.addEventListener("error", (e: Event) => - SnackbarEvents.emit(`Error with websocket ${address}, details: ${JSON.stringify(e)}`, ToastVariant.ERROR, 5000), - ); + try { + this.connection = new WebSocket(address); + } catch (error) { + console.error(error); + showErrorMessage(address, String(error)); + return; + } + this.connection.addEventListener("error", (e: Event) => showErrorMessage(address, JSON.stringify(e))); this.connection.addEventListener("message", handleMessageEvent); this.connection.addEventListener("open", () => SnackbarEvents.emit( diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts index 76f8175b3..3ff4dc52b 100644 --- a/src/Settings/Settings.ts +++ b/src/Settings/Settings.ts @@ -4,6 +4,7 @@ import { defaultStyles } from "../Themes/Styles"; import { CursorStyle, CursorBlinking, WordWrapOptions } from "../ScriptEditor/ui/Options"; import { defaultMonacoTheme } from "../ScriptEditor/ui/themes"; import { objectAssert } from "../utils/helpers/typeAssertion"; +import { Result } from "../types"; import { assertAndSanitizeEditorTheme, assertAndSanitizeMainTheme, @@ -21,7 +22,14 @@ import { * - Use non-http schemes in the hostname: "ftp://a.com" * - etc. */ -export function isValidConnectionHostname(hostname: string): boolean { +export function isValidConnectionHostname(hostname: string): Result { + // Return a user-friendly error message. + if (hostname === "") { + return { + success: false, + message: "Hostname cannot be empty", + }; + } /** * We expect a hostname, but the player may mistakenly put other unexpected things. We will try to catch common mistakes: * - Specify a scheme: http or https. @@ -31,23 +39,36 @@ export function isValidConnectionHostname(hostname: string): boolean { try { // Check scheme. if (hostname.startsWith("http://") || hostname.startsWith("https://")) { - return false; + return { + success: false, + message: "Do not specify scheme (e.g., http, https)", + }; } // Parse to a URL with a default scheme. const url = new URL(`http://${hostname}`); // Check port, pathname, and search params. if (url.port !== "" || url.pathname !== "/" || url.search !== "") { - return false; + return { + success: false, + message: "Do not specify port, pathname, or search parameters", + }; } - } catch (e) { - console.error(`Invalid hostname: ${hostname}`, e); - return false; + } catch (error) { + console.error(error); + return { + success: false, + message: `Invalid hostname: ${hostname}`, + }; } - return true; + return { success: true }; } -export function isValidConnectionPort(port: number): boolean { - return Number.isFinite(port) && port > 0 && port <= 65535; +export function isValidConnectionPort(port: number): Result { + // 0 is a special value for port. It's an invalid port, but the player can use it to disable RFA. + if (!Number.isFinite(port) || port < 0 || port > 65535) { + return { success: false, message: "Invalid port" }; + } + return { success: true }; } /** The current options the player has customized to their play style. */ @@ -198,10 +219,10 @@ export const Settings = { * The hostname and port of RFA have not been validated properly, so the save data may contain invalid data. In that * case, we set them to the default value. */ - if (!isValidConnectionHostname(Settings.RemoteFileApiAddress)) { + if (!isValidConnectionHostname(Settings.RemoteFileApiAddress).success) { Settings.RemoteFileApiAddress = "localhost"; } - if (!isValidConnectionPort(Settings.RemoteFileApiPort)) { + if (!isValidConnectionPort(Settings.RemoteFileApiPort).success) { Settings.RemoteFileApiPort = 0; } },