mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc05b229f1 | ||
|
|
da4599f219 | ||
|
|
a7620d3cdc | ||
|
|
2e48b728dd | ||
|
|
130dca4261 | ||
|
|
2afd7aab38 | ||
|
|
622d372776 | ||
|
|
790c2834f2 | ||
|
|
71dfb8ca24 | ||
|
|
81e419dad4 | ||
|
|
93271730eb | ||
|
|
5d0985cafc | ||
|
|
6ade75e4cd | ||
|
|
86eefab9f5 | ||
|
|
7701010fee | ||
|
|
b3dc262862 | ||
|
|
f0fb52ab74 | ||
|
|
ed4fe3230d | ||
|
|
8217acef3f | ||
|
|
019737286f | ||
|
|
d90ac998aa | ||
|
|
781b98b1c1 | ||
|
|
4986c9af99 | ||
|
|
4868ed2d63 | ||
|
|
2510d47023 | ||
|
|
0d28a3ac3c | ||
|
|
f4ee2005e3 | ||
|
|
4b85367d4e | ||
|
|
433cc3c712 | ||
|
|
ab5162e581 | ||
|
|
3b8058de81 | ||
|
|
f4916594b8 | ||
|
|
4c01f6a789 | ||
|
|
076fb933dc | ||
|
|
bcabad567e | ||
|
|
414ef0bc8b | ||
|
|
9d807fc6d6 | ||
|
|
c43f752eaf | ||
|
|
6e8290cc08 | ||
|
|
d76902cebe | ||
|
|
0fa96521c5 | ||
|
|
61232e3537 | ||
|
|
014b18caf8 | ||
|
|
687a384fe4 | ||
|
|
b2daf26e6c | ||
|
|
d0604f892b | ||
|
|
d05a529c52 |
No files matched your search
@@ -1,10 +1,15 @@
|
||||
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
<!--
|
||||
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
|
||||
|
||||
## Changelog
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
|
||||
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
|
||||
-->
|
||||
|
||||
### Changelog
|
||||
```
|
||||
### Features
|
||||
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
|
||||
- Added doodad to web interface. #999
|
||||
|
||||
### Changes
|
||||
- ...
|
||||
@@ -12,6 +17,6 @@ The changelog lines below will be copied directly from merged pull requests to t
|
||||
### Fixes
|
||||
- ...
|
||||
|
||||
### Breaking changes
|
||||
### Breaking Changes
|
||||
- ...
|
||||
```
|
||||
+53
-9
@@ -31,6 +31,50 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.24
|
||||
|
||||
### Features
|
||||
|
||||
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
|
||||
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
|
||||
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Changes
|
||||
|
||||
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
|
||||
### Meta
|
||||
|
||||
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.23
|
||||
|
||||
### Major Features
|
||||
@@ -43,29 +87,29 @@ Many thanks to the following for contributing to this release:
|
||||
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
|
||||
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
|
||||
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
|
||||
- Extended export-data to produce separate spritesheets for recipes, signals, technologies, planets, qualities, entities, and static UI icons. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
|
||||
|
||||
### Changes
|
||||
|
||||
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
|
||||
- Instance versions can now be set using Major.Minor without specifing a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Versions for factorio and modpacks can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
|
||||
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
|
||||
- Installations where muliple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
|
||||
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
|
||||
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Removed debug log statement from equipment grid serialization. [#XXX](https://github.com/clusterio/clusterio/pull/XXX)
|
||||
- Fix that the documentation had a dead link to details of the message passing protocol (#812)
|
||||
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
|
||||
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
|
||||
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
|
||||
- Fix headless download on linux install. [#815](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix option `--no-download-headless` being ignored. [#815](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
|
||||
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#1234](https://github.com/clusterio/clusterio/issues/1234)
|
||||
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
|
||||
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
|
||||
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
|
||||
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const fs = require("node:fs/promises");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const npmPackage = require("./package.json");
|
||||
@@ -24,13 +24,15 @@ let DRY = false;
|
||||
* @param {string} filePath The path to remove.
|
||||
*/
|
||||
async function tryRemove(filePath) {
|
||||
if (await fs.exists(filePath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
console.log(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DRY) {
|
||||
await fs.remove(filePath);
|
||||
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
|
||||
After your feature branch have been merged you should consider it final.
|
||||
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
|
||||
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
- CHANGELOG.md in the repository root
|
||||
- The changelog.txt file in mod directories
|
||||
|
||||
Pull requests will usually have suggestions for changelog entries.
|
||||
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
|
||||
|
||||
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
|
||||
|
||||
## Packages
|
||||
|
||||
|
||||
+1
-1
@@ -44,13 +44,13 @@
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@stylistic/eslint-plugin": "^5.2.2",
|
||||
"@swc/core": "^1.13.3",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^5.1.0",
|
||||
"form-data": "^4.0.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"globals": "^16.3.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
@@ -182,7 +182,8 @@ async function handleBootstrapCommand(
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
@@ -22,40 +22,38 @@
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"express": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.0",
|
||||
"express": "catalog:",
|
||||
"finalhandler": "^1.3.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/express": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/busboy": "^1.5.0",
|
||||
"@swc/core": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-merge": "catalog:",
|
||||
"webpack-dev-middleware": "^6.1.1"
|
||||
"webpack-dev-middleware": "^6.1.1",
|
||||
"webpack-merge": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type Controller from "./Controller";
|
||||
import type WsServerConnector from "./WsServerConnector";
|
||||
@@ -94,10 +94,12 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
|
||||
let mod = this.getMod(request);
|
||||
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
|
||||
// Open source file before creating the stream in case it throws.
|
||||
let source = (await fs.open(modPath)).createReadStream();
|
||||
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = mod.filename;
|
||||
stream.source = fs.createReadStream(modPath);
|
||||
stream.source = source;
|
||||
stream.mime = "application/zip";
|
||||
stream.size = String(mod.size);
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
|
||||
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
|
||||
@@ -84,6 +85,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
|
||||
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
|
||||
@@ -195,6 +197,26 @@ export default class ControlConnection extends BaseConnection {
|
||||
return this._controller.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this._controller.config.stageProp(
|
||||
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.config.commitStaging();
|
||||
} finally {
|
||||
this._controller.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
|
||||
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
|
||||
}
|
||||
@@ -280,17 +302,42 @@ export default class ControlConnection extends BaseConnection {
|
||||
return instance.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
if (request.field === "instance.assigned_host") {
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (request.field === "instance.id") {
|
||||
if (fieldName === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
ControlConnection.validateInstanceConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
instance.config.stageProp(
|
||||
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
instance.config.commitStaging();
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
|
||||
const instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
ControlConnection.validateInstanceConfigSetField(request.field);
|
||||
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
@@ -298,6 +345,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
|
||||
let instance = this._controller.instances.getForRequest(request.instanceId);
|
||||
let { field, prop, value } = request;
|
||||
ControlConnection.validateInstanceConfigSetField(field);
|
||||
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import finalhandler from "finalhandler";
|
||||
|
||||
import compression from "compression";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import jwt from "jsonwebtoken";
|
||||
@@ -123,7 +123,7 @@ export default class Controller {
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.ensureDir(databaseDirectory);
|
||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||
|
||||
// Migrate combined user and role file from 2.0.0-alpha.22
|
||||
await UserManager.attemptMigrateLegacyUsersFile(
|
||||
@@ -173,7 +173,7 @@ export default class Controller {
|
||||
).bootstrap());
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
|
||||
// Add default mod packs
|
||||
@@ -268,6 +268,11 @@ export default class Controller {
|
||||
this.users.records.on("update", this.usersUpdated.bind(this));
|
||||
}
|
||||
|
||||
get authSecret() {
|
||||
// Auth secret is never null after startup
|
||||
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
|
||||
}
|
||||
|
||||
async start(args: ControllerArgs) {
|
||||
if (this._state !== "new") {
|
||||
throw new Error(`Cannot start in state ${this._state}`);
|
||||
@@ -597,7 +602,7 @@ export default class Controller {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("controller.version");
|
||||
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
|
||||
const packageJson = await fs.readJSON(packageJsonPath);
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
@@ -614,7 +619,7 @@ export default class Controller {
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
|
||||
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
|
||||
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
@@ -883,10 +888,7 @@ export default class Controller {
|
||||
* @param hostId - ID of host to generate a token for.
|
||||
*/
|
||||
generateHostToken(hostId: number): string {
|
||||
return jwt.sign(
|
||||
{ aud: "host", host: hostId },
|
||||
Buffer.from(this.config.get("controller.auth_secret"), "base64")
|
||||
);
|
||||
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
|
||||
}
|
||||
|
||||
hostsUpdated(hosts: HostRecord[]) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -151,7 +151,8 @@ export default class UserManager {
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
|
||||
// auth secret is never null after startup
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,10 +223,7 @@ ${err.stack}`
|
||||
createSession(dst: lib.Address): [ WsServerConnector, string ] {
|
||||
let sessionId = this.nextSessionId;
|
||||
this.nextSessionId += 1;
|
||||
let sessionToken = jwt.sign(
|
||||
{ aud: this.sessionAud, sid: sessionId },
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
);
|
||||
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
|
||||
@@ -247,13 +244,8 @@ ${err.stack}`
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
{ audience: ["host", "slave"] }
|
||||
);
|
||||
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -324,12 +316,7 @@ ${err.stack}`
|
||||
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
|
||||
let user;
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
data.token,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -376,12 +363,7 @@ ${err.stack}`
|
||||
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
|
||||
let connector;
|
||||
try {
|
||||
let payload = jwt.verify(
|
||||
data.sessionToken,
|
||||
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: this.sessionAud }
|
||||
);
|
||||
|
||||
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
|
||||
if (typeof payload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import type Controller from "./Controller";
|
||||
import Controller from "./Controller";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import JSZip from "jszip";
|
||||
import jwt from "jsonwebtoken";
|
||||
import path from "path";
|
||||
@@ -148,12 +148,7 @@ function validateHostToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "host" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -180,12 +175,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
let tokenPayload = jwt.verify(
|
||||
token,
|
||||
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
|
||||
{ audience: "user" }
|
||||
);
|
||||
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
@@ -241,12 +231,13 @@ async function uploadExport(req: Request, res: Response) {
|
||||
"export/settings.json",
|
||||
"export/prototypes.json",
|
||||
"export/locale.json",
|
||||
"export/defines.json",
|
||||
"export/spritesheet.png",
|
||||
"export/metadata.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes = {};
|
||||
let settingPrototypes: lib.ExportSettings = {};
|
||||
for (let filePath of exportFiles) {
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
@@ -552,30 +543,16 @@ async function uploadMod(req: Request, res: Response) {
|
||||
}
|
||||
|
||||
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
const filePath = path.join(modsDirectory, filename);
|
||||
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
|
||||
|
||||
let writeStream;
|
||||
try {
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
|
||||
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
|
||||
checkModName(modInfo.filename);
|
||||
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
|
||||
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
|
||||
req.app.locals.controller.modStore.addMod(modInfo);
|
||||
mods.push(modInfo.toJSON());
|
||||
|
||||
@@ -585,15 +562,13 @@ async function uploadMod(req: Request, res: Response) {
|
||||
stream.resume();
|
||||
|
||||
// Attempt to clean up.
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
writeStream.destroy();
|
||||
|
||||
try {
|
||||
await fs.unlink(path.join(modsDirectory, tempFilename));
|
||||
await fs.unlink(tempFilePath);
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") {
|
||||
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
|
||||
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,12 +670,12 @@ export const webRoutes = [
|
||||
"/instances/:id/view",
|
||||
"/mods",
|
||||
"/mods/mod-packs/:id/view",
|
||||
"/mods/mod-packs/:id/icon-reference",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
"/icon-reference",
|
||||
"/error",
|
||||
];
|
||||
+36
-22
@@ -2,7 +2,7 @@
|
||||
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs-extra");
|
||||
const fs = require("node:fs/promises");
|
||||
const inquirer = require("inquirer");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
@@ -97,11 +97,24 @@ class LineSplitter extends stream.Transform {
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
await fs.outputFile(temporary, data, options);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
@@ -221,7 +234,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateConfig(source, destination) {
|
||||
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
|
||||
if (await pathExists(source) && !await pathExists(destination)) {
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
));
|
||||
@@ -233,7 +246,7 @@ async function migrateRename(args) {
|
||||
await migrateConfig("config-slave.json", "config-host.json");
|
||||
|
||||
let instancesFile = path.join("database", "instances.json");
|
||||
if (await fs.pathExists(instancesFile)) {
|
||||
if (await pathExists(instancesFile)) {
|
||||
await safeOutputFile(instancesFile, JSON.stringify(
|
||||
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
|
||||
));
|
||||
@@ -241,7 +254,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
let usersFile = path.join("database", "users.json");
|
||||
if (await fs.pathExists(usersFile)) {
|
||||
if (await pathExists(usersFile)) {
|
||||
let users = JSON.parse(await fs.readFile(usersFile));
|
||||
for (let role of users["roles"]) {
|
||||
role["permissions"] = role["permissions"].map(rename);
|
||||
@@ -258,7 +271,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function migrateLog(inputFile, outputFile) {
|
||||
let lineStream = new LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(inputFile);
|
||||
let fileStream = (await fs.open(inputFile)).createReadStream();
|
||||
fileStream.pipe(lineStream);
|
||||
let lines = [];
|
||||
|
||||
@@ -278,7 +291,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
|
||||
async function migrateLogsDir(inputLogs, outputLogs) {
|
||||
if (await fs.pathExists(inputLogs)) {
|
||||
if (await pathExists(inputLogs)) {
|
||||
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
|
||||
for (let logFile of await fs.readdir(inputLogs)) {
|
||||
if (!logFile.endsWith(".log")) {
|
||||
@@ -286,7 +299,7 @@ async function migrateRename(args) {
|
||||
}
|
||||
let inputFile = path.join(inputLogs, logFile);
|
||||
let outputFile = path.join(outputLogs, rename(logFile));
|
||||
if (!await fs.pathExists(outputFile)) {
|
||||
if (!await pathExists(outputFile)) {
|
||||
await migrateLog(inputFile, outputFile);
|
||||
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
|
||||
}
|
||||
@@ -297,16 +310,16 @@ async function migrateRename(args) {
|
||||
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
|
||||
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
|
||||
if (
|
||||
await fs.pathExists(path.join("logs", "cluster"))
|
||||
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
|
||||
await pathExists(path.join("logs", "cluster"))
|
||||
&& !await pathExists(path.join("logs", "cluster-prerename"))
|
||||
) {
|
||||
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
|
||||
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
|
||||
}
|
||||
|
||||
if (
|
||||
await fs.pathExists("sharedMods")
|
||||
&& !await fs.pathExists("mods")
|
||||
await pathExists("sharedMods")
|
||||
&& !await pathExists("mods")
|
||||
) {
|
||||
logger.info("Moving sharedMods/ to mods/");
|
||||
await fs.rename("sharedMods", "mods");
|
||||
@@ -328,8 +341,8 @@ async function migrateRename(args) {
|
||||
await execFile(`npm${scriptExt}`, ["update"]);
|
||||
}
|
||||
|
||||
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
|
||||
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
|
||||
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
|
||||
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
|
||||
if (hasRunMaster || hasRunSlave) {
|
||||
logger.info("Writing run scripts");
|
||||
let mode = "standalone";
|
||||
@@ -354,7 +367,7 @@ async function migrateRename(args) {
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await fs.outputFile("package.json", JSON.stringify({
|
||||
await fs.writeFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
@@ -643,7 +656,7 @@ async function inquirerMissingArgs(args) {
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
@@ -687,7 +700,7 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,20 +785,21 @@ async function downloadLinuxServer() {
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
if (await pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.emptyDir(tmpDir);
|
||||
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await fs.mkdir(tmpFactorioDir, { recursive: true });
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
@@ -794,7 +808,7 @@ async function downloadLinuxServer() {
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rm(tmpDir);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"inquirer": "^8.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
const { logger } = require("./logging");
|
||||
const fs = require("fs-extra");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
const _package = require("./package.json");
|
||||
@@ -61,9 +61,15 @@ async function copyTemplateFile(src, dst, properties) {
|
||||
return include ? content : "";
|
||||
});
|
||||
|
||||
// Create folder for output file
|
||||
const dir = path.parse(dst);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Attempt to write the output file, warn if it already exists
|
||||
try {
|
||||
await fs.outputFile(dst, outputData, { flag: "wx" });
|
||||
await fs.writeFile(dst, outputData, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
logger.warn(`Could not create file ${dst} because it already exists`);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -21,17 +21,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"as-table": "^1.0.55",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import os from "os";
|
||||
@@ -87,7 +87,7 @@ hostCommands.add(new lib.Command({
|
||||
} else {
|
||||
logger.info(`Writing ${args.output}`);
|
||||
try {
|
||||
await fs.outputFile(args.output, content, { flag: "wx" });
|
||||
await fs.writeFile(args.output, content, { flag: "wx" });
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.CommandError(`File ${args.output} already exists`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
@@ -338,7 +338,7 @@ instanceSaveCommands.add(new lib.Command({
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: await fs.createReadStream(args.filepath),
|
||||
body: (await fs.open(args.filepath)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": control.config.get("control.controller_token")!,
|
||||
@@ -417,31 +417,7 @@ instanceSaveCommands.add(new lib.Command({
|
||||
|
||||
let url = new URL(control.config.get("control.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let response = await fetch(url);
|
||||
if (!response.body) {
|
||||
throw new lib.CommandError("Got empty response from server.");
|
||||
}
|
||||
|
||||
let writeStream;
|
||||
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.Readable.fromWeb(response.body).pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
let filename = await lib.findUnusedName(".", args.save, ".zip");
|
||||
await fs.rename(tempFilename, filename);
|
||||
const filename = await lib.downloadFile(url, args.save, "rename");
|
||||
|
||||
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import events from "events";
|
||||
import path from "path";
|
||||
@@ -151,7 +151,7 @@ modCommands.add(new lib.Command({
|
||||
|
||||
let response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: fs.createReadStream(args.file),
|
||||
body: (await fs.open(args.file)).createReadStream(),
|
||||
duplex: "half",
|
||||
headers: {
|
||||
"X-Access-Token": control.config.get("control.controller_token")!,
|
||||
@@ -194,30 +194,8 @@ modCommands.add(new lib.Command({
|
||||
|
||||
let url = new URL(control.config.get("control.controller_url")!);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let response = await fetch(url);
|
||||
if (!response.body) {
|
||||
throw new lib.CommandError("Got empty response from server");
|
||||
}
|
||||
|
||||
let writeStream;
|
||||
let filename = `${args.name}_${args.modVersion}.zip`;
|
||||
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.Readable.fromWeb(response.body).pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
await fs.rename(tempFilename, filename);
|
||||
await lib.downloadFile(url, filename, "overwrite");
|
||||
|
||||
logger.info(`Downloaded ${filename}`);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import asTableModule from "as-table";
|
||||
import path from "path";
|
||||
|
||||
@@ -224,7 +224,7 @@ async function handleImportOrRestore(args: {
|
||||
|
||||
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
|
||||
if (restore && backup && !args.noBackup) {
|
||||
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
|
||||
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
|
||||
print(`Wrote backup to ${importType}-backup.json`);
|
||||
}
|
||||
}
|
||||
@@ -296,9 +296,9 @@ userCommands.add(new lib.Command({
|
||||
exportType = "whitelist";
|
||||
}
|
||||
|
||||
await fs.writeJSON(args.filepath, await control.send(
|
||||
await fs.writeFile(args.filepath, JSON.stringify(await control.send(
|
||||
new lib.UserBulkExportRequest(exportType)
|
||||
), { spaces: 2 });
|
||||
), undefined, "\t"));
|
||||
print(`Exported ${exportType} to ${args.filepath}`);
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,7 @@
|
||||
* @example
|
||||
* npx clusteriohost run
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import yargs from "yargs";
|
||||
import setBlocking from "set-blocking";
|
||||
@@ -199,9 +199,9 @@ async function startHost() {
|
||||
await hostConfigLock.acquire();
|
||||
|
||||
// If we get here the command was run
|
||||
await fs.ensureDir(hostConfig.get("host.instances_directory"));
|
||||
await fs.ensureDir(hostConfig.get("host.mods_directory"));
|
||||
await fs.ensureDir("modules");
|
||||
await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true });
|
||||
await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true });
|
||||
await fs.mkdir("modules", { recursive: true });
|
||||
|
||||
// Set the process title, shows up as the title of the CMD window on windows
|
||||
// and as the process name in ps/top on linux.
|
||||
|
||||
@@ -9,4 +9,5 @@ script.on_init(function()
|
||||
for name, mod_setting in pairs(mod_settings) do
|
||||
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
|
||||
end
|
||||
send_json("defines", defines)
|
||||
end)
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -23,15 +23,14 @@
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"jimp": "^1.6.0",
|
||||
"jszip": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"jimp": "^0.22.8",
|
||||
"pidusage": "^3.0.2",
|
||||
"rcon-client": "^4.2.5",
|
||||
"semver": "^7.7.1",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -39,10 +38,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/pidusage": "^2.0.2",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
+48
-57
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import events from "events";
|
||||
import pidusage from "pidusage";
|
||||
@@ -294,7 +294,7 @@ export default class Host extends lib.Link {
|
||||
|
||||
static async bootstrap(hostConfig: lib.HostConfig) {
|
||||
const modsDirectory = hostConfig.get("host.mods_directory");
|
||||
await fs.ensureDir(modsDirectory);
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
return [
|
||||
await lib.ModStore.fromDirectory(modsDirectory),
|
||||
] as const;
|
||||
@@ -387,6 +387,7 @@ export default class Host extends lib.Link {
|
||||
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
|
||||
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
|
||||
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
|
||||
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
|
||||
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
|
||||
@@ -503,18 +504,41 @@ export default class Host extends lib.Link {
|
||||
return this.config.toRemote("control");
|
||||
}
|
||||
|
||||
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
|
||||
if (request.field === "host.id") {
|
||||
private static validateHostConfigSetField(fieldName: string) {
|
||||
if (fieldName === "host.id") {
|
||||
// Changing host.id at runtime is not worth the effort to
|
||||
// support and will break a lot of things if it is allowed.
|
||||
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
|
||||
try {
|
||||
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
|
||||
Host.validateHostConfigSetField(fieldName);
|
||||
if (typeof fieldValue === "object") {
|
||||
for (const [propName, propValue] of Object.entries(fieldValue)) {
|
||||
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
|
||||
}
|
||||
} else {
|
||||
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
|
||||
}
|
||||
}
|
||||
|
||||
this.config.commitStaging();
|
||||
} finally {
|
||||
this.config.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
|
||||
Host.validateHostConfigSetField(request.field);
|
||||
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
|
||||
}
|
||||
|
||||
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
|
||||
let { field, prop, value } = request;
|
||||
Host.validateHostConfigSetField(field);
|
||||
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
|
||||
}
|
||||
|
||||
@@ -591,19 +615,10 @@ export default class Host extends lib.Link {
|
||||
|
||||
let url = new URL(this.config.get("host.controller_url"));
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let response = await fetch(url);
|
||||
if (!response.body) {
|
||||
throw new Error(`Empty body downloading stream ${streamId} from controller`);
|
||||
}
|
||||
|
||||
const file = lib.ModInfo.filename(mod.name, mod.version);
|
||||
const filePath = path.join(this.modStore.modsDirectory, file);
|
||||
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
|
||||
await events.once(writeStream, "open");
|
||||
stream.Readable.fromWeb(response.body).pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
await fs.rename(tempFilePath, filePath);
|
||||
await lib.downloadFile(url, filePath, "overwrite");
|
||||
|
||||
const modInfo = await this.modStore.loadFile(file);
|
||||
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
|
||||
@@ -832,7 +847,7 @@ export default class Host extends lib.Link {
|
||||
// First check the clusterio version
|
||||
const runningVersion = this.config.get("host.version");
|
||||
const packageJsonPath = require.resolve("@clusterio/host/package.json");
|
||||
const packageJson = await fs.readJSON(packageJsonPath);
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
if (runningVersion !== packageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
@@ -849,7 +864,7 @@ export default class Host extends lib.Link {
|
||||
|
||||
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
|
||||
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
|
||||
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
|
||||
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
|
||||
if (pluginInfo.version !== pluginPackageJson.version) {
|
||||
this.config.restartRequired = true;
|
||||
return true;
|
||||
@@ -916,12 +931,17 @@ export default class Host extends lib.Link {
|
||||
checkRequestSaveName(newName);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
await fs.move(
|
||||
// Link save in the new location.
|
||||
await fs.link(
|
||||
path.join(instance.path, "saves", oldName),
|
||||
path.join(instance.path, "saves", newName),
|
||||
{ overwrite: false },
|
||||
);
|
||||
// Unlink save in old location.
|
||||
await fs.unlink(path.join(instance.path, "saves", oldName));
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.RequestError(`${newName} already exists`);
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${oldName} does not exist`);
|
||||
}
|
||||
@@ -936,10 +956,10 @@ export default class Host extends lib.Link {
|
||||
checkRequestSaveName(destination);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
await fs.copy(
|
||||
await fs.copyFile(
|
||||
path.join(instance.path, "saves", source),
|
||||
path.join(instance.path, "saves", destination),
|
||||
{ overwrite: false, errorOnExist: true },
|
||||
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
@@ -965,16 +985,15 @@ export default class Host extends lib.Link {
|
||||
|
||||
try {
|
||||
if (copy) {
|
||||
await fs.copy(
|
||||
await fs.copyFile(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
{ overwrite: true },
|
||||
fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
} else {
|
||||
await fs.move(
|
||||
await fs.rename(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
{ overwrite: true },
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1024,35 +1043,9 @@ export default class Host extends lib.Link {
|
||||
|
||||
let url = new URL(this.config.get("host.controller_url"));
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
let response = await fetch(url);
|
||||
|
||||
if (response.status !== 200 || !response.body) {
|
||||
let body = Buffer.from(await response.arrayBuffer());
|
||||
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
|
||||
}
|
||||
|
||||
let savesDir = path.join(instance.path, "saves");
|
||||
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
let writeStream: NodeJS.WritableStream;
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
|
||||
await events.once(writeStream, "open");
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.Readable.fromWeb(response.body).pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
name = await lib.findUnusedName(savesDir, name, ".zip");
|
||||
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name));
|
||||
|
||||
const savedPath = await lib.downloadFile(url, path.join(savesDir, name), "rename");
|
||||
name = path.basename(savedPath);
|
||||
await this.sendSaveListUpdate(instanceId, savesDir);
|
||||
return name;
|
||||
}
|
||||
@@ -1062,11 +1055,9 @@ export default class Host extends lib.Link {
|
||||
checkRequestSaveName(name);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
|
||||
let content = fs.createReadStream(path.join(instance.path, "saves", name));
|
||||
let content;
|
||||
try {
|
||||
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
|
||||
// "open" with "ready" when fs-extra is replaced with node's fs module.
|
||||
await events.once(content, "open");
|
||||
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
@@ -1098,7 +1089,7 @@ export default class Host extends lib.Link {
|
||||
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
this.assignedInstances.delete(instanceId);
|
||||
await fs.remove(instance.path);
|
||||
await fs.rm(instance.path, { recursive: true, maxRetries: 10 });
|
||||
}
|
||||
|
||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import pidusage from "pidusage";
|
||||
import util from "util";
|
||||
@@ -755,8 +755,8 @@ end`.replace(/\r?\n/g, " ");
|
||||
* @param instanceDir -
|
||||
*/
|
||||
static async populate_folders(instanceDir: string) {
|
||||
await fs.ensureDir(path.join(instanceDir, "script-output"));
|
||||
await fs.ensureDir(path.join(instanceDir, "saves"));
|
||||
await fs.mkdir(path.join(instanceDir, "script-output"), { recursive: true });
|
||||
await fs.mkdir(path.join(instanceDir, "saves"), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -786,7 +786,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
const mods = await this._host.fetchMods(modPack.mods.values());
|
||||
|
||||
await fs.ensureDir(this.path("mods"));
|
||||
await fs.mkdir(this.path("mods"), { recursive: true });
|
||||
|
||||
// Remove all files
|
||||
for (let entry of await fs.readdir(this.path("mods"), { withFileTypes: true })) {
|
||||
@@ -825,12 +825,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
// Write mod-list.json
|
||||
await fs.outputFile(this.path("mods", "mod-list.json"), JSON.stringify({
|
||||
await fs.writeFile(this.path("mods", "mod-list.json"), JSON.stringify({
|
||||
mods: [...this.activeModPack.mods.values()],
|
||||
}, null, "\t"));
|
||||
|
||||
// Write mod-settings.dat
|
||||
await fs.outputFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
|
||||
await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -921,7 +921,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
now.getUTCMinutes().toLocaleString("en", { minimumIntegerDigits: 2 }),
|
||||
saveName,
|
||||
);
|
||||
await fs.copy(this.path("saves", saveName), this.path("saves", newName));
|
||||
// This will overwrite the save if this runs twice in the same minute.
|
||||
await fs.copyFile(
|
||||
this.path("saves", saveName),
|
||||
this.path("saves", newName),
|
||||
fs.constants.COPYFILE_FICLONE,
|
||||
);
|
||||
saveName = newName;
|
||||
}
|
||||
|
||||
|
||||
+133
-168
@@ -1,19 +1,16 @@
|
||||
// Export of item icons and locale
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import Jimp from "jimp";
|
||||
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
|
||||
import type { FactorioServer } from "./server";
|
||||
import { definesPrototypesFallback } from "./export_fallback";
|
||||
|
||||
interface Prototype {
|
||||
[index: string]: unknown,
|
||||
type: string,
|
||||
name: string,
|
||||
}
|
||||
interface SimpleIconSpecification {
|
||||
icons?: never;
|
||||
icon: string;
|
||||
icon_size?: number;
|
||||
icon_mipmaps?: number;
|
||||
@@ -32,11 +29,9 @@ interface LayeredIconSpecification {
|
||||
icon_mipmaps?: number;
|
||||
}
|
||||
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
|
||||
type ItemPrototype = Prototype & IconSpecification;
|
||||
type PrototypeWithIcon = lib.FactorioPrototypeBase & IconSpecification;
|
||||
|
||||
|
||||
type Prototypes = Record<string, Record<string, Prototype>>
|
||||
|
||||
/**
|
||||
* Generate the export mod needed for exportData
|
||||
*
|
||||
@@ -69,7 +64,7 @@ async function generateExportMod(server: FactorioServer) {
|
||||
});
|
||||
}
|
||||
|
||||
let zipCache = new Map();
|
||||
let zipCache = new Map<string, JSZip>();
|
||||
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
|
||||
let modVersion = modVersions.get(mod);
|
||||
if (!modVersion) {
|
||||
@@ -83,7 +78,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
|
||||
zipCache.set(zipPath, zip);
|
||||
}
|
||||
|
||||
return zip.folder(lib.findRoot(zip));
|
||||
return zip.folder(lib.findRoot(zip))!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +129,8 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
|
||||
return await file.async("nodebuffer");
|
||||
}
|
||||
|
||||
type IconCache = Map<string, Jimp | null>;
|
||||
type Image = JimpInstance;
|
||||
type IconCache = Map<string, Image | null>;
|
||||
async function loadIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
@@ -147,8 +143,8 @@ async function loadIcon(
|
||||
if (icon === undefined) {
|
||||
let fileContent = await loadFile(server, modVersions, iconPath);
|
||||
if (fileContent) {
|
||||
icon = await Jimp.read(fileContent);
|
||||
icon.crop(0, 0, iconSize, iconSize);
|
||||
icon = await Jimp.fromBuffer(fileContent) as Image;
|
||||
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
|
||||
iconCache.set(iconPath, icon);
|
||||
} else {
|
||||
icon = null;
|
||||
@@ -184,7 +180,7 @@ async function loadLayeredIcon(
|
||||
iconCache: IconCache,
|
||||
) {
|
||||
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
|
||||
let icon = await Jimp.create(size, size);
|
||||
let icon = new Jimp({ width: size, height: size });
|
||||
|
||||
// The scaling factor of the base layer
|
||||
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
|
||||
@@ -246,7 +242,7 @@ async function loadLayeredIcon(
|
||||
return icon;
|
||||
}
|
||||
|
||||
function fixIcons(item: ItemPrototype) {
|
||||
function fixIcons(item: PrototypeWithIcon) {
|
||||
const icons = item.icons;
|
||||
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
|
||||
// It's possible to specify icons as an object with arbitrary keys
|
||||
@@ -258,84 +254,28 @@ function fixIcons(item: ItemPrototype) {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
|
||||
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
|
||||
if (item.icons) {
|
||||
return (item as LayeredIconSpecification).icons[0]?.icon;
|
||||
}
|
||||
return (item as SimpleIconSpecification).icon;
|
||||
}
|
||||
|
||||
const itemTypes = new Set([
|
||||
const prototypesWithIcon = [
|
||||
"item",
|
||||
"ammo",
|
||||
"capsule",
|
||||
"gun",
|
||||
"item-with-entity-data",
|
||||
"item-with-label",
|
||||
"item-with-inventory",
|
||||
"blueprint-book",
|
||||
"item-with-tags",
|
||||
"selection-tool",
|
||||
"blueprint",
|
||||
"copy-paste-tool",
|
||||
"deconstruction-item",
|
||||
"upgrade-item",
|
||||
"module",
|
||||
"rail-planner",
|
||||
"spidertron-remote",
|
||||
"tool",
|
||||
"armor",
|
||||
"mining-tool",
|
||||
"repair-tool",
|
||||
// XXX Bad hack to get icons for fluids. These should be treated as a
|
||||
// separate namespace from items as they may be named the same as items.
|
||||
"fluid",
|
||||
]);
|
||||
"recipe",
|
||||
"virtual-signal",
|
||||
"technology",
|
||||
"space-location",
|
||||
"quality",
|
||||
"entity",
|
||||
] as const;
|
||||
|
||||
const recipeTypes = new Set(["recipe"]);
|
||||
const signalTypes = new Set(["virtual-signal"]);
|
||||
const technologyTypes = new Set(["technology"]);
|
||||
const planetTypes = new Set(["planet", "space-location"]);
|
||||
const qualityTypes = new Set(["quality"]);
|
||||
|
||||
// Entity types to export. Entities whose name matches an already-exported
|
||||
const entityTypes = new Set([
|
||||
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
|
||||
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
|
||||
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
|
||||
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
|
||||
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
|
||||
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
|
||||
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
|
||||
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
|
||||
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
|
||||
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
|
||||
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
|
||||
"cargo-landing-pad", "asteroid",
|
||||
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
|
||||
"unit", "unit-spawner", "spider-unit", "segmented-unit",
|
||||
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
|
||||
"simple-entity-with-owner", "simple-entity-with-force",
|
||||
"arithmetic-combinator", "decider-combinator", "constant-combinator",
|
||||
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
|
||||
"character",
|
||||
"offshore-pump", "valve", "lightning-attractor",
|
||||
"fusion-generator", "fusion-reactor", "burner-generator",
|
||||
"agricultural-tower", "market", "lane-splitter",
|
||||
]);
|
||||
|
||||
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
|
||||
function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set<string>) {
|
||||
return Object.entries(prototypes)
|
||||
.filter(([type]) => allowedTypes.has(type))
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
|
||||
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as PrototypeWithIcon[])
|
||||
.map(fixIcons);
|
||||
}
|
||||
|
||||
/** Shared state for building a unified spritesheet across all categories. */
|
||||
interface SheetState {
|
||||
sheet: Jimp;
|
||||
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
|
||||
sheet: Image;
|
||||
metadata: Record<string, lib.ExportMetadataEntry[]>;
|
||||
iconCache: IconCache;
|
||||
simpleIcons: Map<string, number>;
|
||||
pos: number;
|
||||
@@ -346,8 +286,8 @@ const SHEET_WIDTH = 1024;
|
||||
|
||||
async function createSheetState(): Promise<SheetState> {
|
||||
return {
|
||||
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
|
||||
metadata: new Map(),
|
||||
sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }),
|
||||
metadata: Object.create(null),
|
||||
iconCache: new Map(),
|
||||
simpleIcons: new Map(),
|
||||
pos: 0,
|
||||
@@ -359,79 +299,98 @@ async function growSheet(state: SheetState) {
|
||||
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
|
||||
if (neededHeight > state.sheet.bitmap.height) {
|
||||
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
|
||||
const extended = new Jimp({ width: SHEET_WIDTH, height: neededHeight });
|
||||
extended.composite(state.sheet, 0, 0);
|
||||
(state.sheet as any).bitmap = (extended as any).bitmap;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportIcon(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
state: SheetState,
|
||||
prototype: PrototypeWithIcon,
|
||||
) {
|
||||
let icon: Image | null = null;
|
||||
let iconPos: number | undefined;
|
||||
let iconData: lib.ExportMetadataEntry["icon"];
|
||||
let iconPath: string | undefined;
|
||||
|
||||
if (prototype.icons) {
|
||||
icon = await loadLayeredIcon(
|
||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
iconPos = state.pos;
|
||||
iconPath = prototype.icons[0].icon;
|
||||
|
||||
} else if (prototype.icon) {
|
||||
iconPos = state.simpleIcons.get(prototype.icon);
|
||||
iconPath = prototype.icon;
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(
|
||||
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
state.simpleIcons.set(prototype.icon, state.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconPos !== undefined) {
|
||||
iconData = {
|
||||
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
size: SHEET_ICON_SIZE,
|
||||
path: iconPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
await growSheet(state);
|
||||
state.sheet.composite(
|
||||
icon,
|
||||
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
);
|
||||
state.pos += 1;
|
||||
}
|
||||
|
||||
return iconData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export item icons for a single category onto the shared spritesheet.
|
||||
* Export prototype icons for a single base prototype onto the shared spritesheet.
|
||||
*
|
||||
* @param server - The server to load icons from.
|
||||
* @param modVersions - Mapping of mod name to version.
|
||||
* @param items - Array of item prototypes to export.
|
||||
* @param category - Category name for metadata.
|
||||
* @param prototypes - Array of prototypes with icon to export for this base type.
|
||||
* @param baseName - name of base type (used in metadata).
|
||||
* @param state - Shared spritesheet state (mutated in place).
|
||||
* @returns Number of icons exported for this category.
|
||||
* @returns Number of icons exported for this base type.
|
||||
* @internal
|
||||
*/
|
||||
async function exportItems(
|
||||
async function exportIcons(
|
||||
server: FactorioServer,
|
||||
modVersions: Map<string, string>,
|
||||
items: ItemPrototype[],
|
||||
category: string,
|
||||
prototypes: PrototypeWithIcon[],
|
||||
baseName: string,
|
||||
state: SheetState,
|
||||
) {
|
||||
const metadata = state.metadata[baseName] ?? [];
|
||||
state.metadata[baseName] ??= metadata;
|
||||
let count = 0;
|
||||
for (let item of items) {
|
||||
// Skip prototypes with no icon data at all
|
||||
if (!item.icons && !item.icon) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let icon: Jimp | null = null;
|
||||
let iconPos: number | undefined;
|
||||
if (item.icons) {
|
||||
icon = await loadLayeredIcon(
|
||||
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
iconPos = state.pos;
|
||||
|
||||
} else {
|
||||
iconPos = state.simpleIcons.get(item.icon as string);
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(
|
||||
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
|
||||
);
|
||||
if (icon) {
|
||||
iconPos = state.pos;
|
||||
state.simpleIcons.set(item.icon as string, state.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconPos !== undefined) {
|
||||
const iconPath = getPrimaryIconPath(item);
|
||||
state.metadata.set(`${category}.${item.name}`, {
|
||||
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
size: SHEET_ICON_SIZE,
|
||||
category,
|
||||
...(iconPath ? { path: iconPath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (let prototype of prototypes) {
|
||||
const icon = await exportIcon(server, modVersions, state, prototype);
|
||||
if (icon) {
|
||||
await growSheet(state);
|
||||
state.sheet.composite(
|
||||
icon,
|
||||
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
|
||||
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
|
||||
);
|
||||
state.pos += 1;
|
||||
count += 1;
|
||||
}
|
||||
metadata.push({
|
||||
name: prototype.name,
|
||||
type: prototype.type,
|
||||
localised_name: prototype.localised_name,
|
||||
icon,
|
||||
});
|
||||
}
|
||||
|
||||
return count;
|
||||
@@ -505,7 +464,7 @@ async function exportLocale(
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the locale and item icons for the given factorio server
|
||||
* Export locale, icons and metadata for the given factorio server
|
||||
*
|
||||
* @param server - The server to export the data from.
|
||||
* @returns zip file with exported data.
|
||||
@@ -513,12 +472,13 @@ async function exportLocale(
|
||||
export async function exportData(server: FactorioServer) {
|
||||
await generateExportMod(server);
|
||||
|
||||
let settings: Prototypes = {};
|
||||
let prototypes: Prototypes = {};
|
||||
let settings: lib.ExportSettings = {};
|
||||
let prototypes: lib.ExportPrototypes = {};
|
||||
let modVersions = new Map();
|
||||
let modOrder: string[] = [];
|
||||
let defines: lib.FactorioDefines | undefined;
|
||||
|
||||
function add(obj: Prototypes, prototype: Prototype) {
|
||||
function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
|
||||
obj[prototype.type] = {};
|
||||
}
|
||||
@@ -537,6 +497,7 @@ export async function exportData(server: FactorioServer) {
|
||||
server._logger.error(`Unable to find ${name} in settings prototypes`);
|
||||
});
|
||||
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
|
||||
server.on("ipc-defines", data => { defines = data; });
|
||||
server.on("output", parsed => {
|
||||
if (parsed.format === "seconds" && parsed.type === "generic") {
|
||||
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
|
||||
@@ -557,6 +518,15 @@ export async function exportData(server: FactorioServer) {
|
||||
throw new Error("No prototypes got exported");
|
||||
}
|
||||
|
||||
if (!defines) {
|
||||
throw new Error("No defines got exported");
|
||||
}
|
||||
|
||||
// compat with versions prior to 1.1.31
|
||||
if (!defines.prototypes) {
|
||||
defines.prototypes = definesPrototypesFallback;
|
||||
}
|
||||
|
||||
// Some mod authors put leading zeros into the versions of their zip files.
|
||||
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
|
||||
for (let entry of await fs.readdir(server.writePath("mods"))) {
|
||||
@@ -579,45 +549,40 @@ export async function exportData(server: FactorioServer) {
|
||||
}
|
||||
}
|
||||
|
||||
const categories: { name: string, types: Set<string> }[] = [
|
||||
{ name: "item", types: itemTypes },
|
||||
{ name: "recipe", types: recipeTypes },
|
||||
{ name: "signal", types: signalTypes },
|
||||
{ name: "technology", types: technologyTypes },
|
||||
{ name: "planet", types: planetTypes },
|
||||
{ name: "quality", types: qualityTypes },
|
||||
{ name: "entity", types: entityTypes },
|
||||
];
|
||||
|
||||
let locale = await exportLocale(server, modVersions, modOrder, "en");
|
||||
|
||||
// Free up the memory used by zip files loaded during the export.
|
||||
zipCache.clear();
|
||||
|
||||
let zip = new JSZip();
|
||||
zip.file("export/settings.json", JSON.stringify(settings));
|
||||
zip.file("export/prototypes.json", JSON.stringify(prototypes));
|
||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
|
||||
zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings));
|
||||
zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes));
|
||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale));
|
||||
zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines));
|
||||
|
||||
// Build a single unified spritesheet across all prototype categories.
|
||||
// Build a single unified spritesheet across all prototypes with icons.
|
||||
const state = await createSheetState();
|
||||
for (const category of categories) {
|
||||
const categoryItems = filterPrototypes(prototypes, category.types);
|
||||
|
||||
if (categoryItems.length === 0) {
|
||||
for (const baseName of prototypesWithIcon) {
|
||||
if (!Object.prototype.hasOwnProperty.call(defines.prototypes, baseName)) {
|
||||
continue;
|
||||
}
|
||||
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
|
||||
server._logger.info(`Exported ${count} ${category.name} icons`);
|
||||
const subTypeNames = new Set(Object.keys(defines.prototypes[baseName]));
|
||||
const prototypesWithIcons = filterAndFixPrototypesWithIcons(prototypes, subTypeNames);
|
||||
|
||||
if (prototypesWithIcons.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state);
|
||||
server._logger.info(`Exported ${count} ${baseName} icons`);
|
||||
}
|
||||
|
||||
// Crop sheet to actual used height and write single spritesheet + metadata.
|
||||
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
|
||||
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
|
||||
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
|
||||
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
||||
zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png));
|
||||
zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata));
|
||||
|
||||
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
|
||||
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
|
||||
|
||||
return zip;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// defines.prototypes from Factorio 1.1.31, the first version that has this.
|
||||
export const definesPrototypesFallback = {
|
||||
"achievement": {
|
||||
"achievement": 0,
|
||||
"build-entity-achievement": 0,
|
||||
"combat-robot-count": 0,
|
||||
"construct-with-robots-achievement": 0,
|
||||
"deconstruct-with-robots-achievement": 0,
|
||||
"deliver-by-robots-achievement": 0,
|
||||
"dont-build-entity-achievement": 0,
|
||||
"dont-craft-manually-achievement": 0,
|
||||
"dont-use-entity-in-energy-production-achievement": 0,
|
||||
"finish-the-game-achievement": 0,
|
||||
"group-attack-achievement": 0,
|
||||
"kill-achievement": 0,
|
||||
"player-damaged-achievement": 0,
|
||||
"produce-achievement": 0,
|
||||
"produce-per-hour-achievement": 0,
|
||||
"research-achievement": 0,
|
||||
"train-path-achievement": 0,
|
||||
},
|
||||
"ambient-sound": {
|
||||
"ambient-sound": 0,
|
||||
},
|
||||
"ammo-category": {
|
||||
"ammo-category": 0,
|
||||
},
|
||||
"animation": {
|
||||
"animation": 0,
|
||||
},
|
||||
"autoplace-control": {
|
||||
"autoplace-control": 0,
|
||||
},
|
||||
"custom-input": {
|
||||
"custom-input": 0,
|
||||
},
|
||||
"damage-type": {
|
||||
"damage-type": 0,
|
||||
},
|
||||
"decorative": {
|
||||
"optimized-decorative": 0,
|
||||
},
|
||||
"editor-controller": {
|
||||
"editor-controller": 0,
|
||||
},
|
||||
"entity": {
|
||||
"accumulator": 0,
|
||||
"ammo-turret": 0,
|
||||
"arithmetic-combinator": 0,
|
||||
"arrow": 0,
|
||||
"artillery-flare": 0,
|
||||
"artillery-projectile": 0,
|
||||
"artillery-turret": 0,
|
||||
"artillery-wagon": 0,
|
||||
"assembling-machine": 0,
|
||||
"beacon": 0,
|
||||
"beam": 0,
|
||||
"boiler": 0,
|
||||
"burner-generator": 0,
|
||||
"car": 0,
|
||||
"cargo-wagon": 0,
|
||||
"character": 0,
|
||||
"character-corpse": 0,
|
||||
"cliff": 0,
|
||||
"combat-robot": 0,
|
||||
"constant-combinator": 0,
|
||||
"construction-robot": 0,
|
||||
"container": 0,
|
||||
"corpse": 0,
|
||||
"curved-rail": 0,
|
||||
"decider-combinator": 0,
|
||||
"deconstructible-tile-proxy": 0,
|
||||
"electric-energy-interface": 0,
|
||||
"electric-pole": 0,
|
||||
"electric-turret": 0,
|
||||
"entity-ghost": 0,
|
||||
"explosion": 0,
|
||||
"fire": 0,
|
||||
"fish": 0,
|
||||
"flame-thrower-explosion": 0,
|
||||
"fluid-turret": 0,
|
||||
"fluid-wagon": 0,
|
||||
"flying-text": 0,
|
||||
"furnace": 0,
|
||||
"gate": 0,
|
||||
"generator": 0,
|
||||
"heat-interface": 0,
|
||||
"heat-pipe": 0,
|
||||
"highlight-box": 0,
|
||||
"infinity-container": 0,
|
||||
"infinity-pipe": 0,
|
||||
"inserter": 0,
|
||||
"item-entity": 0,
|
||||
"item-request-proxy": 0,
|
||||
"lab": 0,
|
||||
"lamp": 0,
|
||||
"land-mine": 0,
|
||||
"leaf-particle": 0,
|
||||
"linked-belt": 0,
|
||||
"linked-container": 0,
|
||||
"loader": 0,
|
||||
"loader-1x1": 0,
|
||||
"locomotive": 0,
|
||||
"logistic-container": 0,
|
||||
"logistic-robot": 0,
|
||||
"market": 0,
|
||||
"mining-drill": 0,
|
||||
"offshore-pump": 0,
|
||||
"particle": 0,
|
||||
"particle-source": 0,
|
||||
"pipe": 0,
|
||||
"pipe-to-ground": 0,
|
||||
"player-port": 0,
|
||||
"power-switch": 0,
|
||||
"programmable-speaker": 0,
|
||||
"projectile": 0,
|
||||
"pump": 0,
|
||||
"radar": 0,
|
||||
"rail-chain-signal": 0,
|
||||
"rail-remnants": 0,
|
||||
"rail-signal": 0,
|
||||
"reactor": 0,
|
||||
"resource": 0,
|
||||
"roboport": 0,
|
||||
"rocket-silo": 0,
|
||||
"rocket-silo-rocket": 0,
|
||||
"rocket-silo-rocket-shadow": 0,
|
||||
"simple-entity": 0,
|
||||
"simple-entity-with-force": 0,
|
||||
"simple-entity-with-owner": 0,
|
||||
"smoke": 0,
|
||||
"smoke-with-trigger": 0,
|
||||
"solar-panel": 0,
|
||||
"speech-bubble": 0,
|
||||
"spider-leg": 0,
|
||||
"spider-vehicle": 0,
|
||||
"splitter": 0,
|
||||
"sticker": 0,
|
||||
"storage-tank": 0,
|
||||
"straight-rail": 0,
|
||||
"stream": 0,
|
||||
"tile-ghost": 0,
|
||||
"train-stop": 0,
|
||||
"transport-belt": 0,
|
||||
"tree": 0,
|
||||
"turret": 0,
|
||||
"underground-belt": 0,
|
||||
"unit": 0,
|
||||
"unit-spawner": 0,
|
||||
"wall": 0,
|
||||
},
|
||||
"equipment": {
|
||||
"active-defense-equipment": 0,
|
||||
"battery-equipment": 0,
|
||||
"belt-immunity-equipment": 0,
|
||||
"energy-shield-equipment": 0,
|
||||
"generator-equipment": 0,
|
||||
"movement-bonus-equipment": 0,
|
||||
"night-vision-equipment": 0,
|
||||
"roboport-equipment": 0,
|
||||
"solar-panel-equipment": 0,
|
||||
},
|
||||
"equipment-category": {
|
||||
"equipment-category": 0,
|
||||
},
|
||||
"equipment-grid": {
|
||||
"equipment-grid": 0,
|
||||
},
|
||||
"fluid": {
|
||||
"fluid": 0,
|
||||
},
|
||||
"font": {
|
||||
"font": 0,
|
||||
},
|
||||
"fuel-category": {
|
||||
"fuel-category": 0,
|
||||
},
|
||||
"god-controller": {
|
||||
"god-controller": 0,
|
||||
},
|
||||
"gui-style": {
|
||||
"gui-style": 0,
|
||||
},
|
||||
"item": {
|
||||
"ammo": 0,
|
||||
"armor": 0,
|
||||
"blueprint": 0,
|
||||
"blueprint-book": 0,
|
||||
"capsule": 0,
|
||||
"copy-paste-tool": 0,
|
||||
"deconstruction-item": 0,
|
||||
"gun": 0,
|
||||
"item": 0,
|
||||
"item-with-entity-data": 0,
|
||||
"item-with-inventory": 0,
|
||||
"item-with-label": 0,
|
||||
"item-with-tags": 0,
|
||||
"mining-tool": 0,
|
||||
"module": 0,
|
||||
"rail-planner": 0,
|
||||
"repair-tool": 0,
|
||||
"selection-tool": 0,
|
||||
"spidertron-remote": 0,
|
||||
"tool": 0,
|
||||
"upgrade-item": 0,
|
||||
},
|
||||
"item-group": {
|
||||
"item-group": 0,
|
||||
},
|
||||
"item-subgroup": {
|
||||
"item-subgroup": 0,
|
||||
},
|
||||
"map-gen-presets": {
|
||||
"map-gen-presets": 0,
|
||||
},
|
||||
"map-settings": {
|
||||
"map-settings": 0,
|
||||
},
|
||||
"module-category": {
|
||||
"module-category": 0,
|
||||
},
|
||||
"mouse-cursor": {
|
||||
"mouse-cursor": 0,
|
||||
},
|
||||
"noise-expression": {
|
||||
"noise-expression": 0,
|
||||
},
|
||||
"noise-layer": {
|
||||
"noise-layer": 0,
|
||||
},
|
||||
"particle": {
|
||||
"optimized-particle": 0,
|
||||
},
|
||||
"recipe": {
|
||||
"recipe": 0,
|
||||
},
|
||||
"recipe-category": {
|
||||
"recipe-category": 0,
|
||||
},
|
||||
"resource-category": {
|
||||
"resource-category": 0,
|
||||
},
|
||||
"shortcut": {
|
||||
"shortcut": 0,
|
||||
},
|
||||
"sound": {
|
||||
"sound": 0,
|
||||
},
|
||||
"spectator-controller": {
|
||||
"spectator-controller": 0,
|
||||
},
|
||||
"sprite": {
|
||||
"sprite": 0,
|
||||
},
|
||||
"technology": {
|
||||
"technology": 0,
|
||||
},
|
||||
"tile": {
|
||||
"tile": 0,
|
||||
},
|
||||
"tile-effect": {
|
||||
"tile-effect": 0,
|
||||
},
|
||||
"tips-and-tricks-item": {
|
||||
"tips-and-tricks-item": 0,
|
||||
},
|
||||
"tips-and-tricks-item-category": {
|
||||
"tips-and-tricks-item-category": 0,
|
||||
},
|
||||
"trigger-target-type": {
|
||||
"trigger-target-type": 0,
|
||||
},
|
||||
"trivial-smoke": {
|
||||
"trivial-smoke": 0,
|
||||
},
|
||||
"tutorial": {
|
||||
"tutorial": 0,
|
||||
},
|
||||
"utility-constants": {
|
||||
"utility-constants": 0,
|
||||
},
|
||||
"utility-sounds": {
|
||||
"utility-sounds": 0,
|
||||
},
|
||||
"utility-sprites": {
|
||||
"utility-sprites": 0,
|
||||
},
|
||||
"virtual-signal": {
|
||||
"virtual-signal": 0,
|
||||
},
|
||||
"wind-sound": {
|
||||
"wind-sound": 0,
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// Library for patching Factorio saves with scenario code.
|
||||
|
||||
import events from "events";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import JSZip from "jszip";
|
||||
import path from "path";
|
||||
import semver from "semver";
|
||||
@@ -93,7 +93,9 @@ export class SaveModule {
|
||||
static async fromPlugin(plugin: BaseInstancePlugin) {
|
||||
let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json"));
|
||||
let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module");
|
||||
if (!await fs.pathExists(moduleDirectory)) {
|
||||
try {
|
||||
await fs.access(moduleDirectory);
|
||||
} catch (err: any) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -431,15 +433,13 @@ export async function patch(savePath: string, modules: SaveModule[]) {
|
||||
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
|
||||
|
||||
// Write back the save
|
||||
let tempSavePath = savePath.replace(/(\.zip)?$/, ".tmp.zip");
|
||||
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
|
||||
let fd = await fs.open(tempSavePath, "w");
|
||||
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
|
||||
try {
|
||||
let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false }));
|
||||
let pipe = stream.pipe(writeStream);
|
||||
await events.once(pipe, "finish");
|
||||
await fs.fsync(fd);
|
||||
} finally {
|
||||
await fs.close(fd);
|
||||
writeStream.destroy();
|
||||
}
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
+13
-12
@@ -1,5 +1,6 @@
|
||||
// Factorio Server interface
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import { type FSWatcher, watch as fsWatch, writeFileSync } from "node:fs";
|
||||
import child_process from "child_process";
|
||||
import path from "path";
|
||||
import JSZip from "jszip";
|
||||
@@ -116,7 +117,7 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
// Extract each file/folder
|
||||
await fs.ensureDir(targetDir);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await Promise.all(
|
||||
Object.values(zip.files).map(async (entry) => {
|
||||
const parts = entry.name.split("/").filter(Boolean);
|
||||
@@ -127,9 +128,9 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
|
||||
|
||||
const entryPath = path.join(targetDir, strippedPath);
|
||||
if (entry.dir) {
|
||||
await fs.ensureDir(entryPath);
|
||||
await fs.mkdir(entryPath, { recursive: true });
|
||||
} else {
|
||||
await fs.ensureDir(path.dirname(entryPath));
|
||||
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
||||
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
|
||||
}
|
||||
})
|
||||
@@ -153,7 +154,7 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
|
||||
const tmpFilePath = `${targetDir}.tmp`;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
await fs.writeFile(tmpFilePath, buffer);
|
||||
await fs.ensureDir(targetDir);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Execute tar CLI to extract
|
||||
try {
|
||||
@@ -165,7 +166,7 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
|
||||
throw err;
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
await fs.remove(tmpFilePath);
|
||||
await fs.rm(tmpFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +632,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||
// https://forums.factorio.com/viewtopic.php?t=123673
|
||||
_whitelistWatcher: fs.FSWatcher | null = null;
|
||||
_whitelistWatcher: FSWatcher | null = null;
|
||||
_whitelist = new Set<string>();
|
||||
|
||||
_logger: lib.Logger;
|
||||
@@ -885,7 +886,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
const filePath = this.writePath("server-whitelist.json");
|
||||
try {
|
||||
this._whitelistWatcher = fs.watch(filePath);
|
||||
this._whitelistWatcher = fsWatch(filePath);
|
||||
} catch (err: any) {
|
||||
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
|
||||
return;
|
||||
@@ -907,7 +908,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
let newWhitelistJson;
|
||||
try {
|
||||
newWhitelistJson = await fs.readJSON(filePath);
|
||||
newWhitelistJson = JSON.parse(await fs.readFile(filePath, "utf8"));
|
||||
} catch (err: any) {
|
||||
this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`);
|
||||
return;
|
||||
@@ -1527,20 +1528,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
});
|
||||
// Must be sync to allow process spawn without awaiting
|
||||
// eslint-disable-next-line node/no-sync
|
||||
fs.writeFileSync(this.writePath("config.ini"), content);
|
||||
writeFileSync(this.writePath("config.ini"), content);
|
||||
}
|
||||
|
||||
_writeMapSettingsSync(mapGenSettings?: object, mapSettings?: object) {
|
||||
// Must be sync to allow process spawn without awaiting
|
||||
if (mapGenSettings) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
fs.writeFileSync(
|
||||
writeFileSync(
|
||||
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t")
|
||||
);
|
||||
}
|
||||
if (mapSettings) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
fs.writeFileSync(
|
||||
writeFileSync(
|
||||
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./src/api";
|
||||
export * from "./src/config";
|
||||
export * from "./src/data";
|
||||
export * from "./src/errors";
|
||||
export * from "./src/export";
|
||||
export * from "./src/factorio/exchange_string";
|
||||
export * from "./src/helpers";
|
||||
export * from "./src/ini";
|
||||
@@ -16,6 +17,7 @@ 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";
|
||||
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
|
||||
|
||||
import { checkSingletonImport } from "./src/check_singleton_import";
|
||||
checkSingletonImport(import.meta.url);
|
||||
+38
-26
@@ -1,9 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const fs = require("node:fs/promises");
|
||||
const JSZip = require("jszip");
|
||||
const klaw = require("klaw");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
@@ -14,30 +12,36 @@ const finished = util.promisify(stream.finished);
|
||||
|
||||
async function buildMod(args, info, modName) {
|
||||
if (args.build) {
|
||||
await fs.ensureDir(args.outputDir);
|
||||
await fs.mkdir(args.outputDir, { recursive: true });
|
||||
modName = args.modName ?? `${info.name}_${info.version}`;
|
||||
|
||||
if (args.pack) {
|
||||
let zip = new JSZip();
|
||||
let walker = klaw(args.sourceDir)
|
||||
.on("data", item => {
|
||||
if (item.stats.isFile()) {
|
||||
// On Windows the path created uses backslashes as the directory sepparator
|
||||
// but the zip file needs to use forward slashes. We can't use the posix
|
||||
// version of relative here as it doesn't work with Windows style paths.
|
||||
let basePath = path.relative(args.sourceDir, item.path).replace(/\\/g, "/");
|
||||
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
|
||||
for await (const dirent of await fs.opendir(args.sourceDir, { recursive: true })) {
|
||||
if (dirent.isFile()) {
|
||||
const itemPath = path.join(dirent.parentPath, dirent.name);
|
||||
// On Windows the path created uses backslashes as the directory sepparator
|
||||
// but the zip file needs to use forward slashes. We can't use the posix
|
||||
// version of relative here as it doesn't work with Windows style paths.
|
||||
let basePath = path.relative(args.sourceDir, itemPath).replace(/\\/g, "/");
|
||||
// eslint-disable-next-line max-depth
|
||||
if (basePath === "info.json") {
|
||||
// The info.json file is overridden later.
|
||||
continue;
|
||||
}
|
||||
})
|
||||
;
|
||||
await events.once(walker, "end");
|
||||
zip.file(path.posix.join(modName, basePath), (await fs.open(itemPath)).createReadStream());
|
||||
}
|
||||
}
|
||||
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
|
||||
let filePath = path.join(args.sourceDir, ...pathParts);
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
throw new Error(`Additional file ${filePath} does not exist`);
|
||||
let fileStream;
|
||||
try {
|
||||
fileStream = (await fs.open(filePath)).createReadStream();
|
||||
} catch (err) {
|
||||
throw new Error(`Error reading additional file ${filePath}`, { cause: err });
|
||||
}
|
||||
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
|
||||
zip.file(path.posix.join(modName, fileName), fileStream);
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
@@ -45,20 +49,28 @@ async function buildMod(args, info, modName) {
|
||||
|
||||
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
||||
console.log(`Writing ${modPath}`);
|
||||
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
|
||||
let writeStream = zip.generateNodeStream().pipe(
|
||||
(await fs.open(modPath, "w")).createWriteStream()
|
||||
);
|
||||
await finished(writeStream);
|
||||
|
||||
} else {
|
||||
let modDir = path.join(args.outputDir, modName);
|
||||
if (await fs.exists(modDir)) {
|
||||
console.log(`Removing existing build ${modDir}`);
|
||||
await fs.remove(modDir);
|
||||
}
|
||||
console.log(`Removing potentially existing build ${modDir}`);
|
||||
await fs.rm(modDir, { force: true, recursive: true, maxRetries: 10 });
|
||||
console.log(`Building ${modDir}`);
|
||||
await fs.copy(args.sourceDir, modDir);
|
||||
await fs.cp(args.sourceDir, modDir, {
|
||||
errorOnExists: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
recursive: true,
|
||||
});
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
|
||||
let filePath = path.join(...pathParts);
|
||||
await fs.copy(filePath, path.join(modDir, fileName));
|
||||
await fs.copyFile(
|
||||
filePath,
|
||||
path.join(modDir, fileName),
|
||||
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE,
|
||||
);
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
@@ -87,7 +99,7 @@ async function build(args) {
|
||||
if (name === info.name) {
|
||||
let modPath = path.join(args.outputDir, entry);
|
||||
console.log(`Removing ${modPath}`);
|
||||
await fs.remove(modPath);
|
||||
await fs.rm(modPath, { force: true, recursive: true, maxRetries: 10 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./src/config";
|
||||
export * from "./src/data";
|
||||
export * from "./src/database";
|
||||
export * from "./src/errors";
|
||||
export * from "./src/export";
|
||||
export * from "./src/external";
|
||||
export * from "./src/factorio";
|
||||
export * from "./src/file_ops";
|
||||
@@ -38,6 +39,7 @@ export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
|
||||
export { default as ModStore } from "./src/ModStore";
|
||||
export { default as RateLimiter } from "./src/RateLimiter";
|
||||
export { default as ValueCache } from "./src/ValueCache";
|
||||
export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal";
|
||||
|
||||
import { checkSingletonImport } from "./src/check_singleton_import";
|
||||
checkSingletonImport(__filename);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -19,28 +19,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"ajv": "^8.17.1",
|
||||
"chalk": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"jszip": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"triple-beam": "^1.4.1",
|
||||
"winston": "catalog:",
|
||||
"winston-transport": "catalog:",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"triple-beam": "^1.4.1",
|
||||
"ajv": "^8.17.1",
|
||||
"klaw": "^4.1.0",
|
||||
"ws": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const fs = require("node:fs");
|
||||
const path = require("path");
|
||||
const Ajv = require("ajv");
|
||||
const standaloneCode = require("ajv/dist/standalone").default;
|
||||
@@ -7,7 +7,7 @@ const standaloneCode = require("ajv/dist/standalone").default;
|
||||
/* eslint-disable node/no-sync */
|
||||
|
||||
// Provide an empty file for message_core to load
|
||||
fs.outputFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), "");
|
||||
fs.writeFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), "");
|
||||
|
||||
const { Message } = require("../dist/node/src/data/messages_core");
|
||||
|
||||
@@ -23,7 +23,7 @@ const ajv = new Ajv({
|
||||
const validate = ajv.compile(Message.jsonSchema);
|
||||
let moduleCode = standaloneCode(ajv, validate);
|
||||
function write(output) {
|
||||
fs.outputFileSync(
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, output),
|
||||
`// generated by scripts/compile_validator.js\n// @ts-nocheck\n/* eslint-disable */\n${moduleCode}`
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileHandle, open, unlink, readFile } from "fs/promises";
|
||||
import { unlinkSync } from "fs";
|
||||
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import assert from "assert";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import events from "events";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { logger } from "./logging";
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
import { Writable } from "stream";
|
||||
import { downloadFile, safeOutputFile } from "./file_ops";
|
||||
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
|
||||
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
|
||||
|
||||
@@ -155,33 +154,6 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
return new this(modsDirectory, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a url's content into a file.
|
||||
*
|
||||
* @param url - Web path to stream from.
|
||||
* @param filePath - File path to stream to.
|
||||
*/
|
||||
private static async downloadFile(url: string | URL, filePath: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is missing.");
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(filePath);
|
||||
const writer = Writable.toWeb(fileStream);
|
||||
await response.body.pipeTo(writer);
|
||||
} catch (error) {
|
||||
logger.error("Error downloading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a matching version from a selection of available versions.
|
||||
*
|
||||
@@ -202,27 +174,16 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`);
|
||||
const url = new URL(selectedRelease.download_url, "https://mods.factorio.com/");
|
||||
url.searchParams.set("username", username);
|
||||
url.searchParams.set("token", token);
|
||||
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
|
||||
const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`;
|
||||
const filePath = path.join(this.modsDirectory, selectedRelease.file_name);
|
||||
|
||||
await ModStore.downloadFile(url, tempFileName);
|
||||
await fs.rename(tempFileName, finalFileName);
|
||||
await downloadFile(url, filePath, "overwrite");
|
||||
await this.loadFile(selectedRelease.file_name);
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`);
|
||||
// Attempt to clean up temp file if it exists
|
||||
try {
|
||||
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
|
||||
await fs.unlink(tempFileName);
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist
|
||||
logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Configuration classes
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import * as fs from "fs-extra";
|
||||
import * as fs from "node:fs/promises";
|
||||
import events from "events";
|
||||
|
||||
import isDeepStrictEqual from "../is_deep_strict_equal";
|
||||
@@ -8,6 +8,7 @@ import { basicType } from "../helpers";
|
||||
import * as libSchema from "../schema";
|
||||
import { StringEnum } from "../data/composites";
|
||||
import { safeOutputFile } from "../file_ops";
|
||||
import * as validators from "./validators";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
@@ -63,14 +64,24 @@ export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
||||
]));
|
||||
export type ConfigSchema = Static<typeof ConfigSchema>;
|
||||
|
||||
type FieldType = "boolean" | "string" | "number" | "object";
|
||||
type FieldValue = null | boolean | string | number | object;
|
||||
export interface FieldDefinition {
|
||||
|
||||
type FieldType<T extends FieldValue> =
|
||||
T extends boolean ? "boolean" :
|
||||
T extends string ? "string" :
|
||||
T extends number ? "number" :
|
||||
T extends object ? "object" :
|
||||
never;
|
||||
|
||||
export interface FieldDefinition<
|
||||
Type extends FieldValue = any,
|
||||
Fields extends Record<keyof Fields, FieldValue> = any,
|
||||
> {
|
||||
/**
|
||||
* string declaring the type of the config value, supports boolean,
|
||||
* string, number and object.
|
||||
*/
|
||||
type: FieldType;
|
||||
type: FieldType<Type>;
|
||||
/**
|
||||
* Text used to identify the config field in user interfaces.
|
||||
*/
|
||||
@@ -79,6 +90,16 @@ export interface FieldDefinition {
|
||||
* Text used to describe the config field in user interfaces.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Validation function ran to confirm the value is consistent with other config values.
|
||||
* Should throw an error if the value in invalid / inconsistent
|
||||
*/
|
||||
validator?: (value: Type, config: Config<Fields>) => void;
|
||||
/**
|
||||
* Names the config fields that the validator depends on.
|
||||
* This will ensure it is checked when these fields are changed.
|
||||
*/
|
||||
dependsOn?: string[];
|
||||
/**
|
||||
* Value to use for the autocomplete attribute in the Web UI's input element.
|
||||
* Useful for making browsers behave properly with credential inputs.
|
||||
@@ -93,7 +114,7 @@ export interface FieldDefinition {
|
||||
/**
|
||||
* True if this field can be set to null. Defaults to false.
|
||||
*/
|
||||
optional?: boolean;
|
||||
optional?: Type extends null ? true : false;
|
||||
/**
|
||||
* Value this config field should take in a newly initialized config.
|
||||
* This can also be an function returning the value to use.
|
||||
@@ -137,8 +158,10 @@ export interface FieldDefinition {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type ConfigDefs<Fields> = {
|
||||
[Prop in keyof Fields]: FieldDefinition;
|
||||
export type ConfigDefs<
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> = {
|
||||
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
||||
}
|
||||
|
||||
function defaultValue(def: FieldDefinition) {
|
||||
@@ -177,14 +200,17 @@ export enum ConfigAccess {
|
||||
* @extends events.EventEmitter
|
||||
*/
|
||||
export class Config<
|
||||
Fields extends { [Field in keyof Fields]: FieldValue },
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> extends events.EventEmitter<ConfigEvents> {
|
||||
/**
|
||||
* Mapping of config name to field meta data
|
||||
* Note that mutating this object will lead to unexpected behaviour.
|
||||
*/
|
||||
declare static fieldDefinitions: ConfigDefs<any>;
|
||||
declare ["constructor"]: typeof Config;
|
||||
declare ["constructor"]: typeof Config<Fields>;
|
||||
|
||||
/** A set of common validators and validator factories */
|
||||
static validators = validators;
|
||||
|
||||
/**
|
||||
* Handle migration between clusterio versions
|
||||
@@ -204,6 +230,8 @@ export class Config<
|
||||
private fields: Fields;
|
||||
private _unknownFields: Record<string, FieldValue> = {};
|
||||
|
||||
/** Set to true during validators to prevent edits */
|
||||
private _readonly = false; // TODO replace with "using config.asReadonly()"
|
||||
/** Set to true when a field in the config is changed. */
|
||||
private dirty = false;
|
||||
/** Set to true when a 'restart required' field in the config is changed. */
|
||||
@@ -244,6 +272,110 @@ export class Config<
|
||||
}
|
||||
}
|
||||
|
||||
/** A staging version of the config with changes tracked, invalided by revertStaging */
|
||||
private _staging?: { config: Config<Fields>, changes: Set<string>};
|
||||
|
||||
/** Get the latest version of staging */
|
||||
// TODO replace with "using config.startStating()"
|
||||
get staging(): Config<Fields> {
|
||||
if (this._staging) { return this._staging.config; }
|
||||
const config = new this.constructor(this.location, this.fields);
|
||||
const changes = new Set<string>();
|
||||
config.on("fieldChanged", name => changes.add(name));
|
||||
this._staging = { config, changes };
|
||||
return config;
|
||||
}
|
||||
|
||||
private static _fieldDependents: Map<string, {
|
||||
name: string,
|
||||
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
|
||||
}[]>;
|
||||
|
||||
/** Map of all fields whose validators depend on the given field */
|
||||
static get fieldDependents(): typeof this._fieldDependents {
|
||||
if (this._fieldDependents) {
|
||||
return this._fieldDependents;
|
||||
}
|
||||
|
||||
const dependents = new Map<string, { name: string, def: FieldDefinition }[]>();
|
||||
|
||||
for (const [name, def] of Object.entries(this.fieldDefinitions)) {
|
||||
if (!def.validator || !def.dependsOn) { continue; }
|
||||
|
||||
for (const otherName of def.dependsOn) {
|
||||
const fieldDependents = dependents.get(otherName);
|
||||
if (fieldDependents) {
|
||||
fieldDependents.push({name, def});
|
||||
} else {
|
||||
dependents.set(otherName, [{name, def}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The type is correct because of "!def.validator", not clear why this doesn't infer
|
||||
this._fieldDependents = dependents as any;
|
||||
return this._fieldDependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce and validate a value according to a field definition.
|
||||
*
|
||||
* @param name - Name of the field being validated.
|
||||
* @param def - Definition describing the field type and constraints.
|
||||
* @param value - Value to validate and coerce.
|
||||
* @throws {InvalidValue} if the value cannot be coerced to the correct type
|
||||
* @returns The validated and coerced value.
|
||||
* @private
|
||||
*/
|
||||
static _coerceValue<T extends FieldDefinition>(name: string, def: T, value: unknown) {
|
||||
// Empty strings are treated as null
|
||||
if (value === "") {
|
||||
value = null;
|
||||
}
|
||||
|
||||
// If def is an enum then it must be a value within it
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
// If it is a string, then attempt to coerce it
|
||||
if (typeof value === "string") {
|
||||
if (def.type === "boolean") {
|
||||
if (value === "true") {
|
||||
value = true;
|
||||
} else if (value === "false") {
|
||||
value = false;
|
||||
}
|
||||
|
||||
} else if (def.type === "number") {
|
||||
const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
||||
if (numberRegExp.test(value.trim())) {
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
|
||||
} else if (def.type === "object") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it is allowed to be null
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
}
|
||||
|
||||
// Check if is the correct type
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
return value as FieldType<T["type"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access for a field
|
||||
*
|
||||
@@ -288,8 +420,14 @@ export class Config<
|
||||
* @param notify - False to skip setting dirty flags and emitting events
|
||||
*/
|
||||
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const prev = this.fields[name];
|
||||
this.fields[name] = value as any;
|
||||
this.revertStaging();
|
||||
|
||||
if (notify && !isDeepStrictEqual(value, prev)) {
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (def.restartRequired) {
|
||||
@@ -308,7 +446,7 @@ export class Config<
|
||||
* @returns Instance of this config
|
||||
*/
|
||||
static async fromFile(location: ConfigLocation, filepath: string) {
|
||||
const fields = await fs.readJson(filepath, { encoding: "utf8" });
|
||||
const fields = JSON.parse(await fs.readFile(filepath, { encoding: "utf8" }));
|
||||
return this.fromJSON(fields, location, filepath);
|
||||
}
|
||||
|
||||
@@ -508,55 +646,50 @@ export class Config<
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
*/
|
||||
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
let value: FieldValue = newValue;
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
// Empty strings are treates as null
|
||||
if (value === "") {
|
||||
value = null;
|
||||
}
|
||||
const value = Config._coerceValue(name, def, newValue);
|
||||
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (def.type === "boolean") {
|
||||
if (value === "true") {
|
||||
value = true;
|
||||
} else if (value === "false") {
|
||||
value = false;
|
||||
}
|
||||
|
||||
} else if (def.type === "number") {
|
||||
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
||||
if (numberRegExp.test(value.trim())) {
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
|
||||
} else if (def.type === "object") {
|
||||
const _readonly = this._readonly;
|
||||
this.staging._readonly = true;
|
||||
this._readonly = true;
|
||||
try {
|
||||
if (def.validator) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
def.validator(value, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._readonly = _readonly;
|
||||
this.staging._readonly = _readonly;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
const dependents = this.constructor.fieldDependents.get(name);
|
||||
if (dependents) {
|
||||
// This mimics stage + commit but optimised for a single value
|
||||
this.staging._set(name, value);
|
||||
this.staging._readonly = true;
|
||||
try {
|
||||
for (const dependent of dependents) {
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.revertStaging();
|
||||
}
|
||||
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
this._set(name, value);
|
||||
@@ -585,6 +718,7 @@ export class Config<
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
@@ -603,6 +737,164 @@ export class Config<
|
||||
delete updated[prop];
|
||||
}
|
||||
|
||||
const _readonly = this._readonly;
|
||||
this.staging._readonly = true;
|
||||
this._readonly = true;
|
||||
try {
|
||||
if (def.validator) {
|
||||
try {
|
||||
def.validator(updated, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._readonly = _readonly;
|
||||
this.staging._readonly = _readonly;
|
||||
}
|
||||
|
||||
const dependents = this.constructor.fieldDependents.get(name);
|
||||
if (dependents) {
|
||||
// This mimics stage + commit but optimised for a single value
|
||||
this.staging._set(name, updated);
|
||||
this.staging._readonly = true;
|
||||
try {
|
||||
for (const dependent of dependents) {
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.revertStaging();
|
||||
}
|
||||
}
|
||||
|
||||
this._set(name, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to set but only validates the field type and not other constraints.
|
||||
* The value is stored in staging and must be committed for the change to take effect.
|
||||
*
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
*/
|
||||
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
const value = Config._coerceValue(name, def, newValue);
|
||||
this.staging._set(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to setProp but only validates the field type and not other constraints.
|
||||
* The value is stored in staging and must be committed for the change to take effect.
|
||||
*
|
||||
* @param name - Name of field to set property on.
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
*/
|
||||
stageProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
prop: string,
|
||||
value?: unknown,
|
||||
remote = this.location,
|
||||
) {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
if (remote !== this.location) {
|
||||
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
const prev = this.staging.get(name) as Record<string, unknown>;
|
||||
const updated = {...prev || {}};
|
||||
|
||||
if (value !== undefined) {
|
||||
updated[prop] = value;
|
||||
} else {
|
||||
delete updated[prop];
|
||||
}
|
||||
|
||||
this.staging._set(name, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit all changes in stating after they have been validated.
|
||||
*
|
||||
* @throws {InvalidValue} if a value is not allowed for its field.
|
||||
*/
|
||||
commitStaging() {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
|
||||
const staging = this._staging;
|
||||
if (!staging) {
|
||||
return; // Nothing to commit
|
||||
}
|
||||
|
||||
// Validate all fields using the current state of staging
|
||||
const config = staging.config;
|
||||
const _readonly = this._readonly;
|
||||
config._readonly = true;
|
||||
try {
|
||||
for (const [fieldName, fieldDef] of Object.entries(this.constructor.fieldDefinitions)) {
|
||||
if (fieldDef.validator) {
|
||||
try {
|
||||
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
config._readonly = _readonly;
|
||||
}
|
||||
|
||||
// Validation success, set all values which have changed
|
||||
for (const fieldName of staging.changes) {
|
||||
const field = fieldName as keyof Fields & string;
|
||||
this._set(field, config.fields[field]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revert all changes in staging */
|
||||
revertStaging() {
|
||||
if (this._readonly) {
|
||||
throw new Error("config is readonly and cannot have values changed");
|
||||
}
|
||||
this._staging = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Core definitions for the configuration system
|
||||
import * as classes from "./classes";
|
||||
import * as validators from "./validators";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { isTargetVersion } from "../data/version";
|
||||
|
||||
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
|
||||
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
|
||||
@@ -18,7 +20,7 @@ export interface ControllerConfigFields {
|
||||
"controller.public_url": string | null;
|
||||
"controller.tls_certificate": string | null;
|
||||
"controller.tls_private_key": string | null;
|
||||
"controller.auth_secret": string;
|
||||
"controller.auth_secret": string | null;
|
||||
"controller.heartbeat_interval": number;
|
||||
"controller.session_timeout": number;
|
||||
"controller.metrics_timeout": number;
|
||||
@@ -73,18 +75,21 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "mods",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"controller.database_directory": {
|
||||
title: "Database directory",
|
||||
description: "Directory where item and configuration data is stored.",
|
||||
type: "string",
|
||||
initialValue: "database",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"controller.autosave_interval": {
|
||||
title: "Autosave Interval",
|
||||
description: "Interval in seconds to autosave data in memory to disk.",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.http_port": {
|
||||
title: "HTTP Port",
|
||||
@@ -93,6 +98,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
type: "number",
|
||||
optional: true,
|
||||
initialValue: 8080,
|
||||
validator: validators.optional(validators.all(
|
||||
validators.integer,
|
||||
validators.greaterThanZero
|
||||
)),
|
||||
},
|
||||
"controller.https_port": {
|
||||
title: "HTTPS Port",
|
||||
@@ -100,6 +109,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.all(
|
||||
validators.integer,
|
||||
validators.greaterThanZero
|
||||
)),
|
||||
},
|
||||
"controller.bind_address": {
|
||||
title: "Bind Address",
|
||||
@@ -107,6 +120,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost
|
||||
},
|
||||
"controller.trusted_proxies": {
|
||||
title: "Trusted Proxies",
|
||||
@@ -114,12 +128,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
"Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on",
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, see parseTrustedProxies
|
||||
},
|
||||
"controller.public_url": {
|
||||
title: "Public URL",
|
||||
description: "Public facing URL the controller is hosted on, including the protocol.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
// TODO extendedValidation, valid url including the protocol
|
||||
},
|
||||
"controller.tls_certificate": {
|
||||
title: "TLS Certificate",
|
||||
@@ -127,6 +143,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"controller.tls_private_key": {
|
||||
title: "TLS Private Key",
|
||||
@@ -134,6 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"controller.auth_secret": {
|
||||
access: ["controller"],
|
||||
@@ -151,30 +169,35 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
description: "Interval heartbeats are sent out on WebSocket connections.",
|
||||
type: "number",
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.session_timeout": {
|
||||
title: "Session Timeout",
|
||||
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.metrics_timeout": {
|
||||
title: "Metrics Timeout",
|
||||
description: "Timeout in seconds for metrics gathering from hosts.",
|
||||
type: "number",
|
||||
initialValue: 8,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.system_metrics_interval": {
|
||||
title: "System Metrics Interval",
|
||||
description: "Interval in seconds to collect and update system metrics for the Web UI",
|
||||
type: "number",
|
||||
initialValue: 10,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
"controller.proxy_stream_timeout": {
|
||||
title: "Proxy Stream Timeout",
|
||||
description: "Timeout in seconds for proxy streams to start flowing.",
|
||||
type: "number",
|
||||
initialValue: 15,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
@@ -219,12 +242,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
description: "Duration in minutes to cache mod portal API responses.",
|
||||
type: "number",
|
||||
initialValue: 30,
|
||||
validator: validators.greaterThanEqualZero, // Zero is allowed here to disable cache
|
||||
},
|
||||
"controller.mod_portal_page_size": {
|
||||
title: "Mod Portal Page Size",
|
||||
description: "Maximum number of results per page when querying the Factorio mod portal API.",
|
||||
type: "number",
|
||||
initialValue: 1000,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"controller.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
@@ -299,6 +324,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initialValue: "factorio",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.mods_directory": {
|
||||
title: "Mods Directory",
|
||||
@@ -306,18 +332,21 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "mods",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.instances_directory": {
|
||||
description: "Path to directory to store instances in.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "instances",
|
||||
validator: validators.filePath,
|
||||
},
|
||||
"host.controller_url": {
|
||||
description: "URL to connect to the controller at",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "http://localhost:8080/",
|
||||
// TODO extendedValidation, valid url including the protocol
|
||||
},
|
||||
"host.controller_token": {
|
||||
access: ["host"],
|
||||
@@ -325,6 +354,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "enter token here",
|
||||
// TODO extendedValidation, valid JWT with matching host id
|
||||
},
|
||||
"host.public_address": {
|
||||
description: "Public facing address players should connect to in order to join instances on this host",
|
||||
@@ -338,6 +368,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
"ranges separated with a dash.",
|
||||
type: "string",
|
||||
initialValue: "34100-34199",
|
||||
// TODO extendedValidation, port range
|
||||
},
|
||||
"host.factorio_username": {
|
||||
title: "Factorio Username",
|
||||
@@ -363,6 +394,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
"host.allow_remote_updates": {
|
||||
description: "When true, allows a remote event to trigger a clusterio update via npm",
|
||||
@@ -456,6 +488,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "number",
|
||||
optional: true,
|
||||
hidden: true,
|
||||
// Internal value, extendedValidation not used
|
||||
},
|
||||
"instance.auto_start": {
|
||||
description: "Automatically start this instance when the host hosting it is started up",
|
||||
@@ -473,6 +506,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
validator: function(value) {
|
||||
if (!isTargetVersion(value)) {
|
||||
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
|
||||
}
|
||||
},
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
@@ -481,6 +519,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.filePath),
|
||||
},
|
||||
"factorio.shutdown_timeout": {
|
||||
description:
|
||||
@@ -488,24 +527,27 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
"the process. Set to 0 to disable.",
|
||||
type: "number",
|
||||
initialValue: 300,
|
||||
optional: true,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
"factorio.game_port": {
|
||||
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanEqualZero),
|
||||
},
|
||||
"factorio.host_assigned_game_port": {
|
||||
access: ["host"],
|
||||
type: "number",
|
||||
optional: true,
|
||||
// Internal value, extendedValidation not used
|
||||
},
|
||||
"factorio.rcon_port": {
|
||||
description: "TCP port to run RCON on, uses a random port if null",
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
optional: true,
|
||||
validator: validators.optional(validators.greaterThanEqualZero),
|
||||
},
|
||||
"factorio.rcon_password": {
|
||||
credential: ["host", "controller"],
|
||||
@@ -520,6 +562,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
"autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
|
||||
type: "number",
|
||||
initialValue: 5,
|
||||
dependsOn: ["factorio.settings", "factorio.settings.autosave_slots"],
|
||||
validator: function(value, config) {
|
||||
const factorioSettings = config.get("factorio.settings");
|
||||
const autosaveSlots = factorioSettings.autosave_slots;
|
||||
if (typeof autosaveSlots === "number" && value < autosaveSlots) {
|
||||
throw new Error("Value cannot be less than the number of autosave slots");
|
||||
}
|
||||
},
|
||||
},
|
||||
"factorio.mod_pack_id": {
|
||||
title: "Mod Pack",
|
||||
@@ -568,6 +618,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
],
|
||||
type: "object",
|
||||
initialValue: {}, // See create instance handler in controller.
|
||||
// TODO extendedValidation, we could check keys and types here if we wanted
|
||||
},
|
||||
"factorio.verbose_logging": {
|
||||
description: "Enable verbose logging on the Factorio server",
|
||||
@@ -610,6 +661,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
initialValue: 5,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -644,12 +696,13 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initialValue: 60,
|
||||
validator: validators.greaterThanEqualZero,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function validateFields(
|
||||
function validateFieldNames(
|
||||
pluginName: string,
|
||||
fields: Record<string, classes.FieldDefinition>,
|
||||
) {
|
||||
@@ -663,36 +716,73 @@ function validateFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* Add config fields defined by the provided plugin infos
|
||||
* Add the config fields defined by the by a specific plugin infos
|
||||
*/
|
||||
export function addPluginFieldDefinitions(
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
|
||||
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
||||
Config: typeof classes.Config<any>
|
||||
) {
|
||||
// Define plugin.load_plugin
|
||||
const fieldDef: classes.FieldDefinition = {
|
||||
title: "Load Plugin",
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
restartRequired: true,
|
||||
};
|
||||
|
||||
// The validator is dynamic based on the feature requirements of the plugin
|
||||
if (kind === "instanceConfigFields" && pluginInfo.features) {
|
||||
fieldDef.dependsOn = [];
|
||||
|
||||
const savePatching = pluginInfo.features.includes("SavePatching");
|
||||
if (savePatching) {
|
||||
fieldDef.dependsOn.push("factorio.enable_save_patching");
|
||||
}
|
||||
|
||||
const scriptCommands = pluginInfo.features.includes("ScriptCommands");
|
||||
if (scriptCommands) {
|
||||
fieldDef.dependsOn.push("factorio.enable_script_commands");
|
||||
}
|
||||
|
||||
if (scriptCommands || savePatching) {
|
||||
fieldDef.validator = (enabled, config) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (savePatching && !config.get("factorio.enable_save_patching")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires save patching`);
|
||||
}
|
||||
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
|
||||
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Config.fieldDefinitions[`${pluginInfo.name}.load_plugin`] = fieldDef;
|
||||
|
||||
// Merge in other fields from the plugin
|
||||
const fields = pluginInfo[kind];
|
||||
if (fields) {
|
||||
validateFieldNames(pluginInfo.name, fields);
|
||||
Object.assign(Config.fieldDefinitions, fields);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all config fields defined by the provided plugin infos
|
||||
*
|
||||
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
|
||||
*/
|
||||
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
|
||||
function pluginConfig(
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
|
||||
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
||||
Config: typeof ControllerConfig | typeof HostConfig | typeof InstanceConfig,
|
||||
) {
|
||||
(Config.fieldDefinitions as any)[`${pluginInfo.name}.load_plugin`] = {
|
||||
title: "Load Plugin",
|
||||
restartRequired: true,
|
||||
type: "boolean",
|
||||
initialValue: true,
|
||||
};
|
||||
|
||||
const fields = pluginInfo[kind];
|
||||
if (fields) {
|
||||
validateFields(pluginInfo.name, fields);
|
||||
Object.assign(Config.fieldDefinitions, fields);
|
||||
}
|
||||
}
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
pluginConfig(pluginInfo, "controllerConfigFields", ControllerConfig);
|
||||
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
|
||||
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
|
||||
pluginConfig(pluginInfo, "hostConfigFields", HostConfig);
|
||||
addPluginFieldDefinitions(pluginInfo, "hostConfigFields", HostConfig);
|
||||
}
|
||||
if (pluginInfo.instanceEntrypoint) {
|
||||
pluginConfig(pluginInfo, "instanceConfigFields", InstanceConfig);
|
||||
addPluginFieldDefinitions(pluginInfo, "instanceConfigFields", InstanceConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "path";
|
||||
|
||||
/** Combine multiple validators into a single one. */
|
||||
export function all<V, C> (
|
||||
...validators: ((value: V, config: C) => void)[]
|
||||
) {
|
||||
return function (value: V, config: C) {
|
||||
for (const validator of validators) {
|
||||
validator(value, config);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip validation of optional fields when null */
|
||||
export function optional<V, C> (
|
||||
validator: (value: V, config: C) => void
|
||||
) {
|
||||
return function (value: V | null, config: C) {
|
||||
if (value === null) { return; }
|
||||
validator(value, config);
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than the minimum value */
|
||||
export function greaterThan(min: number) {
|
||||
return function (value: number) {
|
||||
if (value <= min) {
|
||||
throw new Error(`Value must be greater than ${min}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than 0 */
|
||||
export const greaterThanZero = greaterThan(0);
|
||||
|
||||
/** Value must be greater than or equal to the minimum value */
|
||||
export function greaterThanEqual(min: number) {
|
||||
return function (value: number) {
|
||||
if (value < min) {
|
||||
throw new Error(`Value must be greater than ${min}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Value must be greater than or equal to 0 */
|
||||
export const greaterThanEqualZero = greaterThanEqual(0);
|
||||
|
||||
/** Value must be an integer value */
|
||||
export function integer(value: number) {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new Error("Value must be an integer");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value must be a valid file path, it does not need to exist
|
||||
* On posix this will only error for non string values, which isn't very useful
|
||||
* However, this could be expanded in the future to check system OS
|
||||
*/
|
||||
export function filePath(value: string) {
|
||||
try {
|
||||
path.resolve(value);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Value must be a valid path: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import JSZip from "jszip";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as libSchema from "../schema";
|
||||
import * as libLuaTools from "../lua_tools";
|
||||
|
||||
import ExportManifest from "./ExportManifest";
|
||||
import type { ExportSettings } from "../export";
|
||||
import type { Logger } from "../logging";
|
||||
|
||||
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
|
||||
@@ -357,7 +358,7 @@ export default class ModPack {
|
||||
* Setting prototypes exported from the game.
|
||||
* @param logger - Logger used to report warnings on.
|
||||
*/
|
||||
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
|
||||
fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) {
|
||||
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
|
||||
let prototypes = Object.entries(settingPrototypes)
|
||||
.filter(([type, _]) => knownTypes.includes(type))
|
||||
@@ -370,7 +371,7 @@ export default class ModPack {
|
||||
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`);
|
||||
continue;
|
||||
}
|
||||
if (prototype.default_value === undefined) {
|
||||
if ((prototype as any).default_value === undefined) {
|
||||
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
|
||||
continue;
|
||||
}
|
||||
@@ -389,7 +390,7 @@ export default class ModPack {
|
||||
if (type === "color-setting") {
|
||||
value = libLuaTools.normalizeColor(value as object);
|
||||
}
|
||||
this.settings[settingType].set(prototype.name, { value });
|
||||
this.settings[settingType].set(prototype.name, { value } as ModSetting);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,28 @@ export class ControllerConfigGetRequest {
|
||||
static Response = plainJson(ControllerConfig.jsonSchema);
|
||||
}
|
||||
|
||||
export class ControllerConfigSetRequest {
|
||||
declare ["constructor"]: typeof ControllerConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.controller.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class ControllerConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof ControllerConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -37,6 +37,28 @@ export class HostConfigGetRequest {
|
||||
static Response = plainJson(HostConfig.jsonSchema);
|
||||
}
|
||||
|
||||
export class HostConfigSetRequest {
|
||||
declare ["constructor"]: typeof HostConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "controller"] as const;
|
||||
static dst = "host" as const;
|
||||
static permission = "core.host.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class HostConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof HostConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -102,6 +102,30 @@ export class InstanceConfigGetRequest {
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigSetRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.instance.update_config" as const;
|
||||
|
||||
constructor(
|
||||
public instanceId: number,
|
||||
public fields: Record<string, string | Record<string, unknown>>,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instanceId": Type.Integer(),
|
||||
"fields": Type.Record(Type.String(), Type.Union([
|
||||
Type.String(), Type.Record(Type.String(), Type.Unknown()),
|
||||
])),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.instanceId, json.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceConfigSetFieldRequest {
|
||||
declare ["constructor"]: typeof InstanceConfigSetFieldRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module lib/database
|
||||
* @author Hornwitser
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import * as libFileOps from "./file_ops";
|
||||
import { basicType } from "./helpers";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { safeOutputFile } from "./file_ops";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { FactorioColor } from "./lua_tools";
|
||||
|
||||
/**
|
||||
* A localised string.
|
||||
* @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html)
|
||||
*/
|
||||
export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]];
|
||||
|
||||
/**
|
||||
* Shared properties of all prototypes.
|
||||
* @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html)
|
||||
*/
|
||||
export interface FactorioPrototypeBase {
|
||||
[index: string]: unknown,
|
||||
type: string,
|
||||
name: string,
|
||||
order?: string,
|
||||
localised_name?: FactorioLocalisedString,
|
||||
localised_description?: FactorioLocalisedString,
|
||||
factoriopedia_description?: FactorioLocalisedString,
|
||||
subgroup?: string,
|
||||
hidden?: boolean,
|
||||
hidden_in_factoriopedia?: boolean,
|
||||
parameter?: boolean,
|
||||
factoriopedia_simulation?: unknown,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the mod setting is part of.
|
||||
* @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property)
|
||||
*/
|
||||
export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user";
|
||||
|
||||
/**
|
||||
* Boolean mod setting prototype.
|
||||
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
|
||||
*/
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "bool-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: boolean,
|
||||
forced_value?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer mod setting prototype.
|
||||
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
|
||||
*/
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "int-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
minimum_value?: number,
|
||||
maximum_value?: number,
|
||||
allowed_values?: number[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Real number mod setting prototype.
|
||||
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
|
||||
*/
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "double-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
minimum_value?: number,
|
||||
maximum_value?: number,
|
||||
allowed_values?: number[],
|
||||
}
|
||||
|
||||
/**
|
||||
* String mod setting prototype.
|
||||
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
|
||||
*/
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "string-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: string,
|
||||
allow_black?: boolean,
|
||||
auto_trim?: string,
|
||||
allowed_values?: string[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Color mod setting prototype.
|
||||
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
|
||||
*/
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
|
||||
type: "color-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: FactorioColor,
|
||||
forced_value?: boolean,
|
||||
}
|
||||
|
||||
export type FactorioModSettingPrototype =
|
||||
| FactorioBoolSettingPrototype
|
||||
| FactorioIntSettingPrototype
|
||||
| FactorioDoubleSettingPrototype
|
||||
| FactorioStringSettingPrototype
|
||||
| FactorioColorSettingPrototype
|
||||
;
|
||||
|
||||
/**
|
||||
* A list of prototypes as it appears in Factorio's Lua API.
|
||||
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
|
||||
*/
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
|
||||
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
|
||||
export type FactorioDefines = {
|
||||
[index: string]: FactorioDefinesValue,
|
||||
prototypes: Record<string, Record<string, number>>,
|
||||
};
|
||||
|
||||
export type ExportSettings = FactorioPrototypes<FactorioModSettingPrototype>;
|
||||
export type ExportPrototypes = FactorioPrototypes;
|
||||
export type ExportLocale = [string, string][];
|
||||
export interface ExportMetadataEntry {
|
||||
name: string,
|
||||
type: string,
|
||||
localised_name?: FactorioLocalisedString,
|
||||
icon?: {
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
path?: string,
|
||||
},
|
||||
}
|
||||
export type ExportMetadata = Record<string, ExportMetadataEntry[]>;
|
||||
export type ExportDefines = FactorioDefines;
|
||||
+126
-22
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* @module lib/file_ops
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import crypto from "crypto"; // needed for getTempFile
|
||||
import stream from "stream";
|
||||
|
||||
|
||||
/**
|
||||
* Returns the newest file in a directory
|
||||
@@ -43,7 +46,7 @@ export async function getNewestFile(directory: string, filter = (_name: string)
|
||||
* @returns The size in bytes of all files in the directory.
|
||||
*/
|
||||
export async function directorySize(directory: string) {
|
||||
let dirEntries: fs.Dirent[];
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
@@ -88,8 +91,12 @@ export async function findUnusedName(directory: string, name: string, extension
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (!await fs.pathExists(path.join(directory, `${name}${extension}`))) {
|
||||
return `${name}${extension}`;
|
||||
try {
|
||||
await fs.access(path.join(directory, `${name}${extension}`));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return `${name}${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
let match = /^(.*?)(-(\d+))?$/.exec(name)!;
|
||||
@@ -122,12 +129,13 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely write data to a file
|
||||
* Safely overwrite file data
|
||||
*
|
||||
* Same as fs-extra.outputFile except the data is written to a temporary
|
||||
* file that's renamed over the target file. The name of the temporary file
|
||||
* is the same as the target file with the suffix `.tmp` added before the
|
||||
* extension.
|
||||
* Writes the passed data to a temporary file and rename it over the target
|
||||
* file. If the operation fails because the parent directory does not exist, it
|
||||
* attempts to create the directory, including any ancestors and then retries
|
||||
* the operation. The name of the temporary file is the same as the target file
|
||||
* with the suffix `.tmp` added before the extension.
|
||||
*
|
||||
* If the operation fails it may leave behind the temporary file. This
|
||||
* should not be too much of an issue as the next time the same file is
|
||||
@@ -135,26 +143,32 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
*
|
||||
* @param file - Path to file to write.
|
||||
* @param data - Content to write.
|
||||
* @param options - see fs.writeFile, `flag` must not be set.
|
||||
* @param options - see fs.writeFile, `flag` and `flush` may not be used.
|
||||
*/
|
||||
export async function safeOutputFile(file: string, data: string | Buffer, options: fs.WriteFileOptions ={}) {
|
||||
export async function safeOutputFile(
|
||||
file: string,
|
||||
data: string | Buffer,
|
||||
options?: Omit<
|
||||
Extract<Parameters<typeof fs.writeFile>[2], { flush?: boolean }>,
|
||||
"flag" | "flush"
|
||||
>,
|
||||
) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
if (dir && !await fs.pathExists(dir)) {
|
||||
await fs.mkdirs(dir);
|
||||
}
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
let fd = await fs.open(temporary, "w");
|
||||
try {
|
||||
await fs.writeFile(fd, data, options);
|
||||
await fs.fsync(fd);
|
||||
} finally {
|
||||
await fs.close(fd);
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
// Try creating the folder and then retry the operation.
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
|
||||
// Reserved names by almost all filesystems
|
||||
const badNames = [".", ".."];
|
||||
|
||||
@@ -235,3 +249,93 @@ export function cleanFilename(name: string) {
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a write stream to a guaranteed newly created temporary file in the
|
||||
* same folder as the target `filePath`.
|
||||
*
|
||||
* @param filePath - The target file to create a temporary stream for.
|
||||
* @returns The path to the temporary file that was opened and the write stream to it.
|
||||
*/
|
||||
export async function createTempWriteStream(filePath: string) {
|
||||
const { dir, name: fileName, ext } = path.parse(filePath);
|
||||
let increment = 1;
|
||||
let writeStream;
|
||||
let tempFilePath = path.format({ dir, name: `${fileName}.tmp`, ext });
|
||||
while (true) {
|
||||
try {
|
||||
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
increment += 1;
|
||||
tempFilePath = path.format({ dir, name: `${fileName}-${increment}.tmp`, ext });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [tempFilePath, writeStream] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file by fetching the given url and streaming the body content to it.
|
||||
*
|
||||
* @param url - The resource to download
|
||||
* @param filePath - Path to the file store the resource data in.
|
||||
* @param overwriteMode - What to do if the target filePath already exists. Use
|
||||
* overwrite to clobber it, use rename to write to a differently named file,
|
||||
* and error to throw an EEXIST error.
|
||||
* @returns file path that was written to. May be different from `filePath` if
|
||||
* overwriteMode is set to rename.
|
||||
*/
|
||||
export async function downloadFile(
|
||||
url: URL | string,
|
||||
filePath: string,
|
||||
overwriteMode: "overwrite" | "rename" | "error",
|
||||
) {
|
||||
// Prepare file to stream content to.
|
||||
let writeStream;
|
||||
let tempFilePath;
|
||||
if (overwriteMode === "error") {
|
||||
tempFilePath = filePath;
|
||||
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
|
||||
} else {
|
||||
[tempFilePath, writeStream] = await createTempWriteStream(filePath);
|
||||
}
|
||||
|
||||
// Fetch stream bytes
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !response.body) {
|
||||
writeStream.end();
|
||||
await events.once(writeStream, "close");
|
||||
await fs.unlink(tempFilePath);
|
||||
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const writer = stream.Writable.toWeb(writeStream);
|
||||
await response.body.pipeTo(writer);
|
||||
|
||||
let targetFilePath = filePath;
|
||||
if (overwriteMode === "overwrite") {
|
||||
await fs.rename(tempFilePath, targetFilePath);
|
||||
} else if (overwriteMode === "rename") {
|
||||
let increment = 1;
|
||||
let { dir, name, ext } = path.parse(targetFilePath);
|
||||
while (true) {
|
||||
try {
|
||||
await fs.writeFile(targetFilePath, Buffer.alloc(0), { flag: "wx" });
|
||||
break;
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
increment += 1;
|
||||
targetFilePath = path.format({ dir, name: `${name}-${increment}`, ext });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
await fs.rename(tempFilePath, targetFilePath);
|
||||
}
|
||||
return targetFilePath;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module lib/hash
|
||||
*/
|
||||
import crypto from "crypto";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
|
||||
/**
|
||||
@@ -35,6 +35,6 @@ export function hashStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
* @param path - Path to the file to hash.
|
||||
* @returns hash of the file given.
|
||||
*/
|
||||
export function hashFile(path: string) {
|
||||
return hashStream(fs.createReadStream(path));
|
||||
export async function hashFile(path: string) {
|
||||
return hashStream((await fs.open(path)).createReadStream());
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.ControllerRestartRequest,
|
||||
controller.ControllerUpdateRequest,
|
||||
controller.ControllerConfigGetRequest,
|
||||
controller.ControllerConfigSetRequest,
|
||||
controller.ControllerConfigSetFieldRequest,
|
||||
controller.ControllerConfigSetPropRequest,
|
||||
controller.HostGenerateTokenRequest,
|
||||
@@ -43,6 +44,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
host.HostRestartRequest,
|
||||
host.HostUpdateRequest,
|
||||
host.HostConfigGetRequest,
|
||||
host.HostConfigSetRequest,
|
||||
host.HostConfigSetFieldRequest,
|
||||
host.HostConfigSetPropRequest,
|
||||
host.HostListRequest,
|
||||
@@ -59,6 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceDetailsUpdatesEvent,
|
||||
instance.InstanceCreateRequest,
|
||||
instance.InstanceConfigGetRequest,
|
||||
instance.InstanceConfigSetRequest,
|
||||
instance.InstanceConfigSetFieldRequest,
|
||||
instance.InstanceConfigSetPropRequest,
|
||||
instance.InstanceAssignRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { logger } from "./logging";
|
||||
import * as libFileOps from "./file_ops";
|
||||
@@ -174,12 +174,15 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
||||
*/
|
||||
async function checkPackageJson(pluginPath: string): Promise<boolean> {
|
||||
const packageJsonPath = path.join(pluginPath, "package.json");
|
||||
if (!await fs.exists(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, { encoding: "utf8" }));
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
|
||||
} catch (err: any) {
|
||||
return false;
|
||||
}
|
||||
const packageJson = JSON.parse(content);
|
||||
|
||||
// Check if package has the clusterio-plugin keyword
|
||||
return packageJson.keywords?.includes("clusterio-plugin");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* @author Hornwitser
|
||||
* @module lib/logging_utils
|
||||
*/
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import type { ReadStream } from "node:fs";
|
||||
import path from "path";
|
||||
import stream from "stream";
|
||||
import util from "util";
|
||||
@@ -320,7 +321,7 @@ export class LogIndex {
|
||||
async indexFile(file: string) {
|
||||
let filePath = path.join(this.logDirectory, file);
|
||||
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
let fileStream = fs.createReadStream(filePath);
|
||||
let fileStream = (await fs.open(filePath)).createReadStream();
|
||||
fileStream.pipe(lineStream);
|
||||
let entry = {
|
||||
levels: new Set() as Set<keyof typeof levels>,
|
||||
@@ -478,11 +479,11 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
|
||||
continue;
|
||||
}
|
||||
let filePath = path.join(logDirectory, file);
|
||||
let fileStream: fs.ReadStream;
|
||||
let fileStream: ReadStream;
|
||||
let lineStream: libStream.LineSplitter;
|
||||
if (filter.order === "asc") {
|
||||
lineStream = new libStream.LineSplitter({ readableObjectMode: true });
|
||||
fileStream = fs.createReadStream(filePath);
|
||||
fileStream = (await fs.open(filePath)).createReadStream();
|
||||
} else {
|
||||
lineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
|
||||
fileStream = await libStream.createReverseReadStream(filePath);
|
||||
|
||||
@@ -7,6 +7,14 @@ import type { Logger } from "./logging";
|
||||
import type { FieldDefinition } from "./config";
|
||||
import type { PlayerStats } from "./data";
|
||||
|
||||
|
||||
export const PluginFeatureFlags = [
|
||||
/** The plugin requires module code to be patched into the save */
|
||||
"SavePatching",
|
||||
/** The plugin requires access to script commands over rcon */
|
||||
"ScriptCommands",
|
||||
] as const;
|
||||
|
||||
/** Used to define the plugin export in plugins */
|
||||
export type PluginDeclaration = {
|
||||
name: string;
|
||||
@@ -26,6 +34,8 @@ export type PluginDeclaration = {
|
||||
webEntrypoint?: string;
|
||||
controlConfigFields?: Record<string, FieldDefinition>;
|
||||
|
||||
features?: (typeof PluginFeatureFlags)[number][];
|
||||
|
||||
messages?: any[];
|
||||
routes?: string[];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module lib/plugin_loader
|
||||
*/
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import fs from "node:fs/promises";
|
||||
import * as libErrors from "./errors";
|
||||
import * as libPlugin from "./plugin";
|
||||
import { logger } from "./logging";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from "assert";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs"; // Need the non-promise api for reverse stream
|
||||
import stream from "stream";
|
||||
import util from "node:util";
|
||||
|
||||
const fsOpen = util.promisify(fs.open);
|
||||
const fsFstat = util.promisify(fs.fstat);
|
||||
|
||||
|
||||
/**
|
||||
@@ -13,7 +17,7 @@ import stream from "stream";
|
||||
* ```ts
|
||||
* let lineStream = new lib.LineSplitter({ readableObjectMode: true });
|
||||
* lineStream.on("data", line => { console.log(line.toString("utf8")); });
|
||||
* let fileStream = fs.createReadStream(path);
|
||||
* let fileStream = (await fs.open(path)).createReadStream();
|
||||
* fileStream.pipe(lineStream);
|
||||
* ```
|
||||
*/
|
||||
@@ -141,8 +145,8 @@ export async function createReverseReadStream(path: string, options?: ReadStream
|
||||
if (typeof options === "string") {
|
||||
options = { encoding: options };
|
||||
}
|
||||
const fileFd = await fs.open(path, "r");
|
||||
const fileSize = (await fs.fstat(fileFd)).size;
|
||||
const fileFd = await fsOpen(path, "r");
|
||||
const fileSize = (await fsFstat(fileFd)).size;
|
||||
let filePosition = fileSize;
|
||||
const reverseFs = {
|
||||
read(
|
||||
@@ -162,8 +166,5 @@ export async function createReverseReadStream(path: string, options?: ReadStream
|
||||
open() { assert(false); },
|
||||
close(fd: number, callback: () => void) { return fs.close(fd, callback); },
|
||||
};
|
||||
return fs.createReadStream(
|
||||
"",
|
||||
{ ...options, fd: fileFd, fs: reverseFs } as ReadStreamOptions, // type specification is missing fs prop.
|
||||
);
|
||||
return fs.createReadStream("", { ...options, fd: fileFd, fs: reverseFs });
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "os";
|
||||
import util from "util";
|
||||
import { Gauge } from "./prometheus";
|
||||
@@ -92,14 +92,7 @@ export const systemDiskCapacityBytes = new Gauge(
|
||||
"Size of the filesystem of the current working directory Node.js runs on",
|
||||
{
|
||||
callback: async function(collector) {
|
||||
// statfs was added in Node.js v18.15.0 and may not be present.
|
||||
// TODO: remove this check once minimum supported Node.js >= v18.15.0.
|
||||
if (!fs.statfs) {
|
||||
collector.set(0);
|
||||
return;
|
||||
}
|
||||
const statFsAsync = util.promisify(fs.statfs);
|
||||
const stats = await statFsAsync(".");
|
||||
const stats = await fs.statfs(".");
|
||||
collector.set(stats.blocks * stats.bsize);
|
||||
},
|
||||
},
|
||||
@@ -110,13 +103,7 @@ export const systemDiskAvailableBytes = new Gauge(
|
||||
"Available space on the filesystem of the current working directory Node.js runs on",
|
||||
{
|
||||
callback: async function(collector) {
|
||||
// TODO: remove this check once minimum supported Node.js >= v18.15.0.
|
||||
if (!fs.statfs) {
|
||||
collector.set(0);
|
||||
return;
|
||||
}
|
||||
const statFsAsync = util.promisify(fs.statfs);
|
||||
const stats = await statFsAsync(".");
|
||||
const stats = await fs.statfs(".");
|
||||
collector.set(stats.bavail * stats.bsize);
|
||||
},
|
||||
},
|
||||
@@ -148,14 +135,9 @@ export async function gatherSystemInfo(
|
||||
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(([total, idle]) => (total - idle) / total);
|
||||
previousTotalCpuMs = currentTotalCpuMs;
|
||||
previousIdleCpuMs = currentIdleCpuMs;
|
||||
let diskCapacity = 0;
|
||||
let diskAvailable = 0;
|
||||
if (fs.statfs) { // TODO: remove this check once minimum supported Node.js >= v18.15.0.
|
||||
const statFsAsync = util.promisify(fs.statfs);
|
||||
const stats = await statFsAsync(".");
|
||||
diskCapacity = stats.blocks * stats.bsize;
|
||||
diskAvailable = stats.bavail * stats.bsize;
|
||||
}
|
||||
const stats = await fs.statfs(".");
|
||||
let diskCapacity = stats.blocks * stats.bsize;
|
||||
let diskAvailable = stats.bavail * stats.bsize;
|
||||
|
||||
const now = Date.now();
|
||||
return new SystemInfo(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./src/check_singleton_import.ts",
|
||||
"./src/datastore.ts",
|
||||
"./src/errors.ts",
|
||||
"./src/export.ts",
|
||||
"./src/factorio/exchange_string.ts",
|
||||
"./src/helpers.ts",
|
||||
"./src/hash.ts",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
|
||||
import type { Control } from "../util/websocket";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -67,8 +68,7 @@ function renderInput(inputComponents: Record<string, InputComponent>, def: lib.F
|
||||
type BaseConfigTreeProps = {
|
||||
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
|
||||
retrieveConfig: () => Promise<lib.ConfigSchema>;
|
||||
setField: (field:string, value:any) => Promise<void>;
|
||||
setProp: (field:string, prop:string, value:any) => Promise<void>;
|
||||
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
|
||||
id?: number;
|
||||
onApply?: () => void;
|
||||
available?: boolean;
|
||||
@@ -157,32 +157,33 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
loading={applying}
|
||||
onClick={async () => {
|
||||
setApplying(true);
|
||||
for (let field of changedFields) {
|
||||
let value = form.getFieldValue(field);
|
||||
let request;
|
||||
const fields: Record<string, string | Record<string, string>> = {};
|
||||
for (const field of changedFields) {
|
||||
const value = form.getFieldValue(field);
|
||||
if (propsMap.has(field)) {
|
||||
let [fieldName, prop] = propsMap.get(field);
|
||||
request = props.setProp(fieldName, prop, value);
|
||||
const [fieldName, prop] = propsMap.get(field)!;
|
||||
const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>;
|
||||
fields[fieldName] = fieldValue;
|
||||
try {
|
||||
fieldValue[prop] = value ? JSON.parse(value) : undefined;
|
||||
} catch (err: any) {
|
||||
setErrorFields(errors => {
|
||||
const newErrors = new Map(errors);
|
||||
newErrors.set(fieldName, err.message);
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
request = props.setField(field, value === null ? "" : String(value));
|
||||
fields[field] = value === null ? "" : String(value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await request;
|
||||
setChangedFields(changed => {
|
||||
let newChanged = new Set(changed);
|
||||
newChanged.delete(field);
|
||||
return newChanged;
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
setErrorFields(errors => {
|
||||
let newErrors = new Map(errors);
|
||||
newErrors.set(field, err.message);
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await props.setConfig(fields);
|
||||
setChangedFields(new Set());
|
||||
} catch (err: any) {
|
||||
notifyErrorHandler(err.message);
|
||||
}
|
||||
|
||||
await updateConfig();
|
||||
@@ -254,7 +255,7 @@ function computeTreeData(
|
||||
};
|
||||
|
||||
let treeData = [];
|
||||
let propsMap = new Map();
|
||||
const propsMap = new Map<string, [fieldName: string, prop: string]>();
|
||||
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
|
||||
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
|
||||
const [groupName, field] = lib.splitOn(".", fieldName);
|
||||
@@ -312,17 +313,37 @@ function computeTreeData(
|
||||
{`"${prop}"`}
|
||||
{restart && restartTip}
|
||||
</>}
|
||||
rules={[{ validator: (_, fieldValue) => {
|
||||
dependencies={def.dependsOn}
|
||||
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
|
||||
if (!fieldValue.length) {
|
||||
return Promise.reject(new Error("Will be removed"));
|
||||
throw new Error("Will be removed");
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(fieldValue);
|
||||
fieldValue = JSON.parse(fieldValue);
|
||||
} catch (err) {
|
||||
return Promise.reject(new Error("Must be valid json"));
|
||||
throw new Error("Must be valid json");
|
||||
}
|
||||
return Promise.resolve();
|
||||
}}]}
|
||||
|
||||
if (!def.validator) { return; }
|
||||
config.stageProp(fieldName, prop, fieldValue);
|
||||
|
||||
for (const dependsOn of def.dependsOn ?? []) {
|
||||
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
||||
try {
|
||||
if (propsMap.has(dependsOn)) {
|
||||
const [name, otherProp] = propsMap.get(dependsOn)!;
|
||||
config.stageProp(name, otherProp, JSON.parse(getFieldValue(dependsOn)));
|
||||
} else if (dependsOnDef.type !== "object") {
|
||||
config.stage(dependsOn, getFieldValue(dependsOn));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
||||
}
|
||||
}
|
||||
|
||||
def.validator(fieldValue, config.staging);
|
||||
}})]}
|
||||
>
|
||||
<Input className="json-input" disabled={!canWrite} />
|
||||
</Form.Item>,
|
||||
@@ -390,7 +411,30 @@ function computeTreeData(
|
||||
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
|
||||
help={errorFields.get(fieldName)}
|
||||
tooltip={def.description}
|
||||
dependencies={def.dependsOn}
|
||||
valuePropName={def.type === "boolean" ? "checked" : "value"}
|
||||
rules={[({getFieldValue}) => ({
|
||||
validator: async (_, fieldValue) => {
|
||||
if (!def.validator) { return; }
|
||||
config.stage(fieldName, fieldValue);
|
||||
|
||||
for (const dependsOn of def.dependsOn ?? []) {
|
||||
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
|
||||
try {
|
||||
if (propsMap.has(dependsOn)) {
|
||||
const [name, prop] = propsMap.get(dependsOn)!;
|
||||
config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn)));
|
||||
} else if (dependsOnDef.type !== "object") {
|
||||
config.stage(dependsOn, getFieldValue(dependsOn));
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch field: ${dependsOn}`);
|
||||
}
|
||||
}
|
||||
|
||||
def.validator(fieldValue, config.staging);
|
||||
},
|
||||
})]}
|
||||
>
|
||||
{renderInput(control.inputComponents, def, !canWrite)}
|
||||
</Form.Item>;
|
||||
|
||||
@@ -13,30 +13,13 @@ export default function ControllerConfigTree() {
|
||||
return await control.send(new lib.ControllerConfigGetRequest());
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.send(new lib.ControllerConfigSetFieldRequest(
|
||||
field,
|
||||
String(value),
|
||||
));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.ControllerConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.ControllerConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
||||
|
||||
export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) {
|
||||
return <span
|
||||
className="factorio-icon"
|
||||
data-mod-pack={props.modPackId}
|
||||
data-type={props.prototype?.base_type}
|
||||
data-name={props.prototype?.name}
|
||||
data-unknown={props.prototype ? undefined : "true"}
|
||||
/>;
|
||||
}
|
||||
@@ -13,28 +13,14 @@ export default function HostConfigTree(props: { id: number, available: boolean }
|
||||
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.HostConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.HostConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
id={props.id}
|
||||
available={props.available}
|
||||
/>;
|
||||
|
||||
@@ -1,109 +1,56 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, Input, Space, Table, Tag, Typography } from "antd";
|
||||
|
||||
import {
|
||||
useItemMetadata,
|
||||
useRecipeMetadata,
|
||||
useSignalMetadata,
|
||||
useTechnologyMetadata,
|
||||
usePlanetMetadata,
|
||||
useQualityMetadata,
|
||||
useEntityMetadata,
|
||||
} from "../model/item_metadata";
|
||||
import { useExportManifest } from "../model/export_manifest";
|
||||
import { useExportPrototypeMetadata } from "../model/export_prototype_metadata";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import FactorioIcon from "./FactorioIcon";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useModPack } from "../model/mod_pack";
|
||||
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type IconEntry = {
|
||||
key: string;
|
||||
category: string;
|
||||
name: string;
|
||||
cssClass: string;
|
||||
size: number;
|
||||
path?: string;
|
||||
};
|
||||
function filterPrototypes(
|
||||
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
||||
filter: string
|
||||
) {
|
||||
const query = filter.trim().toLowerCase();
|
||||
return new Map(
|
||||
[...prototypes ?? []].map(([baseType, entries]) => [
|
||||
baseType,
|
||||
new Map([...entries.entries()].filter(([name, entry]) => (
|
||||
`${entry.base_type}.${name}`.includes(query)
|
||||
|| `${entry.type}.${name}`.includes(query)
|
||||
))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
"item", "recipe", "signal", "technology", "planet", "quality", "entity",
|
||||
] as const;
|
||||
|
||||
type Category = typeof CATEGORIES[number];
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
item: "Item",
|
||||
recipe: "Recipe",
|
||||
signal: "Signal",
|
||||
technology: "Technology",
|
||||
planet: "Planet",
|
||||
quality: "Quality",
|
||||
entity: "Entity",
|
||||
};
|
||||
|
||||
function useAllIconEntries() {
|
||||
const item = useItemMetadata();
|
||||
const recipe = useRecipeMetadata();
|
||||
const signal = useSignalMetadata();
|
||||
const technology = useTechnologyMetadata();
|
||||
const planet = usePlanetMetadata();
|
||||
const quality = useQualityMetadata();
|
||||
const entity = useEntityMetadata();
|
||||
|
||||
const byCategory: Record<Category, Map<string, { size: number; path?: string }>> = {
|
||||
item, recipe, signal, technology, planet, quality, entity,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const entries: IconEntry[] = [];
|
||||
|
||||
for (const category of CATEGORIES) {
|
||||
const names = [...byCategory[category].keys()].sort();
|
||||
for (const name of names) {
|
||||
const meta = byCategory[category].get(name)!;
|
||||
entries.push({
|
||||
key: `${category}:${name}`,
|
||||
category,
|
||||
name,
|
||||
cssClass: `${category}-${CSS.escape(name)}`,
|
||||
size: meta.size,
|
||||
path: meta.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [item, recipe, signal, technology, planet, quality, entity]);
|
||||
function countPrototypes(
|
||||
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
|
||||
) {
|
||||
return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0;
|
||||
}
|
||||
|
||||
export default function IconReferencePage() {
|
||||
let params = useParams();
|
||||
let modPackId = Number(params.id);
|
||||
|
||||
const [filter, setFilter] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const allEntries = useAllIconEntries();
|
||||
const [modPack] = useModPack(modPackId);
|
||||
const manifest = modPack?.exportManifest;
|
||||
const prototypes = useExportPrototypeMetadata(modPack);
|
||||
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
|
||||
const groups = useMemo(() => (
|
||||
[...filtered]
|
||||
.map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] }))
|
||||
.filter(group => group.entries.length)
|
||||
), [filtered]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return allEntries;
|
||||
}
|
||||
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
|
||||
);
|
||||
}, [allEntries, filter]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, IconEntry[]>();
|
||||
for (const cat of CATEGORIES) {
|
||||
map.set(cat, []);
|
||||
}
|
||||
for (const entry of filtered) {
|
||||
map.get(entry.category)!.push(entry);
|
||||
}
|
||||
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
|
||||
.filter(g => g.entries.length > 0);
|
||||
}, [filtered]);
|
||||
|
||||
const visibleCount = filtered.length;
|
||||
const totalCount = allEntries.length;
|
||||
const visibleCount = countPrototypes(filtered);
|
||||
const totalCount = countPrototypes(prototypes);
|
||||
const autoExpand = visibleCount <= 20;
|
||||
|
||||
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
|
||||
@@ -123,11 +70,10 @@ export default function IconReferencePage() {
|
||||
const columns = [
|
||||
{
|
||||
title: "Icon",
|
||||
dataIndex: "cssClass",
|
||||
key: "icon",
|
||||
width: 48,
|
||||
render: (_: string, record: IconEntry) => (
|
||||
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
|
||||
render: (prototype: PrototypeMetadataEntry) => (
|
||||
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -138,19 +84,9 @@ export default function IconReferencePage() {
|
||||
<Text style={{ fontSize: 12 }}>{name}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Usage",
|
||||
dataIndex: "cssClass",
|
||||
key: "usage",
|
||||
render: (cssClass: string) => (
|
||||
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
|
||||
{`<div class="${cssClass}" />`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Path",
|
||||
dataIndex: "path",
|
||||
dataIndex: ["icon", "path"],
|
||||
key: "path",
|
||||
render: (p: string | undefined) => (p
|
||||
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
|
||||
@@ -158,13 +94,17 @@ export default function IconReferencePage() {
|
||||
},
|
||||
];
|
||||
|
||||
const manifest = useExportManifest();
|
||||
|
||||
let nav = [
|
||||
{ name: "Mods", path: "/mods" },
|
||||
{ name: "Mod Packs" },
|
||||
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
|
||||
{ name: "Icon Reference" },
|
||||
];
|
||||
return (
|
||||
<PageLayout nav={[{ name: "Icon Reference" }]}>
|
||||
<PageLayout nav={nav}>
|
||||
<PageHeader title="Icon Reference" />
|
||||
<div style={{ width: "80%", margin: "0 auto" }}>
|
||||
{!manifest && (
|
||||
{!modPack?.exportManifest && (
|
||||
<Card>
|
||||
<Text type="secondary">
|
||||
No export data available. Run export-data on an instance to generate icons.
|
||||
@@ -180,7 +120,7 @@ export default function IconReferencePage() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Text type="secondary">Icons: </Text>
|
||||
<Text type="secondary">Prototypes: </Text>
|
||||
<Text>{totalCount}</Text>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +128,7 @@ export default function IconReferencePage() {
|
||||
<Card>
|
||||
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
|
||||
<Input
|
||||
placeholder="Filter by name or class…"
|
||||
placeholder="Filter by type or name…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
allowClear
|
||||
@@ -200,13 +140,12 @@ export default function IconReferencePage() {
|
||||
: `${visibleCount} / ${totalCount} icons`}
|
||||
</Text>
|
||||
</Space>
|
||||
{groups.map(({ category, entries }) => {
|
||||
const label = CATEGORY_LABELS[category as Category] ?? category;
|
||||
const expanded = isExpanded(category);
|
||||
{groups.map(({ baseType, entries }) => {
|
||||
const expanded = isExpanded(baseType);
|
||||
return (
|
||||
<div key={category} style={{ marginBottom: 8 }}>
|
||||
<div key={baseType} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
onClick={() => toggleGroup(category)}
|
||||
onClick={() => toggleGroup(baseType)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -222,13 +161,13 @@ export default function IconReferencePage() {
|
||||
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
|
||||
{expanded ? "▾" : "►"}
|
||||
</span>
|
||||
<Tag style={{ margin: 0 }}>{label}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{baseType}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{entries.length}
|
||||
</Text>
|
||||
</div>
|
||||
{expanded && (
|
||||
<Table<IconEntry>
|
||||
<Table<PrototypeMetadataEntry>
|
||||
columns={columns}
|
||||
dataSource={entries}
|
||||
rowKey="key"
|
||||
|
||||
@@ -17,28 +17,14 @@ export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
|
||||
return await control.send(new lib.InstanceConfigGetRequest(props.id));
|
||||
}
|
||||
|
||||
async function setField(field: string, value: any) {
|
||||
await control.send(new lib.InstanceConfigSetFieldRequest(props.id, field, value));
|
||||
}
|
||||
|
||||
async function setProp(field: string, prop: string, value: any) {
|
||||
if (value) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
await control.send(new lib.InstanceConfigSetPropRequest(props.id, field, prop, value));
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.InstanceConfigSetRequest(props.id, fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.InstanceConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
id={props.id}
|
||||
/>;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
import Link from "./Link";
|
||||
|
||||
const { logger } = lib;
|
||||
const { Text } = Typography;
|
||||
@@ -1334,6 +1335,12 @@ export default function ModPackViewPage() {
|
||||
prototypes={prototypes}
|
||||
locale={locale}
|
||||
/>
|
||||
<Typography.Title level={4}>
|
||||
Inspect exported data
|
||||
</Typography.Title>
|
||||
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
|
||||
Icon reference
|
||||
</Link>
|
||||
</Form>
|
||||
|
||||
<div
|
||||
|
||||
@@ -4,11 +4,21 @@ export { default as notify, notifyErrorHandler } from "./util/notify";
|
||||
export { default as BaseWebPlugin } from "./BaseWebPlugin";
|
||||
export * from "./util/websocket";
|
||||
|
||||
export * from "./model/instance";
|
||||
export * from "./model/host";
|
||||
export * from "./model/locale";
|
||||
export * from "./model/item_metadata";
|
||||
export * from "./model/account";
|
||||
export * from "./model/export_locale";
|
||||
export * from "./model/export_prototype_metadata";
|
||||
export * from "./model/host";
|
||||
export * from "./model/instance";
|
||||
export * from "./model/is_dragging";
|
||||
export * from "./model/mod_pack";
|
||||
export * from "./model/mods";
|
||||
export * from "./model/roles";
|
||||
export * from "./model/saves";
|
||||
export * from "./model/system";
|
||||
export * from "./model/user";
|
||||
|
||||
export * from "./store/export_locale_stores";
|
||||
export * from "./store/export_prototype_metadata_store";
|
||||
|
||||
export { default as ControlContext } from "./components/ControlContext";
|
||||
|
||||
@@ -30,3 +40,5 @@ export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
export { default as FactorioIcon } from "./components/FactorioIcon";
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ModPack } from "@clusterio/lib";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import { exportLocaleStore } from "../store/export_locale_stores";
|
||||
|
||||
|
||||
export function useExportLocale(modPack: ModPack | undefined) {
|
||||
const localePath = modPack?.exportManifest?.assets["locale"];
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback),
|
||||
[modPack?.id, localePath],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id));
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect, useContext, useState } from "react";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
let exportManifestCache: lib.ExportManifest|null = null;
|
||||
let loaded = false;
|
||||
export function useExportManifest(): lib.ExportManifest|null {
|
||||
const control = useContext(ControlContext);
|
||||
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache);
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
|
||||
if (modPack.exportManifest) {
|
||||
exportManifestCache = modPack.exportManifest;
|
||||
setExportManifest(exportManifestCache);
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return exportManifest;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import { ModPack } from "@clusterio/lib";
|
||||
import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store";
|
||||
|
||||
|
||||
/**
|
||||
* Load the unified spritesheet metadata and inject CSS rules for every icon.
|
||||
* Required for {@link FactorioIcon}.
|
||||
*/
|
||||
export function useExportPrototypeMetadata(modPack: ModPack | undefined) {
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback),
|
||||
[modPack]
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id));
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useExportManifest } from "./export_manifest";
|
||||
|
||||
|
||||
type Metadata = {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
category: string;
|
||||
localised_name?: any[];
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const metadataCaches = new Map<string, Map<string, Metadata>>();
|
||||
let cssInjected = false;
|
||||
|
||||
/**
|
||||
* Load the unified spritesheet metadata and inject CSS rules for every icon.
|
||||
* CSS class name: `.{category}-{CSS.escape(name)}`
|
||||
* e.g. `.item-iron-plate`, `.recipe-iron-plate`, `.signal-signal-red`, `.planet-nauvis`
|
||||
*/
|
||||
function useSpriteMetadata(category: string): Map<string, Metadata> {
|
||||
const exportManifest = useExportManifest();
|
||||
const [metadata, setMetadata] = useState<Map<string, Metadata>>(
|
||||
metadataCaches.get(category) ?? new Map()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (
|
||||
!exportManifest
|
||||
|| !exportManifest.assets["metadata"]
|
||||
|| !exportManifest.assets["spritesheet"]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${staticRoot}static/${exportManifest.assets["metadata"]}`);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: [string, Metadata][] = await response.json();
|
||||
|
||||
if (!cssInjected) {
|
||||
const sheetUrl = `${staticRoot}static/${exportManifest.assets["spritesheet"]}`;
|
||||
const style = document.createElement("style");
|
||||
document.head.appendChild(style);
|
||||
for (const [key, meta] of data) {
|
||||
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
|
||||
style.sheet!.insertRule(`\
|
||||
.${meta.category}-${CSS.escape(name)} {
|
||||
background-image: url("${sheetUrl}");
|
||||
background-repeat: no-repeat;
|
||||
background-position: -${meta.x}px -${meta.y}px;
|
||||
height: ${meta.size}px;
|
||||
width: ${meta.size}px;
|
||||
}`
|
||||
);
|
||||
}
|
||||
cssInjected = true;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Metadata>();
|
||||
for (const [key, meta] of data) {
|
||||
if (meta.category === category) {
|
||||
const name = key.includes(".") ? key.slice(key.indexOf(".") + 1) : key;
|
||||
cache.set(name, meta);
|
||||
}
|
||||
}
|
||||
metadataCaches.set(category, cache);
|
||||
setMetadata(cache);
|
||||
}
|
||||
|
||||
if (!metadataCaches.has(category)) {
|
||||
load();
|
||||
}
|
||||
}, [exportManifest]);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function useItemMetadata() { return useSpriteMetadata("item"); }
|
||||
export function useRecipeMetadata() { return useSpriteMetadata("recipe"); }
|
||||
export function useSignalMetadata() { return useSpriteMetadata("signal"); }
|
||||
export function useTechnologyMetadata() { return useSpriteMetadata("technology"); }
|
||||
export function usePlanetMetadata() { return useSpriteMetadata("planet"); }
|
||||
export function useQualityMetadata() { return useSpriteMetadata("quality"); }
|
||||
export function useEntityMetadata() { return useSpriteMetadata("entity"); }
|
||||
@@ -1,29 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useExportManifest } from "./export_manifest";
|
||||
|
||||
|
||||
let localeCache: Map<string, string>|null = null;
|
||||
export function useLocale(): Map<string, string> {
|
||||
let exportManifest = useExportManifest();
|
||||
let [locale, setLocale] = useState<Map<string, string>>(localeCache || new Map());
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
if (!exportManifest?.assets["locale"]) {
|
||||
return;
|
||||
}
|
||||
let response = await fetch(`${staticRoot}static/${exportManifest.assets["locale"]}`);
|
||||
if (response.ok) {
|
||||
let data = await response.json();
|
||||
localeCache = new Map(data);
|
||||
setLocale(localeCache);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localeCache) {
|
||||
load();
|
||||
}
|
||||
}, [exportManifest]);
|
||||
|
||||
return locale;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
import { defaultModPackStore } from "../store/default_mod_pack_store";
|
||||
|
||||
|
||||
export function useModPack(id?: number) {
|
||||
const [modPacks, synced] = useModPacks();
|
||||
@@ -11,3 +13,11 @@ export function useModPacks() {
|
||||
const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot());
|
||||
}
|
||||
|
||||
export function useDefaultModPack() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => defaultModPackStore.subscribe(control, callback), [control],
|
||||
);
|
||||
return useSyncExternalStore(subscribe, () => defaultModPackStore.getSnapshot());
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import PluginViewPage from "./components/PluginViewPage";
|
||||
import ErrorPage from "./components/ErrorPage";
|
||||
import IconReferencePage from "./components/IconReferencePage";
|
||||
|
||||
// When adding or editing page paths here the corresponding webRoutes entry
|
||||
// in packages/controller/src/routes.ts also needs to be updated.
|
||||
export const pages: PluginPage[] = [
|
||||
{
|
||||
path: "/controller",
|
||||
@@ -57,6 +59,11 @@ export const pages: PluginPage[] = [
|
||||
sidebarPath: "/mods",
|
||||
content: <ModPackViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/mods/mod-packs/:id/icon-reference",
|
||||
sidebarPath: "/mods",
|
||||
content: <IconReferencePage />,
|
||||
},
|
||||
{
|
||||
path: "/users",
|
||||
sidebarName: "Users",
|
||||
@@ -89,10 +96,6 @@ export const pages: PluginPage[] = [
|
||||
sidebarPath: "/plugins",
|
||||
content: <PluginViewPage />,
|
||||
},
|
||||
{
|
||||
path: "/icon-reference",
|
||||
content: <IconReferencePage />,
|
||||
},
|
||||
{
|
||||
path: "/error",
|
||||
content: <ErrorPage throw error={new Error("Example Error Message")} />,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ModPackGetDefaultRequest, type ModPack, isDeepStrictEqual } from "@clusterio/lib";
|
||||
import type { Control } from "../util/websocket";
|
||||
|
||||
// TODO: replace this super duper expensive method with listening to a default mod pack id event.
|
||||
class DefaultModPackStore {
|
||||
interval: undefined | ReturnType<typeof setInterval>;
|
||||
modPack: undefined | ModPack;
|
||||
callbacks: (() => void)[] = [];
|
||||
|
||||
fetchDefaultModPack(control: Control) {
|
||||
control.send(new ModPackGetDefaultRequest()).then(modPack => {
|
||||
if (!isDeepStrictEqual(this.modPack, modPack)) {
|
||||
this.modPack = modPack;
|
||||
for (const callback of this.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (this.modPack !== undefined) {
|
||||
this.modPack = undefined;
|
||||
for (const callback of this.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(control: Control, callback: () => void) {
|
||||
if (this.callbacks.length === 0) {
|
||||
const update = () => { this.fetchDefaultModPack(control); };
|
||||
update();
|
||||
this.interval = setInterval(update, 60e3);
|
||||
}
|
||||
this.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.callbacks.splice(index, 1);
|
||||
}
|
||||
if (this.callbacks.length === 0) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.modPack;
|
||||
}
|
||||
};
|
||||
export const defaultModPackStore = new DefaultModPackStore();
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ExportLocale, logger } from "@clusterio/lib";
|
||||
import notify from "../util/notify";
|
||||
|
||||
|
||||
interface ExportLocaleCacheEntry {
|
||||
callbacks: (() => void)[];
|
||||
localePath: string | undefined;
|
||||
data: Map<string, string>;
|
||||
}
|
||||
|
||||
class ExportLocaleStore {
|
||||
cache = new Map<number, ExportLocaleCacheEntry>();
|
||||
_emptyMap = new Map();
|
||||
|
||||
async loadLocale(modPackId: number, cacheEntry: ExportLocaleCacheEntry) {
|
||||
if (!cacheEntry.localePath) {
|
||||
cacheEntry.data = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`);
|
||||
if (response.ok) {
|
||||
cacheEntry.data = new Map(await response.json() as ExportLocale);
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
} else {
|
||||
notify(
|
||||
`Failed to locale for mod pack ${modPackId}, server returned: ` +
|
||||
`${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(modPackId: number | undefined, localePath: string | undefined, callback: () => void) {
|
||||
if (modPackId === undefined || !localePath) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let cacheEntry = this.cache.get(modPackId);
|
||||
if (!cacheEntry) {
|
||||
cacheEntry = {
|
||||
callbacks: [],
|
||||
localePath: undefined,
|
||||
data: new Map(),
|
||||
};
|
||||
this.cache.set(modPackId, cacheEntry);
|
||||
}
|
||||
if (cacheEntry.localePath !== localePath) {
|
||||
cacheEntry.localePath = localePath;
|
||||
this.loadLocale(modPackId, cacheEntry).catch(logger.error);
|
||||
}
|
||||
cacheEntry.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = cacheEntry.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
cacheEntry.callbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(modPackId: number | undefined): ReadonlyMap<string, string> {
|
||||
if (modPackId === undefined) {
|
||||
return this._emptyMap;
|
||||
}
|
||||
return this.cache.get(modPackId)?.data ?? this._emptyMap;
|
||||
}
|
||||
}
|
||||
export const exportLocaleStore = new ExportLocaleStore();
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ExportMetadata, ExportMetadataEntry, logger, ModPack } from "@clusterio/lib";
|
||||
import unknownIconUrl from "../images/unknown-item.png";
|
||||
import notify from "../util/notify";
|
||||
|
||||
|
||||
export type PrototypeMetadataEntry = ExportMetadataEntry & { base_type: string };
|
||||
|
||||
interface MetadataCacheEntry {
|
||||
callbacks: (() => void)[],
|
||||
metadataPath: string | undefined,
|
||||
spritesheetPath: string | undefined,
|
||||
metadata: Map<string, Map<string, PrototypeMetadataEntry>>,
|
||||
styleSheet: CSSStyleSheet,
|
||||
}
|
||||
|
||||
class ExportPrototypeMetadataStore {
|
||||
cache = new Map<number, MetadataCacheEntry>();
|
||||
async loadMetadata(modPackId: number, cacheEntry: MetadataCacheEntry) {
|
||||
if (!cacheEntry.metadataPath) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync("");
|
||||
cacheEntry.metadata = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`);
|
||||
if (!response.ok) {
|
||||
notify(
|
||||
`Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` +
|
||||
`${response.status} ${response.statusText}`
|
||||
);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync("");
|
||||
cacheEntry.metadata = new Map();
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = await response.json() as ExportMetadata;
|
||||
|
||||
const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`;
|
||||
const styleContent: string[] = [];
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"] {
|
||||
background-image: url("${spriteUrl}");
|
||||
}`);
|
||||
for (const [baseName, prototypes] of Object.entries(metadata)) {
|
||||
for (const prototype of prototypes) {
|
||||
const icon = prototype.icon;
|
||||
if (!icon) {
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"]\
|
||||
[data-type="${baseName}"]\
|
||||
[data-name="${CSS.escape(prototype.name)}"] {
|
||||
background-image: url("${unknownIconUrl}");
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}`);
|
||||
continue;
|
||||
}
|
||||
styleContent.push(`\
|
||||
.factorio-icon\
|
||||
[data-mod-pack="${modPackId}"]\
|
||||
[data-type="${baseName}"]\
|
||||
[data-name="${CSS.escape(prototype.name)}"] {
|
||||
background-position: -${icon.x}px -${icon.y}px;
|
||||
height: ${icon.size}px;
|
||||
width: ${icon.size}px;
|
||||
}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line node/no-sync
|
||||
cacheEntry.styleSheet.replaceSync(styleContent.join("\n"));
|
||||
cacheEntry.metadata = new Map(
|
||||
Object.entries(metadata)
|
||||
.map(([baseName, entries]) => [
|
||||
baseName,
|
||||
new Map(
|
||||
entries.map(entry => [entry.name, { base_type: baseName, ...entry }])
|
||||
),
|
||||
])
|
||||
);
|
||||
for (const callback of cacheEntry.callbacks) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(modPack: ModPack | undefined, callback: () => void) {
|
||||
if (!modPack) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const assets = modPack.exportManifest?.assets;
|
||||
let cacheEntry = this.cache.get(modPack.id);
|
||||
if (!cacheEntry) {
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
document.adoptedStyleSheets.push(styleSheet);
|
||||
cacheEntry = {
|
||||
callbacks: [],
|
||||
spritesheetPath: undefined,
|
||||
metadataPath: undefined,
|
||||
metadata: new Map(),
|
||||
styleSheet,
|
||||
};
|
||||
this.cache.set(modPack.id, cacheEntry);
|
||||
}
|
||||
if (
|
||||
cacheEntry.metadataPath !== assets?.metadata
|
||||
|| cacheEntry.spritesheetPath !== assets?.spritesheet
|
||||
) {
|
||||
cacheEntry.metadataPath = assets?.metadata;
|
||||
cacheEntry.spritesheetPath = assets?.spritesheet;
|
||||
this.loadMetadata(modPack.id, cacheEntry).catch(logger.error);
|
||||
}
|
||||
cacheEntry.callbacks.push(callback);
|
||||
return () => {
|
||||
const index = cacheEntry.callbacks.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
cacheEntry.callbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(modPackId: number | undefined) {
|
||||
if (modPackId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return this.cache.get(modPackId)?.metadata;
|
||||
}
|
||||
}
|
||||
export const exportPrototypeMetadataStore = new ExportPrototypeMetadataStore();
|
||||
@@ -320,7 +320,14 @@ body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.factorio-icon.item-unknown-item {
|
||||
.factorio-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* data-mod-pack might not be present, but we need the specificity to win over it if it is */
|
||||
.factorio-icon:is([data-mod-pack],*)[data-unknown="true"] {
|
||||
background: url("./images/unknown-item.png");
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
@@ -38,13 +38,13 @@ module.exports = (env = {}, argv = {}) => ({
|
||||
resource.request = resource.request.replace("@ant-design\/icons", "$&/es/icons");
|
||||
}
|
||||
),
|
||||
// Strip node: prefix in request paths.
|
||||
new webpack.NormalModuleReplacementPlugin(/^node:/, resource => {
|
||||
resource.request = resource.request.replace(/^node:/, "");
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /node_modules.fs-extra/,
|
||||
use: require.resolve("null-loader"),
|
||||
},
|
||||
{
|
||||
test: /node_modules.winston.dist.winston.transports.(http|file)/,
|
||||
use: require.resolve("null-loader"),
|
||||
@@ -120,6 +120,8 @@ module.exports = (env = {}, argv = {}) => ({
|
||||
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
|
||||
fallback: {
|
||||
"crypto": false,
|
||||
"fs": false,
|
||||
"fs/promises": false,
|
||||
|
||||
// Required for winston
|
||||
"util": require.resolve("util/"),
|
||||
|
||||
@@ -9,6 +9,10 @@ export const plugin: lib.PluginDeclaration = {
|
||||
instanceEntrypoint: "dist/node/instance",
|
||||
ctlEntrypoint: "dist/node/control",
|
||||
|
||||
features: [
|
||||
"ScriptCommands",
|
||||
],
|
||||
|
||||
messages: [
|
||||
ChatEvent,
|
||||
],
|
||||
|
||||
@@ -16,10 +16,6 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
messageQueue: string[] = [];
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("global_chat plugin requires script commands.");
|
||||
}
|
||||
|
||||
this.instance.handle(ChatEvent, this.handleChatEvent.bind(this));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller";
|
||||
import type { IpcPlayerData } from "./messages";
|
||||
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as msg from "./messages";
|
||||
|
||||
@@ -45,6 +45,11 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
"ScriptCommands",
|
||||
],
|
||||
|
||||
messages: [
|
||||
messages.AcquireRequest,
|
||||
messages.ReleaseRequest,
|
||||
|
||||
@@ -31,12 +31,6 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
disconnecting!: boolean;
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("inventory_sync plugin requires save patching.");
|
||||
}
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("inventory_sync plugin requires script commands.");
|
||||
}
|
||||
this.playersToRelease = new Set();
|
||||
this.disconnecting = false;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-inventory_sync",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Danielv <danielv@danielv.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
@@ -24,20 +24,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"fs-extra": "catalog:"
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"antd": "catalog:",
|
||||
|
||||
@@ -90,7 +90,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
for (let entry of this.players.values()) {
|
||||
if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) {
|
||||
let verifyCode = await generateCode(this.controller.config.get("player_auth.code_length"));
|
||||
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
|
||||
let verifyToken = jwt.sign(
|
||||
{
|
||||
aud: "player_auth.verify_code",
|
||||
@@ -98,7 +97,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
verify_code: verifyCode,
|
||||
player_code: playerCode,
|
||||
},
|
||||
secret
|
||||
this.controller.authSecret
|
||||
);
|
||||
|
||||
res.send({ verify_code: verifyCode, verify_token: verifyToken });
|
||||
@@ -133,9 +132,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
let secret = Buffer.from(this.controller.config.get("controller.auth_secret"), "base64");
|
||||
try {
|
||||
let payload = jwt.verify(verifyToken, secret, { audience: "player_auth.verify_code" }) as jwt.JwtPayload;
|
||||
let payload = jwt.verify(
|
||||
verifyToken, this.controller.authSecret, { audience: "player_auth.verify_code" }
|
||||
) as jwt.JwtPayload;
|
||||
|
||||
if (payload.verify_code !== verifyCode) {
|
||||
throw new Error("invalid verify_code");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export const plugin: lib.PluginDeclaration = {
|
||||
},
|
||||
},
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
],
|
||||
|
||||
messages: [
|
||||
messages.FetchPlayerCodeRequest,
|
||||
messages.SetVerifyCodeRequest,
|
||||
|
||||
@@ -13,10 +13,6 @@ type IpcPlayerAuth = {
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("player_auth plugin requires save patching.");
|
||||
}
|
||||
|
||||
this.instance.server.on(
|
||||
"ipc-player_auth",
|
||||
(ipcPlayerAuth: IpcPlayerAuth) => this.handleEvent(ipcPlayerAuth).catch(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-player_auth",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "Clusterio plugin authenticating logged in players to the web interface",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
@@ -9,6 +9,11 @@ export const plugin: lib.PluginDeclaration = {
|
||||
instanceEntrypoint: "dist/node/instance",
|
||||
controllerEntrypoint: "dist/node/controller",
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
"ScriptCommands",
|
||||
],
|
||||
|
||||
messages: [
|
||||
messages.ContributionEvent,
|
||||
messages.ProgressEvent,
|
||||
|
||||
@@ -27,13 +27,6 @@ export class InstancePlugin extends BaseInstancePlugin {
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("research_sync plugin requires save patching.");
|
||||
}
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("research_sync plugin requires script commands.");
|
||||
}
|
||||
|
||||
this.instance.server.on("ipc-research_sync:contribution", (tech: IpcContribution) => {
|
||||
this.researchContribution(tech).catch(err => this.unexpectedError(err));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
@@ -24,20 +24,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*"
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"fs-extra": "catalog:"
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -22,4 +22,9 @@ export const plugin: lib.PluginDeclaration = {
|
||||
initialValue: 1,
|
||||
},
|
||||
},
|
||||
|
||||
features: [
|
||||
"SavePatching",
|
||||
"ScriptCommands",
|
||||
],
|
||||
};
|
||||
@@ -104,15 +104,6 @@ function setForceFlowStatistic(
|
||||
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("statistics_exporter plugin requires save patching.");
|
||||
}
|
||||
if (!this.instance.config.get("factorio.enable_script_commands")) {
|
||||
throw new Error("statistics_exporter plugin requires script commands.");
|
||||
}
|
||||
}
|
||||
|
||||
async gatherMetrics() {
|
||||
let string = await this.sendRcon("/sc statistics_exporter.export()");
|
||||
let stats: IpcStats;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user