Add network events and switch to tree select

This commit is contained in:
Cooldude2606 committed 2026-01-24 16:27:28 +00:00
1 parent 07f501a67a
commit 6f68cf74a7
13 files changed
+184 -79

No files matched your search

@@ -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;
+2
View File
@@ -105,6 +105,8 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
+17 -7
View File
@@ -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<lib.FullVersion, string>();
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}`);
+1
View File
@@ -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";
@@ -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<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
+12 -8
View File
@@ -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<typeof ExternalFactorioVersionSchema>
/**
* Fetch all factorio versions that support headless
@@ -48,15 +51,16 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\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`,
});
}
}
+1
View File
@@ -35,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.SystemInfoUpdateEvent,
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
subscriptions.SubscriptionRequest,
+6
View File
@@ -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",
});
+2
View File
@@ -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": ".",
+3 -1
View File
@@ -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<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"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)) {
@@ -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<InputComponentProps, "fieldDefinition"> & { fieldDefinition?: lib.FieldDefinition; className?: string }
export default function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(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 <Select
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
return <TreeSelect
showSearch
optionFilterProp="label"
style={{ minWidth: 175 }}
className={props.className}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
options={options}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
placeholder="Select Factorio version"
/>;
}
@@ -1,50 +1,71 @@
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 InputTargetVersion(
props: Omit<InputComponentProps, "fieldDefinition"> & { fieldDefinition?: lib.FieldDefinition; className?: string }
export default function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(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]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
options.unshift({ label: "latest", value: "latest" });
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
return <Select
return <TreeSelect
showSearch
optionFilterProp="label"
style={{ minWidth: 175 }}
className={props.className}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
options={options}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
placeholder="Select Factorio version"
/>;
}
+7
View File
@@ -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();