From 9fb3d6d8db02bcfaca5a70c8834d671f05cac211 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Thu, 15 Sep 2022 23:32:53 +0200 Subject: [PATCH] Implement mod pack storage Add ModPack data type with corresponding storage on the master, messages to create, update and delete them, as well as commands to invoke them. The ModPack data type contains a list of mods with versions and optional sha1 hashes, and mod settings to be used. --- packages/ctl/ctl.js | 2 + packages/ctl/src/commands.js | 203 +++++++++++++++++++++ packages/lib/command.js | 34 ++++ packages/lib/data/ModPack.js | 215 +++++++++++++++++++++++ packages/lib/data/index.js | 3 +- packages/lib/link/messages.js | 73 ++++++++ packages/lib/plugin.js | 13 ++ packages/lib/users.js | 33 ++++ packages/master/src/ControlConnection.js | 62 +++++++ packages/master/src/Master.js | 39 ++++ packages/master/src/SlaveConnection.js | 5 + packages/web_ui/src/model/mod_pack.jsx | 78 ++++++++ packages/web_ui/src/util/websocket.jsx | 66 ++++++- test/integration/clusterio.js | 65 +++++++ test/integration/index.js | 5 + test/lib/data/ModPack.js | 94 ++++++++++ 16 files changed, 987 insertions(+), 3 deletions(-) create mode 100644 packages/lib/data/ModPack.js create mode 100644 packages/web_ui/src/model/mod_pack.jsx create mode 100644 test/lib/data/ModPack.js diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index d054f89b..b4cfaf90 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -89,6 +89,8 @@ class Control extends libLink.Link { async saveListUpdateEventHandler() { } + async modPackUpdateEventHandler() { } + async modUpdateEventHandler() { } async userUpdateEventHandler() { } diff --git a/packages/ctl/src/commands.js b/packages/ctl/src/commands.js index 2d09982c..9dc00bcb 100644 --- a/packages/ctl/src/commands.js +++ b/packages/ctl/src/commands.js @@ -14,6 +14,7 @@ const libLink = require("@clusterio/lib/link"); const libErrors = require("@clusterio/lib/errors"); const libConfig = require("@clusterio/lib/config"); const libCommand = require("@clusterio/lib/command"); +const libData = require("@clusterio/lib/data"); const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); const libHelpers = require("@clusterio/lib/helpers"); @@ -860,6 +861,207 @@ instanceCommands.add(new libCommand.Command({ }, })); +const modPackCommands = new libCommand.CommandTree({ name: "mod-pack", description: "Mod Pack" }); +modPackCommands.add(new libCommand.Command({ + definition: ["show ", "Show details of mod pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to show", type: "string" }); + }], + handler: async function(args, control) { + let response = await libLink.messages.getModPack.send(control, { + id: await libCommand.resolveModPack(control, args.modPack), + }); + + for (let [field, value] of Object.entries(response.mod_pack)) { + if (field === "mods") { + print(`${field}:`); + for (let entry of value) { + print(` ${entry.name} ${entry.version}${entry.hash ? ` (${entry.hash})` : ""}`); + } + } else if (field === "settings") { + print(`${field}:`); + for (let [scope, settings] of Object.entries(value)) { + print(` ${scope}:`); + for (let [setting, settingValue] of Object.entries(settings)) { + print(` ${setting}: ${JSON.stringify(settingValue.value)}`); + } + } + + } else { + print(`${field}: ${value}`); + } + } + }, +})); + +modPackCommands.add(new libCommand.Command({ + definition: [["list", "l"], "List mod packs in the cluster"], + handler: async function(args, control) { + let response = await libLink.messages.listModPacks.send(control); + let fields = ["id", "name", "factorio_version"]; + for (let entry of response.list) { + for (let field of Object.keys(entry)) { + if (!fields.includes(field)) { + delete entry[field]; + } + } + } + print(asTable(response.list)); + }, +})); + +function setModPackSettings(modPack, args) { + function doSettings(settings, cast) { + for (let i = 0; i + 2 < settings.length; i += 3) { + const [scope, name, value] = settings.slice(i, i + 3); + if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { + throw new libErrors.CommandError( + `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` + ); + } + modPack.settings[scope].set(name, { value: cast(value) }); + } + } + + doSettings(args.boolSetting || [], value => { + if (!["true", "false"].includes(value)) { + throw new libErrors.CommandError(`boolean value must be one of true or false, not ${value}`); + } + return value === "true"; + }); + doSettings(args.intSetting || [], value => { + // eslint-disable-next-line radix + let number = Number.parseInt(value); + if (isNaN(number)) { + throw new libErrors.CommandError(`int value must be an integer, not ${value}`); + } + return number; + }); + doSettings(args.doubleSetting || [], value => { + let number = Number.parseFloat(value); + if (isNaN(number)) { + throw new libErrors.CommandError(`double value must be a number, not ${value}`); + } + return number; + }); + doSettings(args.stringSetting || [], value => value); +} + +function setModPackMods(modPack, mods) { + for (let mod of mods || []) { + const [name, version, sha1] = mod.split(":"); + if (!version) { + throw new libErrors.CommandError("Added mod must be formatted as name:version or name:version:sha1"); + } + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new libErrors.CommandError("version must match the format digit.digit.digit"); + } + if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { + throw new libErrors.CommandError("sha1 must be a 40 digit lower case hex string"); + } + modPack.mods.set(name, { name, version, hash: sha1 ? `sha1:${sha1}` : undefined }); + } +} + +modPackCommands.add(new libCommand.Command({ + definition: ["create ", "Create mod-pack", (yargs) => { + yargs.positional("name", { describe: "Name of mod pack to create", type: "string" }); + yargs.positional("factorio-version", { describe: "Version of factorio the mod pack is for", type: "string" }); + yargs.options({ + "description": { describe: "Description for mod pack", type: "string" }, + "mods": { describe: "Mods in the form of name:version[:sha1]", array: true, type: "string" }, + "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, + "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, + "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, + "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, + }); + }], + handler: async function(args, control) { + const modPack = new libData.ModPack(); + modPack.name = args.name; + if (args.description) { modPack.description = args.description; } + if (args.factorioVersion) { + if (!/^\d+\.\d+(\.\d+)?$/.test(args.factorioVersion)) { + throw new libErrors.CommandError("factorio-version must match the format digit.digit[.digit]"); + } + modPack.factorioVersion = args.factorioVersion; + } + setModPackMods(modPack, args.mods); + setModPackSettings(modPack, args); + await libLink.messages.createModPack.send(control, { mod_pack: modPack.toJSON() }); + print(`Created mod pack ${modPack.name} (${modPack.id})`); + }, +})); + +modPackCommands.add(new libCommand.Command({ + definition: ["edit ", "Edit mod-pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to remove from", type: "string" }); + yargs.options({ + "name": { describe: "New name for mod pack", type: "string" }, + "description": { describe: "New description for mod pack", type: "string" }, + "factorio-version": { describe: "Set version of factorio the mod pack is for", type: "string" }, + "add-mods": { describe: "Mods in the form of name:version[:sha1] to add", array: true, type: "string" }, + "remove-mods": { describe: "Name of mods to remove", array: true, type: "string" }, + "bool-setting": { describe: "Set boolean setting", array: true, nargs: 3, type: "string" }, + "int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" }, + "double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" }, + "string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" }, + "remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" }, + }); + }], + handler: async function(args, control) { + let response = await libLink.messages.getModPack.send(control, { + id: await libCommand.resolveModPack(control, args.modPack), + }); + let modPack = new libData.ModPack(response.mod_pack); + + if (args.name) { modPack.name = args.name; } + if (args.description) { modPack.description = args.description; } + if (args.factorioVersion) { + if (!/^\d+\.\d+(\.\d+)?$/.test(args.factorioVersion)) { + throw new libErrors.CommandError("factorio-version must match the format digit.digit[.digit]"); + } + modPack.factorioVersion = args.factorioVersion; + } + + setModPackMods(modPack, args.addMods); + for (let name of args.removeMods || []) { + if (modPack.mods.has(name)) { + modPack.mods.delete(name); + } else { + logger.warn(`Mod ${name} did not exist on ${modPack.name}`); + } + } + + setModPackSettings(modPack, args); + if (args.removeSetting) { + for (let i = 0; i + 1 < args.removeSetting.length; i += 2) { + const [scope, name] = settings.slice(i, i + 2); + if (!["startup", "runtime-global", "runtime-per-user"].includes(scope)) { + throw new libErrors.CommandError( + `Setting scope must be one of startup, runtime-gloabl, or runtime-per-user, not ${scope}` + ); + } + if (modPack.settings[scope].has(name)) { + modPack.settings[scope].delete(name); + } else { + logger.warn(`Mod setting ${scope} ${name} did not exist on ${modPack.name}`); + } + } + } + await libLink.messages.updateModPack.send(control, { mod_pack: modPack.toJSON() }); + }, +})); + +modPackCommands.add(new libCommand.Command({ + definition: ["delete ", "Delete mod-pack", (yargs) => { + yargs.positional("mod-pack", { describe: "Mod pack to delete", type: "string" }); + }], + handler: async function(args, control) { + const id = await libCommand.resolveModPack(control, args.modPack); + await libLink.messages.deleteModPack.send(control, { id }); + }, +})); + const modCommands = new libCommand.CommandTree({ name: "mod", description: "Manage uploaded mods" }); modCommands.add(new libCommand.Command({ definition: ["show ", "Show details for a mod stored in the cluster", (yargs) => { @@ -1339,6 +1541,7 @@ async function registerCommands(controlPlugins, yargs) { rootCommands.add(masterCommands); rootCommands.add(slaveCommands); rootCommands.add(instanceCommands); + rootCommands.add(modPackCommands); rootCommands.add(modCommands); rootCommands.add(permissionCommands); rootCommands.add(roleCommands); diff --git a/packages/lib/command.js b/packages/lib/command.js index 133a8805..f0738e8b 100644 --- a/packages/lib/command.js +++ b/packages/lib/command.js @@ -198,6 +198,39 @@ async function resolveInstance(client, instanceName) { return instanceId; } +/** + * Resolve a string to a mod pack ID + * + * Resolevs a string with either a mod pack name or an id into an integer + * with the mod pack ID. + * + * @param {module:lib/link.Link} client - + * link to master server to query mod pack on. + * @param {string} modPackName - string with name or id of mod pack. + * @returns {Promise} mod pack ID. + * @static + */ +async function resolveModPack(client, modPackName) { + let modPackId; + if (/^-?\d+/.test(modPackName)) { + modPackId = parseInt(modPackName, 10); + } else { + let response = await libLink.messages.listModPacks.send(client); + for (let mod_pack of response.list) { + if (mod_pack["name"] === modPackName) { + modPackId = mod_pack["id"]; + break; + } + } + + if (modPackId === undefined) { + throw new libErrors.CommandError(`No mod pack named ${modPackName}`); + } + } + + return modPackId; +} + /** * Retrieve role object from string * @@ -244,5 +277,6 @@ module.exports = { resolveSlave, resolveInstance, + resolveModPack, retrieveRole, }; diff --git a/packages/lib/data/ModPack.js b/packages/lib/data/ModPack.js new file mode 100644 index 00000000..9dd09154 --- /dev/null +++ b/packages/lib/data/ModPack.js @@ -0,0 +1,215 @@ +"use strict"; +const zlib = require("zlib"); + +const libSchema = require("../schema"); + + +/** + * A setting for a mod. + * @typedef {object} module:lib/data/ModPack~ModSetting + * @property {boolean|number|string} value - Value of the given mod setting. + */ +const ModSettingJsonSchema = { + type: "object", + additionalProperties: false, + required: ["value"], + properties: { + "value": { type: ["boolean", "number", "string"] }, + }, +}; + +const ModSettingsJsonSchema = { + type: "object", + additionalProperties: ModSettingJsonSchema, +}; + +/** + * A mod in a mod pack + * @typedef {object} module:lib/data/ModPack~ModRecord + * @property {string} name - name of the mod. + * @property {string} version - version of the mod. + * @property {string} hash - hash of the zip file. + */ +const ModRecordJsonSchema = { + type: "object", + additionalProperties: false, + required: ["name", "version"], + properties: { + "name": { type: "string" }, + "version": { type: "string" }, + "hash": { type: "string" }, + }, +}; + +/** + * Factorio Mod Pack + * + * Tracks mods and settings for a collection of Factorio mods. + * @alias module:lib/data/ModPack + */ +class ModPack { + /** + * Id of this mod pack + * @type {number} + */ + id; + + /** + * Name of this mod pack. + * @type {string} + */ + name = ""; + + /** + * Description for this modpcak. + * @type {string} + */ + description = ""; + + /** + * Version of Factorio this mod pack is for + * @type {string} + */ + factorioVersion = "1.1.0"; + + /** + * Integer representation of the factorioVersion + * @type {number} + */ + get integerFactorioVersion() { + const [major, minor, sub] = this.factorioVersion.split(".").map(n => Number.parseInt(n, 10)); + return major * 0x100000000 + minor * 0x10000 + (sub || 0); // Can't use bitwise here because this is 48-bits. + } + + /** + * Mods included in this mod pack + * @type {Map} + */ + mods = new Map(); + + /** + * Mod settings for this mod pack + * @type {Object>=} + */ + settings = { + "startup": new Map(), + "runtime-global": new Map(), + "runtime-per-user": new Map(), + }; + + /** + * True if this mod pack has been deleted from the list of mod packs. + * @type {boolean} + */ + isDeleted = false; + + static jsonSchema = { + type: "object", + additionalProperties: false, + properties: { + "id": { type: "integer" }, + "name": { type: "string" }, + "description": { type: "string" }, + "factorio_version": { type: "string" }, + "mods": { + type: "array", + items: ModRecordJsonSchema, + }, + "settings": { + type: "object", + additionalProperties: false, + required: ["startup", "runtime-global", "runtime-per-user"], + properties: { + "startup": ModSettingsJsonSchema, + "runtime-global": ModSettingsJsonSchema, + "runtime-per-user": ModSettingsJsonSchema, + }, + }, + "is_deleted": { type: "boolean" }, + }, + }; + + static validate = libSchema.compile(this.jsonSchema); + + constructor(json = {}) { + if (json.id) { + this.id = json.id; + } else { + this.id = Math.random() * 2**31 | 0; + } + if (json.name) { this.name = json.name; } + if (json.description) { this.description = json.description; } + if (json.factorio_version) { this.factorioVersion = json.factorio_version; } + if (json.mods) { this.mods = new Map(json.mods.map(m => [m.name, m])); } + if (json.settings) { + this.settings = { + "startup": new Map(Object.entries(json.settings["startup"])), + "runtime-global": new Map(Object.entries(json.settings["runtime-global"])), + "runtime-per-user": new Map(Object.entries(json.settings["runtime-per-user"])), + }; + } + if (json.is_deleted) { this.isDeleted = json.is_deleted; } + } + + toJSON() { + let json = { + id: this.id, + }; + if (this.name) { json.name = this.name; } + if (this.description) { json.description = this.description; } + json.factorio_version = this.factorioVersion; + if (this.mods.size) { json.mods = [...this.mods.values()]; } + if (this.settings) { + json.settings = { + "startup": Object.fromEntries(this.settings["startup"]), + "runtime-global": Object.fromEntries(this.settings["runtime-global"]), + "runtime-per-user": Object.fromEntries(this.settings["runtime-per-user"]), + }; + } + if (this.isDeleted) { json.is_deleted = this.isDeleted; } + return json; + } + + toModPackString(includeHash = false) { + const json = this.toJSON(); + delete json.id; + if (json.mods && !includeHash) { + json.mods = json.mods.map(m => { + m = { ...m }; + delete m.hash; + return m; + }); + } + + // eslint-disable-next-line node/no-sync + let buf = zlib.deflateSync(JSON.stringify(json)); + return buf.toString("base64"); + } + + static fromModPackString(modPackString) { + let buf = Buffer.from(modPackString, "base64"); + try { + // eslint-disable-next-line node/no-sync + buf = zlib.inflateSync(buf); + } catch (err) { + if (err.code.startsWith("Z_")) { + throw new Error("Malformed mod pack string: zlib inflate failed"); + } + } + + let json; + try { + json = JSON.parse(buf); + } catch (err) { + throw new Error(`Malformed mod pack string: ${err.message}`); + } + + if (!this.validate(json)) { + throw new Error("Malformed mod pack string: Schema validation failed"); + } + + return new this(json); + } +} + +module.exports = ModPack; diff --git a/packages/lib/data/index.js b/packages/lib/data/index.js index 17402e53..2b280cc2 100644 --- a/packages/lib/data/index.js +++ b/packages/lib/data/index.js @@ -5,8 +5,9 @@ */ "use strict"; const ModInfo = require("./ModInfo"); +const ModPack = require("./ModPack"); module.exports = { ModInfo, + ModPack, }; - diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index c066a0cb..61b00ce1 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -710,6 +710,71 @@ messages.sendRcon = new Request({ }, }); +messages.getModPack = new Request({ + type: "get_mod_pack", + links: ["control-master", "instance-slave", "slave-master"], + permission: "core.mod_pack.get", + forwardTo: "master", + requestProperties: { + "id": { type: "integer" }, + }, + responseProperties: { + "mod_pack": libData.ModPack.jsonSchema, + }, +}); + +messages.listModPacks = new Request({ + type: "list_mod_packs", + links: ["control-master"], + permission: "core.mod_pack.list", + responseProperties: { + "list": { + type: "array", + items: libData.ModPack.jsonSchema, + }, + }, +}); + +messages.createModPack = new Request({ + type: "create_mod_pack", + links: ["control-master"], + permission: "core.mod_pack.create", + requestProperties: { + "mod_pack": libData.ModPack.jsonSchema, + }, +}); + +messages.updateModPack = new Request({ + type: "update_mod_pack", + links: ["control-master"], + permission: "core.mod_pack.update", + requestProperties: { + "mod_pack": libData.ModPack.jsonSchema, + }, +}); + +messages.deleteModPack = new Request({ + type: "delete_mod_pack", + links: ["control-master"], + permission: "core.mod_pack.delete", + requestProperties: { + "id": { type: "integer" }, + }, +}); + +messages.setModPackSubscriptions = new Request({ + type: "set_mod_pack_subscriptions", + links: ["control-master"], + permission: "core.mod_pack.subscribe", + requestProperties: { + "all": { type: "boolean" }, + "mod_pack_ids": { + type: "array", + items: { type: "integer" }, + }, + }, +}); + messages.getMod = new Request({ type: "get_mod", links: ["control-master"], @@ -1325,6 +1390,14 @@ messages.saveListUpdate = new Event({ }, }); +messages.modPackUpdate = new Event({ + type: "mod_pack_update", + links: ["master-control"], + eventProperties: { + "mod_pack": libData.ModPack.jsonSchema, + }, +}); + messages.modUpdate = new Event({ type: "mod_update", links: ["master-control"], diff --git a/packages/lib/plugin.js b/packages/lib/plugin.js index 22fb96e4..38a7675d 100644 --- a/packages/lib/plugin.js +++ b/packages/lib/plugin.js @@ -493,6 +493,19 @@ class BaseMasterPlugin { */ async onPrepareSlaveDisconnect(connection) { } + /** + * Called when a mod pack is updated + * + * Invoked when a mod pack has been added, updated or deleted in the + * cluster. + * + * If the mod pack has been deleted its `.isDeleted` property will be + * true. + * + * @param {module:lib/data/ModPack} modPack - Mod pack that updated. + */ + async onModPackUpdated(modPack) { } + /** * Called when a mod stored on the master server is updated * diff --git a/packages/lib/users.js b/packages/lib/users.js index 300393d3..f6ef8558 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -511,6 +511,39 @@ definePermission({ description: "Send arbitrary RCON commands to instances.", }); +definePermission({ + name: "core.mod_pack.get", + title: "Retrieve mod pack", + description: "Get the details of a mod pack in the cluster.", + grantByDefault: true, +}); +definePermission({ + name: "core.mod_pack.list", + title: "List mod packs", + description: "Get the full list of a mod packs in the cluster.", + grantByDefault: true, +}); +definePermission({ + name: "core.mod_pack.subscribe", + title: "Subscribe to mod pack updates", + description: "Subscribe to be notified on updates on the details of mod packs.", + grantByDefault: true, +}); +definePermission({ + name: "core.mod_pack.create", + title: "Create mod pack", + description: "Create new mod pack in the cluster.", +}); +definePermission({ + name: "core.mod_pack.update", + title: "Update mod pack", + description: "Edit existing mod pack definition in the cluster.", +}); +definePermission({ + name: "core.mod_pack.delete", + title: "Delete mod pack", + description: "Delete mod pack definition in the cluster.", +}); definePermission({ name: "core.mod.get", title: "Get mods", diff --git a/packages/master/src/ControlConnection.js b/packages/master/src/ControlConnection.js index a090712b..12472705 100644 --- a/packages/master/src/ControlConnection.js +++ b/packages/master/src/ControlConnection.js @@ -5,7 +5,9 @@ const jwt = require("jsonwebtoken"); const path = require("path"); const libConfig = require("@clusterio/lib/config"); +const libData = require("@clusterio/lib/data"); const libErrors = require("@clusterio/lib/errors"); +const libHelpers = require("@clusterio/lib/helpers"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); @@ -55,6 +57,11 @@ class ControlConnection extends BaseConnection { instance_ids: [], }; + this.modPackSubscriptions = { + all: false, + mod_pack_ids: [], + }; + this.modSubscriptions = { all: false, mod_names: [], @@ -465,6 +472,61 @@ class ControlConnection extends BaseConnection { return { save: result.save }; } + async getModPackRequestHandler(message) { + let { id } = message.data; + let modPack = this._master.modPacks.get(id); + if (!modPack) { + throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); + } + return { mod_pack: modPack.toJSON() }; + } + + async listModPacksRequestHandler(message) { + return { list: [...this._master.modPacks.values()].map(pack => pack.toJSON()) }; + } + + async setModPackSubscriptionsRequestHandler(message) { + this.modPackSubscriptions = message.data; + } + + async createModPackRequestHandler(message) { + let modPack = new libData.ModPack(message.data.mod_pack); + if (this._master.modPacks.has(modPack.id)) { + throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} already exist`); + } + this._master.modPacks.set(modPack.id, modPack); + this._master.modPackUpdated(modPack); + } + + async updateModPackRequestHandler(message) { + let modPack = new libData.ModPack(message.data.mod_pack); + if (!this._master.modPacks.has(modPack.id)) { + throw new libErrors.RequestError(`Mod pack with ID ${modPack.id} does not exist`); + } + this._master.modPacks.set(modPack.id, modPack); + this._master.modPackUpdated(modPack); + } + + async deleteModPackRequestHandler(message) { + let { id } = message.data; + let modPack = this._master.modPacks.get(id); + if (!modPack) { + throw new libErrors.RequestError(`Mod pack with ID ${id} does not exist`); + } + modPack.isDeleted = true; + this._master.modPacks.delete(id); + this._master.modPackUpdated(modPack); + } + + modPackUpdated(modPack) { + if ( + this.modPackSubscriptions.all + || this.modPackSubscriptions.mod_pack_ids.includes(modPack.id) + ) { + libLink.messages.modPackUpdate.send(this, { mod_pack: modPack.toJSON() }); + } + } + async getModRequestHandler(message) { let { name, version } = message.data; let key = `${name}_${version}`; diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 994916c1..abc67bcc 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -66,6 +66,12 @@ class Master { */ this.instances = null; + /** + * Mapping of mod pack id to mod pack + * @type {Map} + */ + this.modPacks = null; + /** * Mapping of mod names to mod infos * @type {Map} @@ -168,6 +174,7 @@ class Master { this.slaves = await Master.loadSlaves(path.join(databaseDirectory, "slaves.json")); this.instances = await Master.loadInstances(path.join(databaseDirectory, "instances.json")); + this.modPacks = await Master.loadModPacks(path.join(databaseDirectory, "mod-packs.json")); this.userManager = new UserManager(this.config); await this.userManager.load(path.join(databaseDirectory, "users.json")); this.exportManifest = await Master.loadJsonObject(path.join(databaseDirectory, "export_manifest.json")); @@ -295,6 +302,10 @@ class Master { await Master.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances); } + if (this.modPacks) { + await Master.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks); + } + if (this.userManager) { await this.userManager.save(path.join(databaseDirectory, "users.json")); } @@ -371,6 +382,23 @@ class Master { await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4)); } + static async loadModPacks(filePath) { + let json; + try { + json = JSON.parse(await fs.readFile(filePath)); + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + return new Map(); + } + return new Map(json.map(e => [e.id, new libData.ModPack(e)])); + } + + static async saveModPacks(filePath, modPacks) { + await libFileOps.safeOutputFile(filePath, JSON.stringify([...modPacks.values()])); + } + static async loadModInfos(modsDirectory) { let mods = new Map(); for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) { @@ -562,6 +590,17 @@ class Master { } } + modPackUpdated(modPack) { + for (let controlConnection of this.wsServer.controlConnections) { + if (controlConnection.connector.closing) { + continue; + } + controlConnection.modPackUpdated(modPack); + } + + libPlugin.invokeHook(this.plugins, "onModPackUpdated", modPack); + } + modUpdated(mod) { for (let controlConnection of this.wsServer.controlConnections) { if (controlConnection.connector.closing) { diff --git a/packages/master/src/SlaveConnection.js b/packages/master/src/SlaveConnection.js index 35564264..4f9203ed 100644 --- a/packages/master/src/SlaveConnection.js +++ b/packages/master/src/SlaveConnection.js @@ -3,6 +3,7 @@ const libConfig = require("@clusterio/lib/config"); const libLink = require("@clusterio/lib/link"); const { logger } = require("@clusterio/lib/logging"); const libPlugin = require("@clusterio/lib/plugin"); +const libErrors = require("@clusterio/lib/errors"); const PlayerStats = require("@clusterio/lib/PlayerStats"); const BaseConnection = require("./BaseConnection"); @@ -167,6 +168,10 @@ class SlaveConnection extends BaseConnection { this._master.saveListUpdate(message.data); } + async getModPackRequestHandler(message) { + throw new libErrors.RequestError("Not implemented"); + } + async logMessageEventHandler(message) { this._master.clusterLogger.log({ ...message.data.info, diff --git a/packages/web_ui/src/model/mod_pack.jsx b/packages/web_ui/src/model/mod_pack.jsx new file mode 100644 index 00000000..4983cf54 --- /dev/null +++ b/packages/web_ui/src/model/mod_pack.jsx @@ -0,0 +1,78 @@ +import { useContext, useEffect, useState } from "react"; + +import { libData, libLink, libLogging } from "@clusterio/lib"; + +import ControlContext from "../components/ControlContext"; + +const { logger } = libLogging; + +export function useModPack(id) { + let control = useContext(ControlContext); + let [modPack, setModPack] = useState({ loading: true }); + + function updateModPack() { + libLink.messages.getModPack.send(control, { id }).then(result => { + setModPack(new libData.ModPack(result.mod_pack)); + }).catch(err => { + logger.error(`Failed to get mod pack: ${err}`); + setModPack({ missing: true }); + }); + } + + useEffect(() => { + if (typeof id !== "number") { + setModPack({ missing: true }); + return undefined; + } + updateModPack(); + + control.onModPackUpdate(id, setModPack); + return () => { + control.offModPackUpdate(id, setModPack); + }; + }, [id]); + + return [modPack]; +} + +export function useModPackList() { + let control = useContext(ControlContext); + let [modPackList, setModPackList] = useState([]); + + function updateModPackList() { + libLink.messages.listModPacks.send(control).then(result => { + setModPackList(result.list.map(pack => new libData.ModPack(pack))); + }).catch(err => { + logger.error(`Failed to list mod packs:\n${err}`); + }); + } + + useEffect(() => { + updateModPackList(); + + function updateHandler(newModPack) { + setModPackList(oldList => { + let newList = oldList.concat(); + let index = newList.findIndex(u => u.id === newModPack.id); + if (!newModPack.isDeleted) { + if (index !== -1) { + newList[index] = newModPack; + } else { + newList.push(newModPack); + } + } else if (index !== -1) { + newList.splice(index, 1); + } + return newList; + }); + } + + control.onModPackUpdate(null, updateHandler); + return () => { + control.offModPackUpdate(null, updateHandler); + }; + }, []); + + return [modPackList]; + +} diff --git a/packages/web_ui/src/util/websocket.jsx b/packages/web_ui/src/util/websocket.jsx index 7ac65b0f..fd9b56a3 100644 --- a/packages/web_ui/src/util/websocket.jsx +++ b/packages/web_ui/src/util/websocket.jsx @@ -86,6 +86,7 @@ export class Control extends libLink.Link { this.slaveUpdateHandlers = new Map(); this.instanceUpdateHandlers = new Map(); this.saveListUpdateHandlers = new Map(); + this.modPackUpdateHandlers = new Map(); this.modUpdateHandlers = new Map(); this.userUpdateHandlers = new Map(); this.logHandlers = new Map(); @@ -103,6 +104,9 @@ export class Control extends libLink.Link { this.updateSaveListSubscriptions().catch(err => logger.error( `Unexpected error updating save list subscriptions:\n${err.stack}` )); + this.updateModPackSubscriptions().catch(err => logger.error( + `Unexpected error updating mod pack subscriptions:\n${err.stack}` + )); this.updateModSubscriptions().catch(err => logger.error( `Unexpected error updating mod subscriptions:\n${err.stack}` )); @@ -323,6 +327,64 @@ export class Control extends libLink.Link { }); } + async modPackUpdateEventHandler(message) { + let modPack = new libData.ModPack(message.data.mod_pack); + let handlers = [].concat( + this.modPackUpdateHandlers.get(null) || [], + this.modPackUpdateHandlers.get(modPack.id) || [], + ); + for (let handler of handlers) { + handler(modPack); + } + } + + async onModPackUpdate(id, handler) { + if (id !== null && typeof id !== "number") { + throw new Error("Invalid mod pack id"); + } + + let handlers = this.modPackUpdateHandlers.get(id); + if (!handlers) { + handlers = []; + this.modPackUpdateHandlers.set(id, handlers); + } + + handlers.push(handler); + + if (handlers.length === 1) { + await this.updateModPackSubscriptions(); + } + } + + async offModPackUpdate(id, handler) { + let handlers = this.modPackUpdateHandlers.get(id); + if (!handlers || !handlers.length) { + throw new Error(`No handlers for mod pack ${id} exist`); + } + + let index = handlers.lastIndexOf(handler); + if (index === -1) { + throw new Error(`Given handler is not registered for mod pack ${id}`); + } + + handlers.splice(index, 1); + if (!handlers.length) { + this.modPackUpdateHandlers.delete(id); + await this.updateModPackSubscriptions(); + } + } + + async updateModPackSubscriptions() { + if (!this.connector.connected) { + return; + } + + await libLink.messages.setModPackSubscriptions.send(this, { + all: this.modPackUpdateHandlers.has(null), + mod_pack_ids: [...this.modPackUpdateHandlers.keys()].filter(k => k !== null), + }); + } + async modUpdateEventHandler(message) { let mod = new libData.ModInfo(message.data.mod); let handlers = [].concat( @@ -355,12 +417,12 @@ export class Control extends libLink.Link { async offModUpdate(name, handler) { let handlers = this.modUpdateHandlers.get(name); if (!handlers || !handlers.length) { - throw new Error(`No handlers for mod pack ${name} exist`); + throw new Error(`No handlers for mod ${name} exist`); } let index = handlers.lastIndexOf(handler); if (index === -1) { - throw new Error(`Given handler is not registered for mod pack ${name}`); + throw new Error(`Given handler is not registered for mod ${name}`); } handlers.splice(index, 1); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3c9a58f6..2cd230c8 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -6,6 +6,7 @@ const events = require("events"); const phin = require("phin"); const libBuildMod = require("@clusterio/lib/build_mod"); +const libData = require("@clusterio/lib/data"); const libHash = require("@clusterio/lib/hash"); const libLink = require("@clusterio/lib/link"); const libUsers = require("@clusterio/lib/users"); @@ -820,6 +821,70 @@ describe("Integration of Clusterio", function() { }); }); + describe("mod-pack create", function() { + it("should create a mod-pack", async function() { + await execCtl("mod-pack create empty-pack 1.1"); + let response = await libLink.messages.listModPacks.send(getControl()); + assert(response.list.some(modPack => modPack.name === "empty-pack"), "created pack is not in the list"); + }); + it("should allow setting all fields", async function() { + await execCtl( + "mod-pack create full-pack 0.17 " + + "--description Description " + + "--mods empty_mod:1.0.0 " + + "--bool-setting startup MyBool true " + + "--int-setting runtime-global MyInt 1235 " + + "--double-setting runtime-global MyDouble 12.25 " + + "--string-setting runtime-per-user MyString a-string" + ); + let response = await libLink.messages.listModPacks.send(getControl()); + let modPack = response.list.find(entry => entry.name === "full-pack"); + assert(modPack, "created mod pack not found"); + let reference = new libData.ModPack(); + reference.id = modPack.id; + reference.name = "full-pack"; + reference.description = "Description"; + reference.factorioVersion = "0.17"; + reference.mods.set("empty_mod", { name: "empty_mod", version: "1.0.0" }); + reference.settings["startup"].set("MyBool", { value: true }); + reference.settings["runtime-global"].set("MyInt", { value: 1235 }); + reference.settings["runtime-global"].set("MyDouble", { value: 12.25 }); + reference.settings["runtime-per-user"].set("MyString", { value: "a-string" }); + assert.deepEqual(new libData.ModPack(modPack), reference); + }); + }); + + describe("mod-pack list", function() { + it("runs", async function() { + await execCtl("mod-pack list"); + }); + }); + + describe("mod-pack show", function() { + it("runs", async function() { + await execCtl("mod-pack show empty-pack"); + }); + }); + + describe("mod-pack edit", function() { + it("runs", async function() { + await execCtl("mod-pack edit full-pack --factorio-version 1.2"); + let response = await libLink.messages.listModPacks.send(getControl()); + let modPack = response.list.find(entry => entry.name === "full-pack"); + assert(modPack, "created mod pack not found"); + assert.equal(modPack.factorio_version, "1.2"); + }); + }); + + describe("mod-pack delete", function() { + it("deletes the pack", async function() { + await execCtl("mod-pack delete full-pack"); + let response = await libLink.messages.listModPacks.send(getControl()); + let modPack = response.list.find(entry => entry.name === "full-pack"); + assert(!modPack, "mod pack not deleted"); + }); + }); + describe("mod upload", function() { it("uploads a mod", async function() { await libBuildMod.build({ diff --git a/test/integration/index.js b/test/integration/index.js index 0a4485af..2830e39f 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -29,6 +29,7 @@ class TestControl extends libLink.Link { this.instanceUpdates = []; this.saveListUpdates = []; this.modUpdates = []; + this.modPackUpdates = []; this.userUpdates = []; this.connector.on("connect", () => { @@ -75,6 +76,10 @@ class TestControl extends libLink.Link { this.modUpdates.push(message.data.mod); } + async modPackUpdateEventHandler(message) { + this.modPackUpdates.push(message.data.mod_pack); + } + async userUpdateEventHandler(message) { this.userUpdates.push(message.data); } diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js new file mode 100644 index 00000000..9c32a17e --- /dev/null +++ b/test/lib/data/ModPack.js @@ -0,0 +1,94 @@ +"use strict"; +const assert = require("assert").strict; +const zlib = require("zlib"); + +const libSchema = require("@clusterio/lib/schema"); +const ModPack = require("@clusterio/lib/data/ModPack"); + + +describe("lib/data/ModPack", function() { + describe("class ModPack", function() { + it("should round trip serialize", function() { + const validate = libSchema.compile(ModPack.jsonSchema); + function check(pack) { + const json = JSON.parse(JSON.stringify(pack)); + if (!validate(json)) { + throw validate.errors; + } + assert.deepEqual(new ModPack(json), pack); + const packStringed = ModPack.fromModPackString(pack.toModPackString(true)); + packStringed.id = pack.id; + assert.deepEqual(packStringed, pack); + } + + check(new ModPack()); + check(new ModPack({ name: "MyPack" })); + check(new ModPack({ description: "My Description" })); + check(new ModPack({ factorio_version: "2.0" })); + check(new ModPack({ mods: [ + { name: "subspace_storage", version: "1.99.8" }, + { name: "clusterio_lib", version: "0.1.2", hash: "sha1:012345abcd" }, + ]})); + check(new ModPack({ settings: { + "startup": { + "bool-setting": { "value": true }, + }, + "runtime-global": { + "number-setting": { "value": 123 }, + }, + "runtime-per-user": { + "string-setting": { "value": "a string" }, + }, + }})); + check(new ModPack({ deleted: true })); + check(new ModPack({ + name: "Super pack", + description: "Every option at once.", + factorio_version: "2.0", + mods: [ + { name: "subspace_storage", version: "1.99.8" }, + { name: "clusterio_lib", version: "0.1.2", hash: "sha1:012345abcd" }, + ], + settings: { + "startup": { + "bool-setting": { "value": true }, + }, + "runtime-global": { + "number-setting": { "value": 123 }, + }, + "runtime-per-user": { + "string-setting": { "value": "a string" }, + }, + }, + deleted: true, + })); + }); + + it("should sort integer factorio versions lexicographically", function() { + let unsortedVersions = ["1.0", "1.1.0", "0.1", "3.0.0", "1.2", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; + let sortedVersions = ["0.0.1", "0.1", "0.3.1", "0.3.3", "1.0", "1.1.0", "1.2", "2.1.1", "3.0.0"]; + let factorioMods = unsortedVersions.map(v => new ModPack({ factorio_version: v })); + factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion); + assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions); + }); + + describe(".fromModPackString()", function() { + it("should handle malformed strings", function() { + assert.throws( + () => ModPack.fromModPackString("AMalformedString"), + new Error("Malformed mod pack string: zlib inflate failed") + ); + assert.throws( + // eslint-disable-next-line node/no-sync + () => ModPack.fromModPackString(Buffer.from(zlib.deflateSync("Not Json")).toString("base64")), + new Error("Malformed mod pack string: Unexpected token N in JSON at position 0") + ); + assert.throws( + // eslint-disable-next-line node/no-sync + () => ModPack.fromModPackString(Buffer.from(zlib.deflateSync('{"i":1}')).toString("base64")), + new Error("Malformed mod pack string: Schema validation failed") + ); + }); + }); + }); +});