diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 332a19ed..905ef956 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -126,6 +126,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); + this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -1222,6 +1223,10 @@ export default class ControlConnection extends BaseConnection { this.connector._socket!.clusterio_ignore_dump = true; this._controller.debugEvents.on("message", this.ws_dumper); } + + async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { + return await this._controller.factorioVersions.get(request.maxAgeMs); + } } module.exports = ControlConnection; diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a22181b1..7c366a73 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -105,6 +105,8 @@ export default class Controller { // Cache for mod portal requests modPortalCache = new Map(); + // Cache for factorio versions + factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions); static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 4ffda317..dc45d101 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) { * @returns Array with path to data dir and version found. * @internal */ -async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) { +async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt")); if (simpleVersion !== null) { - if (simpleVersion === targetVersion || targetVersion === "latest") { - return [path.join(factorioDir, "data"), simpleVersion]; + if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") { + if (lib.isFullVersion(simpleVersion)) { + return [path.join(factorioDir, "data"), simpleVersion]; + } } throw new Error( @@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion ); } - let versions = new Map(); + let versions = new Map(); for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt")); - if (version === null) { + if (version === null || !lib.isFullVersion(version)) { continue; } @@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion throw new Error(`Unable to find any Factorio install in ${factorioDir}`); } + if (lib.isPartialVersion(targetVersion)) { + const sorted = [...versions.keys()].sort(versionOrder); + const latest = sorted.find(version => version.startsWith(targetVersion)); + if (latest) { + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; + } + } + if (targetVersion === "latest") { - let latest = [...versions.keys()].sort(versionOrder)[0]; - return [path.join(factorioDir, versions.get(latest), "data"), latest]; + const latest = [...versions.keys()].sort(versionOrder)[0]; + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; } throw new Error(`Unable to find Factorio version ${targetVersion}`); diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 31fd837f..58c0debf 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -15,3 +15,4 @@ export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index dfcc368c..aa4232a5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum, StringKey, plainJson } from "./composites"; import { levels } from "../logging"; import { ControllerConfig, HostConfig } from "../config"; +import { ExternalFactorioVersionSchema } from "../external"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; @@ -391,3 +392,25 @@ export class DebugWsMessageEvent { return new this(json.direction, json.content); } } + +export class FactorioVersionsRequest { + declare ["constructor"]: typeof FactorioVersionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.external.get_factorio_versions" as const; + + constructor( + public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes + ) {} + + static jsonSchema = Type.Object({ + "maxAgeMs": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.maxAgeMs); + } + + static Response = plainJson(Type.Array(ExternalFactorioVersionSchema)); +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts index 9760460b..b9041fc0 100644 --- a/packages/lib/src/external/FactorioVersions.ts +++ b/packages/lib/src/external/FactorioVersions.ts @@ -1,15 +1,18 @@ -import { FullVersion, isFullVersion } from "../data"; +import { Static, Type } from "@sinclair/typebox"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data"; const ARCHIVE_URL = "https://factorio.com/download/archive"; /** * Represents the parsed result of a factorio version */ -export type ExternalFactorioVersion = { - stable: boolean; - version: FullVersion; - downloadUrl: string; -}; +export const ExternalFactorioVersionSchema = Type.Object({ + stable: Type.Boolean(), + version: FullVersionSchema, + headlessUrl: Type.String(), +}); + +export type ExternalFactorioVersion = Static /** * Fetch all factorio versions that support headless @@ -48,15 +51,16 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] { const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; let match: RegExpExecArray | null; + const minimumMultiplayerVersion = integerFullVersion("0.12.35"); while ((match = anchorRegex.exec(html)) !== null) { const kind = match.groups?.kind; const version = match.groups?.version; - if (kind && version && isFullVersion(version)) { + if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) { versions.set(version, { version: version, stable: kind === "stable", - downloadUrl: `${ARCHIVE_URL}/${version}`, + headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`, }); } } diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 6e29a75f..299cf3ae 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -35,6 +35,7 @@ export const dataClasses: (RequestClass | EventClass) controller.SystemInfoUpdateEvent, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, + controller.FactorioVersionsRequest, subscriptions.SubscriptionRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index fb7395b9..0507a2a4 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -568,3 +568,9 @@ definePermission({ title: "Dump WebSocket", description: "Dump all WebSocket communicatation from the controller.", }); + +definePermission({ + name: "core.external.get_factorio_versions", + title: "Get factorio versions", + description: "Get list of all factorio versions from Wube", +}); diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 059b1ce6..05554ac8 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -6,6 +6,7 @@ "./src/api.ts", "./src/config/*.ts", "./src/data/*.ts", + "./src/external/*.ts", "./src/datastore.ts", "./src/errors.ts", "./src/factorio/exchange_string.ts", @@ -25,6 +26,7 @@ "./src/ExponentialBackoff.ts", "./src/RateLimiter.ts", "./src/TypedEventEmitter.ts", + "./src/ValueCache.ts", ], "compilerOptions": { "rootDir": ".", diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index ce025ee6..3b64bbd5 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -10,6 +10,7 @@ import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; import InputTargetVersion from "./components/InputTargetVersion"; +import InputPartialVersion from "./components/InputPartialVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -110,9 +111,10 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { + "partial_version": InputPartialVersion, + "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, - "target_version": InputTargetVersion, }; for (let [pluginName, plugin] of plugins) { for (const [name, Component] of Object.entries(plugin.inputComponents)) { diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx index ac38da0e..250b5de6 100644 --- a/packages/web_ui/src/components/InputPartialVersion.tsx +++ b/packages/web_ui/src/components/InputPartialVersion.tsx @@ -1,47 +1,68 @@ -import React from "react"; -import { Select } from "antd"; +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; -import { InputComponentProps } from "../BaseWebPlugin"; import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; -// Hardcoded list for step 1 -const FACTORIO_VERSIONS = [ - "2.0", - "1.1", - "1.0", - "0.18", - "0.17", - "0.16", - "0.15", - "0.14", - "0.13", -] as const; +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; -const defaultFieldDefinition: lib.FieldDefinition = { - type: "string", - optional: false, -}; - -export default function InputPartialVersion( - props: Omit & { fieldDefinition?: lib.FieldDefinition; className?: string } +export default function InputPartialVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } ) { const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); - const options = FACTORIO_VERSIONS.map(version => ({ - label: version, - value: version, - })); + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); - return props.onChange(value ?? null)} value={props.value} - options={options} + treeData={tree} allowClear={fieldDefinition.optional} + className={props.className} disabled={props.disabled} - placeholder="Select Factorio version" />; } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 62363569..dbd02325 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -69,6 +69,9 @@ export class Control extends lib.Link { users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this); + /** Cache for factorio versions to avoid repeat calls to the controller */ + factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this)); + declare connector: ControlConnector; constructor( @@ -105,6 +108,10 @@ export class Control extends lib.Link { this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } + requestFactorioVersions() { + return this.send(new lib.FactorioVersionsRequest()); + } + async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) { this.accountRoles = event.roles??null; this.emitAccountUpdate();