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") + ); + }); + }); + }); +});