mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cd830c01 | ||
|
|
ddc03eaf30 | ||
|
|
b5d8cde93f | ||
|
|
4e8a833d62 | ||
|
|
3add08b100 | ||
|
|
f2b28968a0 | ||
|
|
599cf3ed22 | ||
|
|
ff28357d7a | ||
|
|
8b4c8ad337 | ||
|
|
07b030d045 | ||
|
|
7f799c8490 | ||
|
|
91a5108b85 | ||
|
|
90c8188952 | ||
|
|
198ea76674 | ||
|
|
5758cd94b6 | ||
|
|
eab1a42e07 | ||
|
|
fdfd77df9a | ||
|
|
58f2ad9810 | ||
|
|
3cc99a1ea6 | ||
|
|
15781bb038 | ||
|
|
9ec12ac7d7 | ||
|
|
2559c0478c | ||
|
|
07fcdb0d4e | ||
|
|
40407e52d6 | ||
|
|
2898fdf5d3 | ||
|
|
bb4c82ea5a | ||
|
|
1f2730a64f | ||
|
|
43238feada | ||
|
|
573274875c | ||
|
|
2e27c4f499 | ||
|
|
8029ad3b2d | ||
|
|
eca5cdcd9e | ||
|
|
523293d4d9 | ||
|
|
5d20f8fc84 | ||
|
|
e27ad5fdf9 | ||
|
|
5b1f7d796d | ||
|
|
0874c26208 | ||
|
|
1e7b8f08cf | ||
|
|
7c2793752d | ||
|
|
5cde390c24 | ||
|
|
46ebb13fda | ||
|
|
4775a5fd06 | ||
|
|
7e89e7fb6f | ||
|
|
95ea073b77 | ||
|
|
e135d37878 | ||
|
|
2dedb44372 | ||
|
|
97a6c32838 | ||
|
|
a56fcbd785 | ||
|
|
e16603bbd0 | ||
|
|
dfe132699e | ||
|
|
f793ec33b3 | ||
|
|
ce8bddad8f | ||
|
|
3fe0c85055 | ||
|
|
9da48d5387 | ||
|
|
2bd9bc6138 | ||
|
|
a274fe74e1 | ||
|
|
ea5c35198f | ||
|
|
0f6f3bbb8a | ||
|
|
448eb9223d | ||
|
|
cc0be69019 | ||
|
|
9c627f4c16 | ||
|
|
118154a43a | ||
|
|
b29f4db52c | ||
|
|
5efa257a36 | ||
|
|
09ec6d6ec5 | ||
|
|
8cacdc5112 | ||
|
|
d4394f1190 | ||
|
|
6feb9c2762 | ||
|
|
79c6bd1ac4 | ||
|
|
4e94092730 | ||
|
|
82629a57c6 | ||
|
|
92f607a864 | ||
|
|
245483cb35 | ||
|
|
f04fc68513 | ||
|
|
49eb594e5b | ||
|
|
ed84a476c6 | ||
|
|
eb77ca566c | ||
|
|
262357acef | ||
|
|
9e727f9a13 | ||
|
|
d35e8caa2b | ||
|
|
b9c073e26f | ||
|
|
b160fecac4 | ||
|
|
dab727f90f | ||
|
|
0e36df8ae3 | ||
|
|
f7d3516c24 | ||
|
|
e6665d0803 | ||
|
|
6fd4654c21 | ||
|
|
b5c99fad2a | ||
|
|
f588f7f8d4 | ||
|
|
4fe83cbcbc | ||
|
|
069a050f39 | ||
|
|
577615803b | ||
|
|
680ade5975 | ||
|
|
6914645c19 | ||
|
|
1d0b6b9a62 | ||
|
|
0482f4ae08 | ||
|
|
16ce748ffa | ||
|
|
2c199028ea | ||
|
|
22b16a7ac2 | ||
|
|
f2ccde0dd6 | ||
|
|
dd80d49989 | ||
|
|
e3d5f434a4 | ||
|
|
11b8a0ff97 | ||
|
|
e54b292fce | ||
|
|
41222343e3 | ||
|
|
4676f1c820 | ||
|
|
e49b82842d | ||
|
|
e0a39e5630 | ||
|
|
21b050b220 | ||
|
|
9bed1223fd | ||
|
|
4735f0725d | ||
|
|
1f27617a27 | ||
|
|
ee460d677d | ||
|
|
f2ca465ec6 | ||
|
|
40e6961711 | ||
|
|
1bb51988ac | ||
|
|
2c30fa4a94 | ||
|
|
a22186ca01 | ||
|
|
746b11ff65 | ||
|
|
de51741835 | ||
|
|
a633c42904 | ||
|
|
7522424517 | ||
|
|
57f15d7ba4 | ||
|
|
1af672aa1d | ||
|
|
41f8f493aa | ||
|
|
8be95c140a | ||
|
|
add0088704 | ||
|
|
037f06808e | ||
|
|
01456818a8 | ||
|
|
5c3182fb56 | ||
|
|
f515b2db80 | ||
|
|
eb2c2b5aec | ||
|
|
6f181f357b | ||
|
|
1bcc152368 | ||
|
|
26df9d2caa | ||
|
|
45adb1e88c | ||
|
|
6f5b8ce68d | ||
|
|
16a249d5bf | ||
|
|
a52e4320ec | ||
|
|
e8cabb7196 | ||
|
|
7e82ad89ff | ||
|
|
2219b58000 | ||
|
|
3667b4733a | ||
|
|
490e9c0e5f | ||
|
|
7e87201c24 | ||
|
|
e9653c13eb | ||
|
|
255cc4c325 | ||
|
|
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 | ||
|
|
bbd3cc7fdb | ||
|
|
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
|
||||
- ...
|
||||
```
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [20.x, 22.x]
|
||||
node-version: [22.x, 24.x]
|
||||
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.47]
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
- run: pnpm i --no-frozen-lockfile
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
types:
|
||||
- released
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,16 +17,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
@@ -33,11 +33,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
|
||||
+163
-9
@@ -31,6 +31,160 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.27
|
||||
|
||||
### Features
|
||||
|
||||
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
|
||||
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
|
||||
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
|
||||
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
|
||||
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
|
||||
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
|
||||
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
|
||||
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
|
||||
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
|
||||
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>)
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.26
|
||||
|
||||
### Features
|
||||
|
||||
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
|
||||
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
|
||||
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
|
||||
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
|
||||
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
|
||||
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
|
||||
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.25
|
||||
|
||||
### Features
|
||||
|
||||
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
|
||||
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
|
||||
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
|
||||
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
|
||||
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
|
||||
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
[@Daniel](<https://github.com/Daniel>)
|
||||
[@Hornwitser](<https://github.com/Hornwitser>)
|
||||
|
||||
## 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 +197,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
|
||||
|
||||
|
||||
+7
-7
@@ -26,7 +26,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -35,22 +35,22 @@
|
||||
"external_plugins/*/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:*",
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/host": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/ctl": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@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,10 +15,9 @@
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
import yargs from "yargs";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
@@ -182,7 +181,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") {
|
||||
@@ -445,33 +445,9 @@ async function startup() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
controller.stop();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
@@ -17,45 +17,41 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.0",
|
||||
"express": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"finalhandler": "^1.3.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.0",
|
||||
"finalhandler": "^1.3.1",
|
||||
"ws": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:*",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@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/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";
|
||||
@@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link {
|
||||
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
|
||||
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
|
||||
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
@@ -79,7 +80,7 @@ export default class BaseConnection extends lib.Link {
|
||||
return modPack;
|
||||
}
|
||||
|
||||
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
|
||||
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
@@ -94,10 +95,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);
|
||||
|
||||
@@ -111,4 +114,8 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -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,43 @@ 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();
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} 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 +346,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);
|
||||
}
|
||||
@@ -626,6 +675,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
|
||||
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
|
||||
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const optionalRequirements = new Map<string, lib.ModVersionRange>();
|
||||
const candidateReleases = new Map<string, lib.ModInfo>();
|
||||
const errors = new Map<string, lib.ModDependencyResolveErrors>();
|
||||
@@ -658,7 +708,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const release = releases.releases
|
||||
.filter(info => versionRange.testVersion(info.version))
|
||||
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
|
||||
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
|
||||
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
|
||||
), undefined);
|
||||
|
||||
if (!release) {
|
||||
@@ -690,16 +740,32 @@ export default class ControlConnection extends BaseConnection {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the current version range (optionals are tracked in case they become required)
|
||||
let versionRange = dependencyRequirements.get(mod.name);
|
||||
if (!versionRange) {
|
||||
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
|
||||
if (mod.required) {
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
// Get the current version range (dependencies can be promoted to a stricter requirement)
|
||||
const versionRange =
|
||||
dependencyRequirements.get(mod.name)
|
||||
?? recommendedRequirements.get(mod.name)
|
||||
?? optionalRequirements.get(mod.name)
|
||||
?? new lib.ModVersionRange();
|
||||
|
||||
if (mod.required) {
|
||||
// Add to required, remove from recommended and optional
|
||||
dependencyRequirements.set(mod.name, versionRange);
|
||||
recommendedRequirements.delete(mod.name);
|
||||
optionalRequirements.delete(mod.name);
|
||||
|
||||
} else if (mod.recommended) {
|
||||
// If not required, add to recommended and remove from optional
|
||||
if (!dependencyRequirements.has(mod.name)) {
|
||||
recommendedRequirements.set(mod.name, versionRange);
|
||||
optionalRequirements.delete(mod.name);
|
||||
} else {
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
} else if (
|
||||
!dependencyRequirements.has(mod.name)
|
||||
&& !recommendedRequirements.has(mod.name)
|
||||
) {
|
||||
// If not required or recommended, add to optional
|
||||
optionalRequirements.set(mod.name, versionRange);
|
||||
}
|
||||
|
||||
// Update the version range if needed
|
||||
@@ -708,6 +774,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (!versionRange.valid) {
|
||||
candidateReleases.delete(mod.name);
|
||||
errors.set(mod.name, "unsatisfiable");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,9 +822,9 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove incompatible dependencies if they aren't required by others
|
||||
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
|
||||
for (const [modName, error] of errors) {
|
||||
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
|
||||
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
|
||||
errors.delete(modName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -121,9 +121,31 @@ export default class Controller {
|
||||
}
|
||||
});
|
||||
|
||||
// Cache for the latest stable/experimental factorio releases. Persisted to
|
||||
// disk so that release channel targets can still be resolved when
|
||||
// factorio.com is unavailable.
|
||||
latestReleases = new lib.ValueCache(async () => {
|
||||
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
|
||||
try {
|
||||
const releases = await lib.fetchLatestReleases();
|
||||
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
|
||||
return releases;
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
|
||||
} catch (readErr: any) {
|
||||
if (readErr.code !== "ENOENT") {
|
||||
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 +195,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 +290,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 +624,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 +641,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;
|
||||
@@ -736,8 +763,11 @@ export default class Controller {
|
||||
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
|
||||
return serialized.map(json => {
|
||||
for (const mod of json.mods) {
|
||||
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
|
||||
mod.version = lib.normaliseFullVersion(mod.version);
|
||||
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
|
||||
// All other versions are kept verbatim so they still match the mod's file name.
|
||||
if (lib.isPartialVersion(mod.version)) {
|
||||
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
|
||||
}
|
||||
}
|
||||
return json;
|
||||
});
|
||||
@@ -870,6 +900,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const systems = [...this.systems.values()].filter(
|
||||
metric => metric.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
@@ -883,10 +916,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[]) {
|
||||
@@ -895,6 +925,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleHostSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const hosts = [...this.hosts.values()].filter(
|
||||
host => host.updatedAtMs > request.lastRequestTimeMs,
|
||||
).map(host => host.toHostDetails());
|
||||
@@ -942,6 +975,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const instances = [...this.instances.values()].filter(
|
||||
instance => instance.updatedAtMs > request.lastRequestTimeMs,
|
||||
).map(instance => instance.toInstanceDetails());
|
||||
@@ -953,7 +989,12 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
|
||||
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const saves = [...this.saves.values()].filter(
|
||||
save => save.updatedAtMs > request.lastRequestTimeMs
|
||||
);
|
||||
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
|
||||
}
|
||||
|
||||
@@ -964,6 +1005,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleModPackSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const modPacks = [...this.modPacks.values()].filter(
|
||||
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
@@ -977,6 +1021,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleModSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const mods = [...this.modStore.files.values()].filter(
|
||||
mod => mod.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
@@ -988,6 +1035,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleUserSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const users = [...this.users.records.values()].filter(
|
||||
user => user.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
@@ -1035,6 +1085,9 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async handleRoleSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
const roles = [...this.roles.values()].filter(
|
||||
role => role.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
+30
-98
@@ -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");
|
||||
@@ -14,7 +14,6 @@ const { levels, logger, setLogLevel } = require("./logging");
|
||||
const { copyPluginTemplates } = require("./template");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
const { escapeArg } = require("./escape_arg");
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
@@ -97,11 +96,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 +233,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 +245,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 +253,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 +270,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 +290,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 +298,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 +309,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 +340,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 +366,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,26 +655,10 @@ 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);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -687,7 +683,7 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,53 +750,6 @@ async function inquirerMissingArgs(args) {
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function downloadLinuxServer() {
|
||||
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
||||
|
||||
// get the filename of the latest factorio archive from redirected url
|
||||
const url = new URL(res.url); // The res is redirects, need final url
|
||||
const filename = path.posix.basename(url.pathname);
|
||||
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||
if (!match || !match.length) {
|
||||
throw Error(`Unable to extract version from filename: ${filename}`);
|
||||
}
|
||||
const version = match[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.emptyDir(tmpDir);
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rm(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args = yargs
|
||||
.option("log-level", {
|
||||
@@ -823,10 +772,6 @@ async function main() {
|
||||
.option("http-port", {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("download-headless", {
|
||||
describe: "Download the latest headless release [standalone/controller] [linux only]",
|
||||
type: "boolean", nargs: 0,
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
@@ -865,12 +810,6 @@ async function main() {
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.option("download-headless", {
|
||||
nargs: 0,
|
||||
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
@@ -897,13 +836,6 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
@@ -13,11 +13,10 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"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`);
|
||||
|
||||
+4
-11
@@ -4,11 +4,10 @@
|
||||
* 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";
|
||||
import setBlocking from "set-blocking";
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
@@ -311,15 +310,9 @@ async function startControl() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", control.shutdown.bind(control)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", control.shutdown.bind(control)));
|
||||
|
||||
try {
|
||||
const targetCommand = selectTargetCommand(args, rootCommands);
|
||||
|
||||
@@ -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.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -17,23 +17,19 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"set-blocking": "catalog:",
|
||||
"fs-extra": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"as-table": "^1.0.55",
|
||||
"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",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -9,3 +9,23 @@ debugCommands.add(new lib.Command({
|
||||
control.keepOpen = true;
|
||||
},
|
||||
}));
|
||||
|
||||
debugCommands.add(new lib.Command({
|
||||
definition: [
|
||||
"parse-exchange-string <exchange-string>",
|
||||
"Parse and print the contents of an exchange string",
|
||||
(yargs) => {
|
||||
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
|
||||
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
|
||||
}],
|
||||
handler: async function(args: { exchangeString: string, pretty: boolean }, control: Control) {
|
||||
const result = lib.readMapExchangeString(args.exchangeString);
|
||||
if (args.pretty) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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";
|
||||
@@ -20,7 +20,7 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
|
||||
@@ -108,9 +108,13 @@ modCommands.add(new lib.Command({
|
||||
},
|
||||
control: Control
|
||||
) {
|
||||
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
|
||||
if (searchVersion === undefined) {
|
||||
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
|
||||
}
|
||||
let response = await control.send(new lib.ModSearchRequest(
|
||||
args.query,
|
||||
lib.normaliseApiVersion(args.factorioVersion as any),
|
||||
lib.normaliseMajorMinorVersion(searchVersion),
|
||||
args.page,
|
||||
args.pageSize,
|
||||
args.sort,
|
||||
@@ -151,7 +155,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")!,
|
||||
@@ -187,37 +191,15 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
|
||||
|
||||
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}`);
|
||||
},
|
||||
@@ -229,7 +211,7 @@ modCommands.add(new lib.Command({
|
||||
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { name: string, modVersion: string }, control: Control) {
|
||||
if (!lib.isFullVersion(args.modVersion)) {
|
||||
if (!lib.isSourceVersion(args.modVersion)) {
|
||||
throw new lib.CommandError("mod-version must match format digit.digit.digit");
|
||||
}
|
||||
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
|
||||
|
||||
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
|
||||
if (!version) {
|
||||
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
|
||||
}
|
||||
if (!lib.isFullVersion(version)) {
|
||||
if (!lib.isSourceVersion(version)) {
|
||||
throw new lib.CommandError("version must match the format digit.digit.digit");
|
||||
}
|
||||
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
}));
|
||||
+6
-31
@@ -12,10 +12,9 @@
|
||||
* @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";
|
||||
import { version } from "./package.json";
|
||||
import winston from "winston";
|
||||
import "winston-daily-rotate-file";
|
||||
@@ -199,9 +198,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.
|
||||
@@ -239,32 +238,8 @@ async function startHost() {
|
||||
);
|
||||
|
||||
// Handle interrupts
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
host!.shutdown();
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
{
|
||||
"version": "2.0.20",
|
||||
"factorio_version": "2.0"
|
||||
},
|
||||
{
|
||||
"version": "2.0.21",
|
||||
"factorio_version": "2.1"
|
||||
}
|
||||
],
|
||||
"title": "Clusterio Library (Alpha)",
|
||||
|
||||
@@ -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)
|
||||
@@ -17,6 +17,8 @@ local lib_json = require("json")
|
||||
--- @field prototypes LuaPrototypes
|
||||
--- @field active_mods table<string, string>
|
||||
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
|
||||
--- @field encode_string fun(string: string): string
|
||||
--- @field decode_string fun(string: string): string
|
||||
--- @field table_to_json fun(data: table): string
|
||||
--- @field json_to_table fun(json: string): AnyBasic?
|
||||
local compat = {}
|
||||
@@ -177,6 +179,24 @@ function runtime_properties.write_file()
|
||||
end
|
||||
end
|
||||
|
||||
--- Deflates and base64 encodes the given string.
|
||||
function runtime_properties.encode_string()
|
||||
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
|
||||
return game.encode_string
|
||||
elseif v2 then
|
||||
return helpers.encode_string
|
||||
end
|
||||
end
|
||||
|
||||
--- Base64 decodes and inflates the given string.
|
||||
function runtime_properties.decode_string()
|
||||
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
|
||||
return game.decode_string
|
||||
elseif v2 then
|
||||
return helpers.decode_string
|
||||
end
|
||||
end
|
||||
|
||||
--- Convert tables to and from json, we have backported this to work before runtime
|
||||
if v0 or v1 then
|
||||
compat.table_to_json = function(tbl)
|
||||
|
||||
@@ -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.27",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -18,20 +18,19 @@
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@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:"
|
||||
}
|
||||
|
||||
+50
-61
@@ -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) {
|
||||
@@ -644,10 +659,8 @@ export default class Host extends lib.Link {
|
||||
return await this.downloadMod(mod);
|
||||
}
|
||||
|
||||
async fetchMods(mods: Iterable<lib.ModRecord>) {
|
||||
// This is better than the previous hard coded names
|
||||
// But it really shouldn't be a hard coded version either
|
||||
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
|
||||
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
|
||||
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
|
||||
const modInfos: Promise<lib.ModInfo>[] = [];
|
||||
for (const mod of mods) {
|
||||
if (builtinModNames.includes(mod.name)) {
|
||||
@@ -832,7 +845,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 +862,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 +929,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 +954,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 +983,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 +1041,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 +1053,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 +1087,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";
|
||||
@@ -642,6 +642,21 @@ end`.replace(/\r?\n/g, " ");
|
||||
try {
|
||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
await this._loadStats();
|
||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||
// version using the controller's cached latest-releases data, so the
|
||||
// existing download/lookup logic can fetch and run it.
|
||||
const targetVersion = this.config.get("factorio.version") as string;
|
||||
if (lib.isReleaseChannel(targetVersion)) {
|
||||
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`Cannot resolve Factorio release channel '${targetVersion}': ` +
|
||||
"latest-releases unavailable and no cached copy on the controller."
|
||||
);
|
||||
}
|
||||
this.server.setTargetVersion(resolved);
|
||||
}
|
||||
await this.server.checkForUpdates(factorioVersions);
|
||||
await this.server.init();
|
||||
} catch (err) {
|
||||
@@ -755,8 +770,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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -782,11 +797,9 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
this.activeModPack = modPack;
|
||||
|
||||
// TODO validate factorioVersion
|
||||
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
|
||||
|
||||
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 +838,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 +934,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);
|
||||
}
|
||||
|
||||
+44
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
|
||||
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
|
||||
return [path.join(factorioDir, "data"), directVersion];
|
||||
}
|
||||
throw new Error(`Unable to find Factorio version ${targetVersion}`);
|
||||
throw new Error(
|
||||
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
|
||||
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
|
||||
"per version, to run other versions or download them automatically."
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise assume it is a directory of multiple versions
|
||||
@@ -631,7 +636,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 +890,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 +912,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;
|
||||
@@ -951,6 +956,18 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
@@ -986,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (!installedVersions.versions.has(latestVersion.version)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct || process.platform !== "linux") {
|
||||
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1527,20 +1556,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.27",
|
||||
"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,12 +1,11 @@
|
||||
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";
|
||||
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data";
|
||||
|
||||
export interface ModStoreEvents {
|
||||
/** A stored mod was created, updated or deleted */
|
||||
@@ -45,7 +44,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
super();
|
||||
}
|
||||
|
||||
getMod(name: string, version: FullVersion, sha1?: string) {
|
||||
getMod(name: string, version: SourceVersion, sha1?: string) {
|
||||
const mod = this.files.get(ModInfo.filename(name, version));
|
||||
if (!mod || sha1 && sha1 !== mod.sha1) {
|
||||
return undefined;
|
||||
@@ -87,11 +86,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
this.emit("change", modInfo);
|
||||
}
|
||||
|
||||
async loadMod(name: string, version: FullVersion) {
|
||||
async loadMod(name: string, version: SourceVersion) {
|
||||
return await this.loadFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
async deleteMod(name: string, version: FullVersion) {
|
||||
async deleteMod(name: string, version: SourceVersion) {
|
||||
return await this.deleteFile(ModInfo.filename(name, version));
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +198,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
private async downloadModsChunk(
|
||||
mods: ModNameVersionPair[],
|
||||
username: string, token: string,
|
||||
factorioVersion: ApiVersion,
|
||||
factorioVersion: MajorMinorVersion,
|
||||
) {
|
||||
const url = new URL("https://mods.factorio.com/api/mods");
|
||||
url.searchParams.set("page_size", "max");
|
||||
@@ -248,7 +209,18 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
|
||||
// Surface the portal's own error (e.g. an unsupported version) rather
|
||||
// than a generic status line.
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json() as { message?: string };
|
||||
if (body && typeof body.message === "string") {
|
||||
detail = body.message;
|
||||
}
|
||||
} catch {
|
||||
// Response body was not JSON; keep the status line.
|
||||
}
|
||||
throw new Error(`Mod portal request to ${url} failed: ${detail}`);
|
||||
}
|
||||
|
||||
const modReleases = (await response.json() as ModsInfoResponse).results;
|
||||
@@ -275,7 +247,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
|
||||
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
|
||||
*/
|
||||
async downloadMods(
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
|
||||
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
|
||||
) {
|
||||
const chunkSize = 500;
|
||||
const futures: Promise<void>[] = [];
|
||||
|
||||
@@ -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,8 @@ import { basicType } from "../helpers";
|
||||
import * as libSchema from "../schema";
|
||||
import { StringEnum } from "../data/composites";
|
||||
import { safeOutputFile } from "../file_ops";
|
||||
import * as validators from "./validators";
|
||||
import { RequestError } from "../errors";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
@@ -20,7 +22,7 @@ export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidAccess extends Error { };
|
||||
export class InvalidAccess extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Value exception
|
||||
@@ -30,7 +32,7 @@ export class InvalidAccess extends Error { };
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidValue extends Error { };
|
||||
export class InvalidValue extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Field exception
|
||||
@@ -40,7 +42,7 @@ export class InvalidValue extends Error { };
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidField extends Error { };
|
||||
export class InvalidField extends RequestError { };
|
||||
|
||||
|
||||
const OldConfigGroupSchema = Type.Object({
|
||||
@@ -63,14 +65,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 +91,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 +115,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 +159,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 +201,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 +231,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. */
|
||||
@@ -222,7 +251,7 @@ export class Config<
|
||||
constructor(
|
||||
public location: ConfigLocation,
|
||||
fields?: Static<typeof Config.jsonSchema>,
|
||||
public filepath?: string,
|
||||
private _filepath?: string,
|
||||
) {
|
||||
if (typeof location !== "string") {
|
||||
throw new Error("location must be a string");
|
||||
@@ -244,6 +273,119 @@ export class Config<
|
||||
}
|
||||
}
|
||||
|
||||
get filepath() {
|
||||
return this._filepath;
|
||||
}
|
||||
|
||||
set filepath(filepath: string | undefined) {
|
||||
this.dirty = true;
|
||||
this._filepath = filepath;
|
||||
}
|
||||
|
||||
/** 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 +430,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 +456,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 +656,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 +728,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 +747,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>;
|
||||
@@ -16,9 +18,10 @@ export interface ControllerConfigFields {
|
||||
"controller.bind_address": string | null;
|
||||
"controller.trusted_proxies": string | null;
|
||||
"controller.public_url": string | null;
|
||||
"controller.grafana_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 +76,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 +99,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 +110,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 +121,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 +129,20 @@ 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.grafana_url": {
|
||||
title: "Grafana URL",
|
||||
description: "URL of a Grafana dashboard to link from the overview page, including the protocol.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
},
|
||||
"controller.tls_certificate": {
|
||||
title: "TLS Certificate",
|
||||
@@ -127,6 +150,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 +158,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 +176,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 +249,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 +331,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 +339,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 +361,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 +375,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 +401,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 +495,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",
|
||||
@@ -468,11 +508,18 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
initialValue: false,
|
||||
},
|
||||
"factorio.version": {
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
description:
|
||||
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
|
||||
"channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.",
|
||||
restartRequired: true,
|
||||
type: "string",
|
||||
initialValue: "latest",
|
||||
inputComponent: "target_version",
|
||||
validator: function(value) {
|
||||
if (!isTargetVersion(value)) {
|
||||
throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z");
|
||||
}
|
||||
},
|
||||
},
|
||||
"factorio.executable_path": {
|
||||
description:
|
||||
@@ -481,6 +528,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 +536,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 +571,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 +627,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 +670,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
restartRequired: true,
|
||||
type: "number",
|
||||
initialValue: 5,
|
||||
validator: validators.greaterThanZero,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -644,12 +705,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 +725,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";
|
||||
|
||||
@@ -8,14 +8,13 @@ import { findRoot } from "../zip_ops";
|
||||
import { ModRecord } from "./ModPack";
|
||||
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionSchema,
|
||||
FullVersion, FullVersionSchema, integerFullVersion,
|
||||
integerPartialVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
|
||||
|
||||
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required" | "recommended";
|
||||
|
||||
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
|
||||
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
|
||||
@@ -40,6 +39,8 @@ export class ModDependency {
|
||||
return "hidden";
|
||||
case "~":
|
||||
return "unordered";
|
||||
case "+":
|
||||
return "recommended";
|
||||
case "":
|
||||
return "required";
|
||||
default:
|
||||
@@ -87,6 +88,10 @@ export class ModDependency {
|
||||
return this.type === "incompatible";
|
||||
}
|
||||
|
||||
get recommended() {
|
||||
return this.type === "recommended";
|
||||
}
|
||||
|
||||
get required() {
|
||||
return this.type === "unordered" || this.type === "required";
|
||||
}
|
||||
@@ -129,16 +134,23 @@ export default class ModInfo {
|
||||
name = "";
|
||||
|
||||
/**
|
||||
* Version of the mod.
|
||||
* Sourced from info.json.
|
||||
* Version of the mod, exactly as it appears in info.json and the mod's file
|
||||
* name. This is the raw, un-normalised version the game reads leniently, so
|
||||
* it is only safe to use for building file names and identifiers; use
|
||||
* {@link integerVersion} to compare it.
|
||||
*/
|
||||
version = "0.0.0" as FullVersion;
|
||||
version = "0.0.0" as SourceVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the version
|
||||
* Integer representation of the version, normalised the lenient way the
|
||||
* game reads it so it can be compared and sorted.
|
||||
*/
|
||||
get integerVersion() {
|
||||
return integerFullVersion(this.version);
|
||||
return integerSourceVersion(this.version);
|
||||
}
|
||||
|
||||
get normalisedVersion() {
|
||||
return normaliseSourceVersion(this.version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,17 +184,17 @@ export default class ModInfo {
|
||||
description = "";
|
||||
|
||||
/**
|
||||
* Major version of Factorio this mod supports.
|
||||
* Sourced from info.json.
|
||||
* Major version of Factorio this mod supports, reduced to major.minor.
|
||||
* Sourced from info.json and normalised when read in fromJSON.
|
||||
*/
|
||||
factorioVersion = "0.12" as "0.12" | ApiVersion;
|
||||
factorioVersion = "0.12" as MajorMinorVersion;
|
||||
|
||||
/**
|
||||
* Integer representation of the factorioVersion
|
||||
* @type {number}
|
||||
*/
|
||||
get integerFactorioVersion() {
|
||||
return integerPartialVersion(this.factorioVersion);
|
||||
return integerMajorMinorVersion(this.factorioVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +233,7 @@ export default class ModInfo {
|
||||
* @param version - Mod's version
|
||||
* @returns string containing {name}_{version}.zip
|
||||
*/
|
||||
static filename(name: string, version: FullVersion) {
|
||||
static filename(name: string, version: SourceVersion) {
|
||||
return `${name}_${version}.zip`;
|
||||
}
|
||||
|
||||
@@ -248,16 +260,17 @@ export default class ModInfo {
|
||||
*/
|
||||
isDeleted = false;
|
||||
|
||||
// Content of info.json found in mod files
|
||||
// Content of info.json found in mod files. The version fields use the same
|
||||
// lenient format the game accepts; they are normalised when read in fromJSON.
|
||||
static infoJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"title": Type.String(),
|
||||
"author": Type.String(),
|
||||
"contact": Type.Optional(Type.String()),
|
||||
"homepage": Type.Optional(Type.String()),
|
||||
"description": Type.Optional(Type.String()),
|
||||
"factorio_version": Type.Optional(ApiVersionSchema),
|
||||
"factorio_version": Type.Optional(SourceVersionSchema),
|
||||
"dependencies": Type.Optional(Type.Array(Type.String())),
|
||||
});
|
||||
|
||||
@@ -279,13 +292,24 @@ export default class ModInfo {
|
||||
|
||||
// info.json fields
|
||||
if (json.name) { modInfo.name = json.name; }
|
||||
if (json.version) { modInfo.version = json.version; }
|
||||
if (json.version) {
|
||||
if (!isSourceVersion(json.version)) {
|
||||
throw new Error(`Invalid mod version "${json.version}"`);
|
||||
}
|
||||
modInfo.version = json.version;
|
||||
}
|
||||
if (json.title) { modInfo.title = json.title; }
|
||||
if (json.author) { modInfo.author = json.author; }
|
||||
if (json.contact) { modInfo.contact = json.contact; }
|
||||
if (json.homepage) { modInfo.homepage = json.homepage; }
|
||||
if (json.description) { modInfo.description = json.description; }
|
||||
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
|
||||
if (json.factorio_version) {
|
||||
const factorioVersion = normaliseSourceVersion(json.factorio_version);
|
||||
if (factorioVersion === undefined) {
|
||||
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
|
||||
}
|
||||
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
|
||||
}
|
||||
|
||||
// Parse the dependencies
|
||||
try {
|
||||
@@ -314,7 +338,7 @@ export default class ModInfo {
|
||||
if (this.contact) { json.contact = this.contact; }
|
||||
if (this.homepage) { json.homepage = this.homepage; }
|
||||
if (this.description) { json.description = this.description; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
|
||||
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as SourceVersion; }
|
||||
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
|
||||
json.dependencies = this.dependencySpecifications;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ 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";
|
||||
|
||||
import {
|
||||
PartialVersion, PartialVersionSchema, integerPartialVersion,
|
||||
FullVersion, FullVersionSchema, normaliseFullVersion,
|
||||
SourceVersion, SourceVersionSchema, normaliseFullVersion,
|
||||
} from "./version";
|
||||
|
||||
|
||||
@@ -45,8 +46,8 @@ export interface ModRecord {
|
||||
name: string,
|
||||
/** if mod is to be loaded. */
|
||||
enabled: boolean,
|
||||
/** version of the mod. */
|
||||
version: FullVersion,
|
||||
/** version of the mod, verbatim as it appears in the mod's file name. */
|
||||
version: SourceVersion,
|
||||
/** SHA1 hash of the zip file. */
|
||||
sha1?: string,
|
||||
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
|
||||
@@ -60,7 +61,7 @@ export interface ModRecord {
|
||||
const ModRecordJsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"enabled": Type.Boolean(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -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,13 +390,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Array of default mod packs which should exist on a newly installed cluster */
|
||||
static defaultModPacks = [
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
|
||||
...(["0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Base Game ${version}`,
|
||||
description: `Factorio ${version} with no extra mods.`,
|
||||
@@ -412,6 +413,18 @@ export default class ModPack {
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
...(["2.1"] as const)
|
||||
.map(version => this.fromJSON({
|
||||
name: `Space Age ${version}`,
|
||||
description: `Factorio ${version} with Space Age expansion.`,
|
||||
factorio_version: version,
|
||||
mods: [
|
||||
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
|
||||
{ name: "recycler", enabled: true, version: normaliseFullVersion(version) },
|
||||
],
|
||||
} as any)),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -421,18 +434,24 @@ export default class ModPack {
|
||||
* @return Built in mods for the given version
|
||||
*/
|
||||
static getBuiltinMods(factorioVersion: PartialVersion) {
|
||||
factorioVersion = normaliseFullVersion(factorioVersion);
|
||||
const version = normaliseFullVersion(factorioVersion) as SourceVersion;
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
let defaultMods: ModRecord[] = [
|
||||
// "core" not included because core cannot be disabled
|
||||
{ name: "base", enabled: true, version: factorioVersion },
|
||||
{ name: "base", enabled: true, version: version },
|
||||
];
|
||||
|
||||
const integerVersion = integerPartialVersion(factorioVersion);
|
||||
if (integerVersion >= integerPartialVersion("1.2")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "elevated-rails", enabled: false, version: factorioVersion },
|
||||
{ name: "quality", enabled: false, version: factorioVersion },
|
||||
{ name: "space-age", enabled: false, version: factorioVersion },
|
||||
{ name: "elevated-rails", enabled: false, version: version },
|
||||
{ name: "quality", enabled: false, version: version },
|
||||
{ name: "space-age", enabled: false, version: version },
|
||||
]);
|
||||
}
|
||||
|
||||
if (integerVersion >= integerPartialVersion("2.1")) {
|
||||
defaultMods = defaultMods.concat([
|
||||
{ name: "recycler", enabled: false, version: version },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@ export default class Permission {
|
||||
public name: string,
|
||||
public title: string,
|
||||
public description: string,
|
||||
public grantByDefault?: boolean,
|
||||
public grantByDefault: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
title: Type.String(),
|
||||
description: Type.String(),
|
||||
grantByDefault: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.name, json.title, json.description);
|
||||
return new this(json.name, json.title, json.description, Boolean(json.grantByDefault));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import { JsonString, StringEnum, StringKey, plainJson } from "./composites";
|
||||
import { levels } from "../logging";
|
||||
import { ControllerConfig, HostConfig } from "../config";
|
||||
import { ExternalFactorioVersionSchema } from "../external";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external";
|
||||
|
||||
export class ControllerStopRequest {
|
||||
declare ["constructor"]: typeof ControllerStopRequest;
|
||||
@@ -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;
|
||||
@@ -420,3 +442,25 @@ export class FactorioVersionsRequest {
|
||||
|
||||
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
|
||||
}
|
||||
|
||||
export class LatestReleasesRequest {
|
||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_latest_releases" as const;
|
||||
|
||||
constructor(
|
||||
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"maxAgeMs": Type.Number(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(LatestReleasesSchema);
|
||||
}
|
||||
@@ -102,8 +102,16 @@ export class Address {
|
||||
* Returns a string suitable for indexing the address in a Map
|
||||
* @returns unique string for this address
|
||||
*/
|
||||
index() {
|
||||
return `${this.type}:${this.id}:${this.requestId}`;
|
||||
requestIndex() {
|
||||
return `${this.type}:${this.id}:${this.requestId}` as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string suitable for indexing the address in a Map
|
||||
* @returns unique string for this address, excluding required id
|
||||
*/
|
||||
addressIndex() {
|
||||
return `${this.type}:${this.id}` 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,8 +4,8 @@ import ModPack from "./ModPack";
|
||||
import { JsonString, jsonArray } from "./composites";
|
||||
|
||||
import {
|
||||
FullVersion, FullVersionSchema,
|
||||
ApiVersion, ApiVersionSchema, normaliseApiVersion,
|
||||
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
|
||||
SourceVersion, SourceVersionSchema,
|
||||
ModVersionEquality,
|
||||
} from "./version";
|
||||
|
||||
@@ -119,13 +119,13 @@ export class ModGetRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ export class ModSearchRequest {
|
||||
|
||||
constructor(
|
||||
public query: string,
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public page: number,
|
||||
public pageSize?: number,
|
||||
public sort?: string,
|
||||
@@ -163,7 +163,7 @@ export class ModSearchRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"query": Type.String(),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"page": Type.Integer(),
|
||||
"pageSize": Type.Optional(Type.Integer()),
|
||||
"sort": Type.Optional(Type.String()),
|
||||
@@ -205,9 +205,9 @@ export class ModSearchRequest {
|
||||
|
||||
// Define the structure for the latest release info from the portal
|
||||
export const ModPortalReleaseSchema = Type.Object({
|
||||
version: FullVersionSchema,
|
||||
version: SourceVersionSchema,
|
||||
// Match the structure from ModStore's ModRelease/ModDetails
|
||||
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
|
||||
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
|
||||
released_at: Type.String(), // ISO 8601 date string
|
||||
download_url: Type.String(),
|
||||
file_name: Type.String(),
|
||||
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
|
||||
static permission = "core.mod.search_portal" as const;
|
||||
|
||||
constructor(
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public hide_deprecated?: boolean,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"hide_deprecated": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
public sha1?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
"sha1": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: FullVersion,
|
||||
public version: SourceVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"version": FullVersionSchema,
|
||||
"version": SourceVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModNameVersionPair[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModNameVersionPairSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
|
||||
|
||||
constructor(
|
||||
public mods: ModDependency[],
|
||||
public factorioVersion: ApiVersion,
|
||||
public factorioVersion: MajorMinorVersion,
|
||||
public checkForUpdates: boolean = false,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"mods": Type.Array(ModDependency.jsonSchema),
|
||||
"factorioVersion": ApiVersionSchema,
|
||||
"factorioVersion": MajorMinorVersionSchema,
|
||||
"checkForUpdates": Type.Boolean(),
|
||||
});
|
||||
|
||||
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
|
||||
return new this(
|
||||
[...modPack.mods.values()]
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
|
||||
[...modPack.mods.values()]
|
||||
.filter(mod => mod.enabled)
|
||||
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
|
||||
normaliseApiVersion(modPack.factorioVersion),
|
||||
normaliseMajorMinorVersion(modPack.factorioVersion),
|
||||
checkForUpdates,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,24 +24,55 @@ export function isVersionEquality(input: string): input is VersionEquality {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions accepts by the web API
|
||||
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
|
||||
* Known Factorio major versions, offered as fallback options in the web
|
||||
* interface before the live version list has been fetched. Versions are no
|
||||
* longer validated against this list; the mod portal is the source of truth.
|
||||
*/
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
|
||||
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
|
||||
export type ApiVersion = Static<typeof ApiVersionSchema>;
|
||||
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const;
|
||||
|
||||
export function isApiVersion(input: string): input is ApiVersion {
|
||||
return ApiVersions.includes(input as any);
|
||||
/**
|
||||
* Matches a version string the same lenient way the game reads it: each number
|
||||
* may be preceded by whitespace, the major and minor are required, the patch is
|
||||
* optional, and any trailing content is ignored. The pattern is intentionally
|
||||
* not anchored at the end.
|
||||
*/
|
||||
const sourceVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
|
||||
|
||||
/**
|
||||
* A raw, un-normalised version string exactly as the game reads it, such as a
|
||||
* mod's version taken from info.json or a mod file name.
|
||||
*/
|
||||
declare const sourceVersionSymbol: unique symbol;
|
||||
export type SourceVersion = string & { [sourceVersionSymbol]: void };
|
||||
export const SourceVersionSchema = Type.Unsafe<SourceVersion>(
|
||||
Type.String({ pattern: sourceVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isSourceVersion(input: string): input is SourceVersion {
|
||||
return sourceVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function normaliseApiVersion(version: PartialVersion) {
|
||||
const parts = version.split(".");
|
||||
const apiVersion = `${parts[0]}.${parts[1]}`;
|
||||
if (!isApiVersion(apiVersion)) {
|
||||
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
|
||||
export function integerSourceVersion(version: SourceVersion) {
|
||||
return integerFullVersion(normaliseSourceVersion(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a version string the same lenient way the game reads it. Numbers
|
||||
* are read as integers, so leading whitespace and zeros are dropped and any
|
||||
* content after the patch is ignored.
|
||||
*
|
||||
* @returns The normalised full version, or undefined if no major and minor
|
||||
* could be read.
|
||||
*/
|
||||
export function normaliseSourceVersion(input: SourceVersion): FullVersion;
|
||||
export function normaliseSourceVersion(input: string): FullVersion | undefined;
|
||||
|
||||
export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined {
|
||||
const match = sourceVersionRegExp.exec(input);
|
||||
if (match === null) {
|
||||
return undefined;
|
||||
}
|
||||
return apiVersion;
|
||||
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,11 +99,38 @@ export function normaliseFullVersion(version: PartialVersion) {
|
||||
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a major.minor version, as used by the mod portal's version filter.
|
||||
*/
|
||||
const majorMinorVersionRegExp = /^\d+\.\d+$/;
|
||||
export type MajorMinorVersion = `${number}.${number}`;
|
||||
export const MajorMinorVersionSchema = Type.Unsafe<MajorMinorVersion>(
|
||||
Type.String({ pattern: majorMinorVersionRegExp.source })
|
||||
);
|
||||
|
||||
export function isMajorMinorVersion(input: string): input is MajorMinorVersion {
|
||||
return majorMinorVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
export function integerMajorMinorVersion(version: MajorMinorVersion) {
|
||||
const [major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
|
||||
// Can't use bitwise here because this is 48-bits. sub is always 0 so is omitted.
|
||||
return major * 0x100000000 + minor * 0x10000 as IntegerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a version to its major.minor, as used by the mod portal's version filter.
|
||||
*/
|
||||
export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion {
|
||||
const [major, minor] = version.split(".");
|
||||
return `${major}.${minor}` as MajorMinorVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
|
||||
*/
|
||||
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
|
||||
export type PartialVersion = FullVersion | `${number}.${number}`;
|
||||
export type PartialVersion = FullVersion | MajorMinorVersion;
|
||||
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
|
||||
Type.String({ pattern: partialVersionRegExp.source })
|
||||
);
|
||||
@@ -88,16 +146,35 @@ export function integerPartialVersion(version: PartialVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
|
||||
* Matches a release channel name from the latest-releases API, such as
|
||||
* "stable" or "experimental". Excludes "latest", which is handled separately.
|
||||
* The set of channels is dynamic, so any lowercase identifier is accepted here
|
||||
* and resolved against the live API when the instance starts.
|
||||
*/
|
||||
export type TargetVersion = PartialVersion | "latest";
|
||||
const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/;
|
||||
declare const releaseChannelSymbol: unique symbol;
|
||||
export type ReleaseChannel = string & { [releaseChannelSymbol]: void };
|
||||
export const ReleaseChannelSchema = Type.Unsafe<ReleaseChannel>(
|
||||
Type.String({ pattern: releaseChannelRegExp.source })
|
||||
);
|
||||
|
||||
export function isReleaseChannel(input: string): input is ReleaseChannel {
|
||||
return input !== "latest" && releaseChannelRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches valid factorio target versions: a partial version, "latest", or a
|
||||
* release channel name (e.g. "stable" or "experimental").
|
||||
*/
|
||||
export type TargetVersion = PartialVersion | ReleaseChannel | "latest";
|
||||
export const TargetVersionSchema = Type.Union([
|
||||
PartialVersionSchema,
|
||||
ReleaseChannelSchema,
|
||||
Type.Literal("latest"),
|
||||
]);
|
||||
|
||||
export function isTargetVersion(input: string): input is TargetVersion {
|
||||
return input === "latest" || partialVersionRegExp.test(input);
|
||||
return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,8 +213,8 @@ export class ModVersionEquality {
|
||||
}
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: SourceVersion) {
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -199,8 +276,11 @@ export class ModVersionRange {
|
||||
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
|
||||
}
|
||||
|
||||
testVersion(version: PartialVersion) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
testVersion(version: PartialVersion | SourceVersion) {
|
||||
if (isPartialVersion(version)) {
|
||||
return this.testIntegerVersion(integerPartialVersion(version));
|
||||
}
|
||||
return this.testIntegerVersion(integerSourceVersion(version));
|
||||
}
|
||||
|
||||
combineVersion(other: ModVersionEquality) {
|
||||
|
||||
@@ -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;
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
import { FullVersion, FullVersionSchema } from "../data/version";
|
||||
|
||||
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
|
||||
|
||||
/**
|
||||
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
|
||||
* for a single release channel.
|
||||
*/
|
||||
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
|
||||
|
||||
/**
|
||||
* The latest released version of each build, keyed by release channel name
|
||||
* (e.g. "stable" and "experimental").
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
*
|
||||
* @returns The latest version of each build keyed by release channel
|
||||
* @throws Network errors and non-ok status responses
|
||||
*/
|
||||
export async function fetchLatestReleases(): Promise<LatestReleases> {
|
||||
let res;
|
||||
|
||||
try {
|
||||
res = await fetch(LATEST_RELEASES_URL);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return await res.json() as LatestReleases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a release channel to a concrete factorio version
|
||||
*
|
||||
* @param releases Releases data as returned by {@link fetchLatestReleases}
|
||||
* @param channel Release channel name, e.g. "stable" or "experimental"
|
||||
* @param build Build to read the version of, defaults to the headless build
|
||||
* @returns The version for the channel, or undefined if it cannot be resolved
|
||||
*/
|
||||
export function resolveReleaseChannel(
|
||||
releases: LatestReleases,
|
||||
channel: string,
|
||||
build = "headless",
|
||||
): FullVersion | undefined {
|
||||
const builds = releases[channel];
|
||||
if (!builds) {
|
||||
return undefined;
|
||||
}
|
||||
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
|
||||
}
|
||||
Vendored
+1
@@ -3,3 +3,4 @@
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
export * from "./LatestReleases";
|
||||
@@ -4,7 +4,10 @@ class MapReaderState {
|
||||
pos = 0;
|
||||
last_position = { x: 0, y: 0 };
|
||||
/** True when a version greater than 2.0.0 is detected */
|
||||
v2 = false;
|
||||
gt_v2_0 = false;
|
||||
/** True when a version greater than 2.1.0 is detected */
|
||||
gt_v2_1 = false;
|
||||
// If more versions are required then we should expose version directly
|
||||
constructor(
|
||||
public buf: Buffer
|
||||
) { }
|
||||
@@ -161,7 +164,7 @@ function readBoundingBox(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readCliffSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
name: readString(state),
|
||||
control: readString(state), // v2
|
||||
cliff_elevation_0: readFloat(state),
|
||||
@@ -186,7 +189,7 @@ function readTerritorySettings(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readMapGenSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
|
||||
default_enable_all_autoplace_controls: readBool(state),
|
||||
@@ -262,7 +265,23 @@ function readEnemyEvolution(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readEnemyExpansion(state: MapReaderState) {
|
||||
return {
|
||||
return state.gt_v2_1 ? {
|
||||
enabled: readOptional(state, readBool),
|
||||
max_expansion_distance: readOptional(state, readUInt32),
|
||||
min_expansion_distance: readOptional(state, readUInt32), // v2.1
|
||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||
enemy_building_influence_radius: readOptional(state, readUInt32),
|
||||
building_coefficient: readOptional(state, readDouble),
|
||||
other_base_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_chunk_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
|
||||
max_colliding_tiles_coefficient: readOptional(state, readDouble),
|
||||
settler_group_min_size: readOptional(state, readUInt32),
|
||||
settler_group_max_size: readOptional(state, readUInt32),
|
||||
evolution_group_size_factor: readOptional(state, readDouble), // v2.1
|
||||
min_expansion_cooldown: readOptional(state, readUInt32),
|
||||
max_expansion_cooldown: readOptional(state, readUInt32),
|
||||
} : {
|
||||
enabled: readOptional(state, readBool),
|
||||
max_expansion_distance: readOptional(state, readUInt32),
|
||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||
@@ -336,7 +355,7 @@ function readPathFinder(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readDifficultySettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
return state.gt_v2_0 ? {
|
||||
technology_price_multiplier: readDouble(state),
|
||||
spoil_time_modifier: readDouble(state), // v2
|
||||
} : {
|
||||
@@ -355,17 +374,32 @@ function readAsteroids(state: MapReaderState) {
|
||||
}
|
||||
|
||||
function readMapSettings(state: MapReaderState) {
|
||||
return state.v2 ? {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
} : {
|
||||
if (state.gt_v2_1) {
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
// steering removed v2.1
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
};
|
||||
} else if (state.gt_v2_0) {
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
asteroids: readAsteroids(state), // v2
|
||||
};
|
||||
}
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
@@ -426,7 +460,8 @@ export function readMapExchangeString(exchangeString: string) {
|
||||
|
||||
try {
|
||||
const version = readVersion(state);
|
||||
state.v2 = version[0] >= 2;
|
||||
state.gt_v2_0 = version[0] >= 2;
|
||||
state.gt_v2_1 = version[0] >= 2 && version[1] >= 1;
|
||||
data = {
|
||||
version: version,
|
||||
unknown: readUInt8(state),
|
||||
|
||||
+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());
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
* @module lib/helpers
|
||||
*/
|
||||
|
||||
import type { Logger } from "./logging";
|
||||
import setBlocking from "set-blocking";
|
||||
|
||||
/**
|
||||
* Return a string describing the type of the value passed
|
||||
*
|
||||
@@ -51,6 +54,32 @@ export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutRe
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Catch process signals, force exit on second signal
|
||||
*/
|
||||
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
|
||||
let called = false;
|
||||
|
||||
return () => {
|
||||
if (called) {
|
||||
setBlocking(true);
|
||||
logger.fatal(`Caught second ${signalName}, terminating immediately`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
called = true;
|
||||
logger.info(`Caught ${signalName}, shutting down`);
|
||||
callback().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.fatal("Error during shutdown, terminating immediately");
|
||||
logger.fatal(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to serialise and merge waiting calls to an async callback.
|
||||
*
|
||||
|
||||
@@ -219,7 +219,7 @@ export class Link {
|
||||
|
||||
if (!message.dst.addressedTo(this.connector.src)) {
|
||||
if (message.type === "response" || message.type === "responseError") {
|
||||
this._forwardedRequests.delete(message.dst.index());
|
||||
this._forwardedRequests.delete(message.dst.requestIndex());
|
||||
}
|
||||
this._routeMessage(message, entry);
|
||||
return;
|
||||
@@ -566,7 +566,7 @@ export class Link {
|
||||
src: message.src,
|
||||
dst: message.dst,
|
||||
};
|
||||
this._forwardedRequests.set(message.src.index(), pending);
|
||||
this._forwardedRequests.set(message.src.requestIndex(), pending);
|
||||
this.connector.forward(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -36,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
controller.LatestReleasesRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
@@ -43,6 +45,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 +62,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);
|
||||
|
||||
@@ -524,3 +524,9 @@ definePermission({
|
||||
title: "Get factorio versions",
|
||||
description: "Get list of all factorio versions from Wube",
|
||||
});
|
||||
|
||||
definePermission({
|
||||
name: "core.external.get_latest_releases",
|
||||
title: "Get factorio releases",
|
||||
description: "Get the latest stable and experimental factorio releases from Wube",
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
+401
-153
@@ -1,13 +1,144 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
|
||||
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
|
||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data";
|
||||
import isDeepStrictEqual from "./is_deep_strict_equal";
|
||||
import { RequestError, SessionLost } from "./errors";
|
||||
import { RequestError } from "./errors";
|
||||
import { logger } from "./logging";
|
||||
|
||||
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
|
||||
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
|
||||
|
||||
/**
|
||||
* Represents a set of string subscription filters.
|
||||
* When isAll() is true, all strings match this filter.
|
||||
* When isEmpty() is true, no strings match this filter.
|
||||
* Otherwise, literal string matching is performed against a set.
|
||||
*/
|
||||
export class SubscriptionFilters extends Set<string> {
|
||||
private constructor(
|
||||
private _all: boolean,
|
||||
_filters: Iterable<string> = [],
|
||||
) {
|
||||
super(_filters);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Array(Type.String());
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(false, json);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return [...this];
|
||||
}
|
||||
|
||||
/** Creates a subscription filter accepting all strings */
|
||||
static all() {
|
||||
return new this(true);
|
||||
}
|
||||
|
||||
isAll() {
|
||||
return this._all;
|
||||
}
|
||||
|
||||
/** Creates a subscription filter rejecting all strings */
|
||||
static empty() {
|
||||
return new this(false);
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return !this._all && this.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription filter from undefined, string, or array of strings.
|
||||
* Strings work as expected, undefined will produce a filter accepting all values.
|
||||
* @param filters Shorthand representation
|
||||
*/
|
||||
static fromShorthand(filters?: string | string[]) {
|
||||
if (filters === undefined) {
|
||||
return this.all();
|
||||
}
|
||||
if (Array.isArray(filters)) {
|
||||
return new this(false, new Set(filters));
|
||||
}
|
||||
return new this(false, new Set([filters]));
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this._all) {
|
||||
return "[SubscriptionFilters All]";
|
||||
}
|
||||
if (this.size === 0) {
|
||||
return "[SubscriptionFilters Empty]";
|
||||
}
|
||||
return `[SubscriptionFilters Set<${this.size}>]`;
|
||||
}
|
||||
|
||||
/** Returns true if this filter accepts the given string */
|
||||
accepts(value: string) {
|
||||
return this._all || this.has(value);
|
||||
}
|
||||
|
||||
/** Returns true if this filter extends the other */
|
||||
extends(other: this) {
|
||||
return this._all || (!other._all && [...other.values()].every(value => this.has(value)));
|
||||
}
|
||||
|
||||
/** Returns true if there is overlap between two filters */
|
||||
intersects(other: this) {
|
||||
if (this._all) {
|
||||
return !other.isEmpty();
|
||||
}
|
||||
if (other._all) {
|
||||
return !this.isEmpty();
|
||||
}
|
||||
for (const filter of other) {
|
||||
if (this.has(filter)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Add filters from a filter into this one. */
|
||||
union(other: this) {
|
||||
if (this._all) {
|
||||
return; // Already all
|
||||
}
|
||||
if (other._all) {
|
||||
this._all = true;
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
for (const filter of other) {
|
||||
this.add(filter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove filters in a filter from this one.
|
||||
* You cannot subtract filters from a filter accepting all.
|
||||
* Subtracting a filter accepting all produces an empty filter.
|
||||
*/
|
||||
subtract(other: this) {
|
||||
if (other._all) {
|
||||
this._all = false;
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
if (this._all) {
|
||||
return; // Cannot subtract from all
|
||||
}
|
||||
for (const filter of other) {
|
||||
this.delete(filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SubscriptionActionSchema = StringEnum(["subscribe", "unsubscribe", "replace"]);
|
||||
type SubscriptionAction = Static<typeof SubscriptionActionSchema>;
|
||||
|
||||
/**
|
||||
* A subscription request sent by a subscriber, this updates what events the subscriber will be sent
|
||||
* The permission for this request copies the permission from the event being subscribed to
|
||||
@@ -20,7 +151,7 @@ export class SubscriptionRequest {
|
||||
static dst = "controller" as const;
|
||||
static permission(user: IUser, message: MessageRequest) {
|
||||
if (typeof message.data === "object" && message.data !== null) {
|
||||
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
|
||||
const data = message.data as Static<typeof this.jsonSchema>;
|
||||
const entry = Link._eventsByName.get(data[0]);
|
||||
if (entry && entry.Event.permission) {
|
||||
if (typeof entry.Event.permission === "string") {
|
||||
@@ -34,55 +165,86 @@ export class SubscriptionRequest {
|
||||
|
||||
/** Indicates if updates have been sent */
|
||||
static Response = JsonBoolean;
|
||||
public filters: SubscriptionFilters;
|
||||
public action: SubscriptionAction;
|
||||
|
||||
/** @deprecated Use SubscriptionFilters and SubscriptionAction instead */
|
||||
constructor(
|
||||
eventName: string,
|
||||
subscribe: boolean,
|
||||
lastRequestTimeMs: number | undefined,
|
||||
filters?: string | string[],
|
||||
);
|
||||
|
||||
constructor(
|
||||
eventName: string,
|
||||
action: SubscriptionAction,
|
||||
lastRequestTimeMs: number | undefined,
|
||||
filters?: SubscriptionFilters,
|
||||
);
|
||||
|
||||
constructor(
|
||||
public eventName: string,
|
||||
public subscribe: boolean,
|
||||
action: boolean | SubscriptionAction,
|
||||
public lastRequestTimeMs: number = 0,
|
||||
public filters: string | string[] | undefined = undefined,
|
||||
filters: undefined | string | string[] | SubscriptionFilters,
|
||||
) {
|
||||
if (!Link._eventsByName.has(eventName)) {
|
||||
throw new Error(`Unregistered Event class ${eventName}`);
|
||||
}
|
||||
if (filters instanceof SubscriptionFilters) { // Easier migration for plugins
|
||||
this.filters = filters;
|
||||
} else {
|
||||
this.filters = SubscriptionFilters.fromShorthand(filters);
|
||||
}
|
||||
if (typeof action === "boolean") { // Easier migration for plugins
|
||||
this.action = action ? "subscribe" : "unsubscribe";
|
||||
} else {
|
||||
this.action = action;
|
||||
}
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Union([
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
SubscriptionActionSchema,
|
||||
Type.Number(),
|
||||
]),
|
||||
Type.Tuple([
|
||||
Type.String(),
|
||||
Type.Boolean(),
|
||||
SubscriptionActionSchema,
|
||||
Type.Number(),
|
||||
Type.Union([Type.String(), Type.Array(Type.String())]),
|
||||
SubscriptionFilters.jsonSchema,
|
||||
]),
|
||||
]);
|
||||
|
||||
toJSON() {
|
||||
if (this.filters !== undefined) {
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
|
||||
if (!this.filters.isAll()) {
|
||||
return [this.eventName, this.action, this.lastRequestTimeMs, this.filters.toJSON()];
|
||||
}
|
||||
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
|
||||
return [this.eventName, this.action, this.lastRequestTimeMs];
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
|
||||
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
|
||||
return new this(eventName, subscribe, lastRequestTimeMs, filters);
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
const [eventName, action, lastRequestTimeMs, filters] = json;
|
||||
return new this(
|
||||
eventName,
|
||||
action,
|
||||
lastRequestTimeMs,
|
||||
filters ? SubscriptionFilters.fromJSON(filters) : SubscriptionFilters.all()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
link: Link,
|
||||
dst: Address,
|
||||
/** Optional filters this subscriber is interested in. Undefined means all events */
|
||||
filters?: string[],
|
||||
filters: SubscriptionFilters,
|
||||
}
|
||||
|
||||
type EventData = {
|
||||
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
|
||||
subscriptions: Array<Subscriber>,
|
||||
subscriptions: Map<ReturnType<Address["addressIndex"]>, Subscriber>,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -92,13 +254,6 @@ type EventData = {
|
||||
export class SubscriptionController {
|
||||
_events = new Map<string, EventData>();
|
||||
|
||||
private static normalizeFilters(value?: string | string[]): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow clients to subscribe to an event by telling the subscription controller to accept them
|
||||
* Has an optional subscription update handler which is called when a client subscribes
|
||||
@@ -116,7 +271,7 @@ export class SubscriptionController {
|
||||
}
|
||||
this._events.set(entry.name, {
|
||||
subscriptionUpdate: subscriptionUpdate,
|
||||
subscriptions: [],
|
||||
subscriptions: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +279,7 @@ export class SubscriptionController {
|
||||
* Broadcast an event to all subscribers of that event
|
||||
* @param event - Event to broadcast.
|
||||
*/
|
||||
broadcast<T>(event: Event<T>, filter?: string | string[]) {
|
||||
broadcast<T extends Event<T>>(event: T & { filters?: string | string[] }, filters?: string | string[]) {
|
||||
const entry = Link._eventsByClass.get(event.constructor);
|
||||
if (!entry) {
|
||||
throw new Error(`Unregistered Event class ${event.constructor.name}`);
|
||||
@@ -133,24 +288,17 @@ export class SubscriptionController {
|
||||
if (!eventData) {
|
||||
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
|
||||
}
|
||||
const toRemove: Subscriber[] = [];
|
||||
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
|
||||
|
||||
for (const subscriber of eventData.subscriptions) {
|
||||
const broadcastFilters = SubscriptionFilters.fromShorthand(filters ?? event.filters);
|
||||
for (const [addressIndex, subscriber] of eventData.subscriptions) {
|
||||
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
|
||||
toRemove.push(subscriber);
|
||||
eventData.subscriptions.delete(addressIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
filtersToMatch === undefined
|
||||
|| subscriber.filters === undefined
|
||||
|| subscriber.filters.some(f => filtersToMatch.includes(f))
|
||||
) {
|
||||
if (subscriber.filters.intersects(broadcastFilters)) {
|
||||
subscriber.link.sendTo(subscriber.dst, event);
|
||||
}
|
||||
}
|
||||
eventData.subscriptions = eventData.subscriptions.filter(subscriber => !toRemove.includes(subscriber));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,10 +307,9 @@ export class SubscriptionController {
|
||||
* @param address - Address to stop sending events to.
|
||||
*/
|
||||
unsubscribeAddress(address: Address) {
|
||||
for (let eventData of this._events.values()) {
|
||||
eventData.subscriptions = eventData.subscriptions.filter(
|
||||
subscriber => !subscriber.dst.addressedTo(address)
|
||||
);
|
||||
const addressIndex = address.addressIndex();
|
||||
for (const eventData of this._events.values()) {
|
||||
eventData.subscriptions.delete(addressIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,69 +319,76 @@ export class SubscriptionController {
|
||||
* @param link - Link to stop sending events to.
|
||||
*/
|
||||
unsubscribeLink(link: Link) {
|
||||
for (let eventData of this._events.values()) {
|
||||
eventData.subscriptions = eventData.subscriptions.filter(
|
||||
subscriber => subscriber.link !== link
|
||||
);
|
||||
for (const eventData of this._events.values()) {
|
||||
for (const [addressIndex, subscriber] of eventData.subscriptions) {
|
||||
if (subscriber.link === link) {
|
||||
eventData.subscriptions.delete(addressIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming subscription requests on a link
|
||||
* @param link - Link message was received on
|
||||
* @param event - incomming event.
|
||||
* @param request - incoming request.
|
||||
* @param src - Source address of incomming request.
|
||||
* @param dst - destination address of incomming request.
|
||||
*/
|
||||
async handleRequest(link: Link, event: SubscriptionRequest, src: Address, dst: Address) {
|
||||
if (!Link._eventsByName.has(event.eventName)) {
|
||||
throw new Error(`Event ${event.eventName} is not a registered event`);
|
||||
async handleRequest(link: Link, request: SubscriptionRequest, src: Address, dst: Address) {
|
||||
if (!Link._eventsByName.has(request.eventName)) {
|
||||
throw new Error(`Event ${request.eventName} is not a registered event`);
|
||||
}
|
||||
const eventData = this._events.get(event.eventName);
|
||||
const eventData = this._events.get(request.eventName);
|
||||
if (!eventData) {
|
||||
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
|
||||
throw new Error(`Event ${request.eventName} is not a registered as subscribable`);
|
||||
}
|
||||
if (event.subscribe === false) {
|
||||
// Unsubscribe logic
|
||||
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
if (!subscriber) {
|
||||
return false;
|
||||
}
|
||||
if (event.filters === undefined || subscriber.filters === undefined) {
|
||||
// Remove entire subscription for this address
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src));
|
||||
} else {
|
||||
const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters];
|
||||
subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f));
|
||||
if (subscriber.filters.length === 0) {
|
||||
// No filters left, remove subscription
|
||||
eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber);
|
||||
const addressIndex = src.addressIndex();
|
||||
const subscriber = eventData.subscriptions.get(addressIndex);
|
||||
switch (request.action) {
|
||||
case "unsubscribe":
|
||||
if (!subscriber) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subscribe logic
|
||||
let subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||
const newFilters = SubscriptionController.normalizeFilters(event.filters);
|
||||
if (!subscriber) {
|
||||
eventData.subscriptions.push({ link: link, dst: src, filters: newFilters });
|
||||
} else if (subscriber.filters === undefined || newFilters === undefined) {
|
||||
// Either was already subscribed to all or new request wants all
|
||||
subscriber.filters = undefined;
|
||||
} else {
|
||||
for (const f of newFilters) {
|
||||
if (!subscriber.filters.includes(f)) {
|
||||
subscriber.filters.push(f);
|
||||
subscriber.filters.subtract(request.filters);
|
||||
if (subscriber.filters.isEmpty()) {
|
||||
eventData.subscriptions.delete(addressIndex);
|
||||
}
|
||||
break;
|
||||
|
||||
case "subscribe":
|
||||
if (!subscriber) {
|
||||
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
|
||||
} else {
|
||||
subscriber.filters.union(request.filters);
|
||||
}
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
if (request.filters.isEmpty()) {
|
||||
if (!subscriber) {
|
||||
return false;
|
||||
}
|
||||
eventData.subscriptions.delete(addressIndex);
|
||||
} else if (!subscriber) {
|
||||
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
|
||||
} else {
|
||||
subscriber.filters = request.filters;
|
||||
}
|
||||
}
|
||||
if (eventData.subscriptionUpdate) {
|
||||
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
|
||||
if (eventReplay) {
|
||||
link.sendTo(src, eventReplay);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`unreachable case: ${String(request.action)}`);
|
||||
}
|
||||
|
||||
if (eventData.subscriptionUpdate) {
|
||||
const eventReplay = await eventData.subscriptionUpdate(request, src, dst);
|
||||
if (eventReplay) {
|
||||
link.sendTo(src, eventReplay);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -245,36 +399,29 @@ export interface SubscribableValue {
|
||||
isDeleted: boolean,
|
||||
}
|
||||
|
||||
export type EventSubscribable<T, V extends SubscribableValue> = Event<T> & Partial<{
|
||||
updates: V[],
|
||||
}>
|
||||
|
||||
/**
|
||||
* Component for subscribing to and tracking updates of a remote resource
|
||||
* Multiple handlers can be subscribed at the same time
|
||||
*/
|
||||
export class EventSubscriber<
|
||||
T extends EventSubscribable<T, V>,
|
||||
K extends string | number = NonNullable<T["updates"]>[number]["id"],
|
||||
V extends SubscribableValue = NonNullable<T["updates"]>[number],
|
||||
> {
|
||||
export class EventSubscriber<E, S = null> {
|
||||
/** The time at which an event was last received, is less than 0 when there have been no events */
|
||||
lastResponseTimeMs = -1;
|
||||
/** Values of the subscribed resource */
|
||||
values = new Map<K, V>();
|
||||
lastUpdatedMs = -1;
|
||||
/** True if this subscriber is currently synced with the source */
|
||||
synced = false;
|
||||
/** True if this subscriber is expecting to receive updates from the source */
|
||||
_syncing = false;
|
||||
/** Error value from last attempted subscription event */
|
||||
error = null as Error | null;
|
||||
/** Repeat calls to getSnapshot will return the same readonly copy unless values has updated */
|
||||
_snapshot: readonly [ReadonlyMap<K, Readonly<V>>, boolean] = [new Map<K, V>(), false];
|
||||
_snapshotLastUpdatedMs = -1;
|
||||
private _snapshot: readonly [S, boolean, Error | null] = [this.makeSnapshot(), false, null];
|
||||
private _snapshotLastUpdatedMs = -1;
|
||||
/** Callbacks will be called when an event is received or the synced state changes */
|
||||
_callbacks = new Array<EventSubscriberCallback<T>>();
|
||||
private _callbacks = new Array<EventSubscriberCallback<E>>();
|
||||
/** Filters applied to the filtered handler, if empty the filtered handler will not be called */
|
||||
private _filters = SubscriptionFilters.empty();
|
||||
|
||||
constructor(
|
||||
private Event: EventClass<T>,
|
||||
protected Event: EventClass<E>,
|
||||
public control: Link,
|
||||
protected filteredHandler?: EventSubscriberCallback<E>,
|
||||
) {
|
||||
control.handle(Event, this._handleEvent.bind(this));
|
||||
// The events below exist only on websocket connectors, we can't early return because of test mocking
|
||||
@@ -294,12 +441,10 @@ export class EventSubscriber<
|
||||
*/
|
||||
handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
|
||||
if (event === "connect" || event === "resume") {
|
||||
this._updateSubscription();
|
||||
this._sendRequest("replace", this._callbacks.length > 0 ? SubscriptionFilters.all() : this._filters);
|
||||
} else if (this.synced) {
|
||||
this.synced = false;
|
||||
for (let callback of this._callbacks) {
|
||||
callback(null, false);
|
||||
}
|
||||
this._notify(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,33 +453,35 @@ export class EventSubscriber<
|
||||
* @param event - event from subscribed resource
|
||||
* @internal
|
||||
*/
|
||||
async _handleEvent(event: T) {
|
||||
// We support the automatic maintaining of a map for events with the updates property
|
||||
const updates = event.updates ?? [];
|
||||
for (const value of updates) {
|
||||
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
|
||||
// Warn about updates which changes content but don't update updatedAtMs
|
||||
const existing = this.values.get(value.id as K);
|
||||
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
|
||||
logger.warn(
|
||||
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
|
||||
"updatedAtMs timestamp but differing content"
|
||||
);
|
||||
}
|
||||
if (value.isDeleted) {
|
||||
this.values.delete(value.id as K);
|
||||
} else {
|
||||
this.values.set(value.id as K, value);
|
||||
}
|
||||
}
|
||||
// Event handling logic
|
||||
if (this._syncing && !this.synced) {
|
||||
this._snapshotLastUpdatedMs = -1;
|
||||
this.synced = true;
|
||||
private async _handleEvent(event: E) {
|
||||
const eventTimeMs = this.getLastUpdatedTimeMs(event);
|
||||
if (eventTimeMs > this.lastUpdatedMs) {
|
||||
this.lastUpdatedMs = eventTimeMs;
|
||||
}
|
||||
this.synced = this._hasSubscriptions();
|
||||
this.processEvent(event);
|
||||
this._notify(event);
|
||||
}
|
||||
|
||||
private _notify(event: E | null) {
|
||||
for (const callback of this._callbacks) {
|
||||
callback(event, this.synced);
|
||||
}
|
||||
if (this.filteredHandler && !this._filters.isEmpty()) {
|
||||
this.filteredHandler(event, this.synced);
|
||||
}
|
||||
}
|
||||
|
||||
protected getLastUpdatedTimeMs(event: E) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
protected processEvent(event: E) {
|
||||
// Noop for EventSubscriber
|
||||
}
|
||||
|
||||
protected makeSnapshot(): S {
|
||||
return null as S;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,10 +491,10 @@ export class EventSubscriber<
|
||||
* synced property changes, in which case the event will be null.
|
||||
* @returns function that will unsubscribe from notifications
|
||||
*/
|
||||
subscribe(handler: EventSubscriberCallback<T>) {
|
||||
subscribe(handler: EventSubscriberCallback<E>) {
|
||||
this._callbacks.push(handler);
|
||||
if (this._callbacks.length === 1) {
|
||||
this._updateSubscription();
|
||||
this._sendRequest("subscribe", SubscriptionFilters.all());
|
||||
}
|
||||
return () => {
|
||||
// During a page transition the components currently rendered
|
||||
@@ -368,52 +515,153 @@ export class EventSubscriber<
|
||||
}
|
||||
this._callbacks.splice(index, 1);
|
||||
if (this._callbacks.length === 0) {
|
||||
this._updateSubscription();
|
||||
this._sendRequest("replace", this._filters);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns true if all filters are present */
|
||||
hasFilters(filters: string | string[] | SubscriptionFilters) {
|
||||
if (!(filters instanceof SubscriptionFilters)) {
|
||||
filters = SubscriptionFilters.fromShorthand(filters);
|
||||
}
|
||||
return this._filters.extends(filters);
|
||||
}
|
||||
|
||||
/** Add filters to the filtered handler */
|
||||
addFilters(filters: string | string[] | SubscriptionFilters) {
|
||||
if (!(filters instanceof SubscriptionFilters)) {
|
||||
filters = SubscriptionFilters.fromShorthand(filters);
|
||||
}
|
||||
this._filters.union(filters);
|
||||
this._sendRequest("subscribe", filters);
|
||||
}
|
||||
|
||||
/** Remove filters from the filtered handler */
|
||||
removeFilters(filters: string | string[] | SubscriptionFilters) {
|
||||
if (!(filters instanceof SubscriptionFilters)) {
|
||||
filters = SubscriptionFilters.fromShorthand(filters);
|
||||
}
|
||||
this._filters.subtract(filters);
|
||||
this._sendRequest("unsubscribe", filters);
|
||||
}
|
||||
|
||||
/** Clear all filters from the filtered handler, it will no longer be called */
|
||||
clearFilters() {
|
||||
this._filters = SubscriptionFilters.empty();
|
||||
if (!this._hasSubscriptions()) {
|
||||
this._sendRequest("unsubscribe", SubscriptionFilters.all());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a snapshot of the current state of the tracked resource
|
||||
* @returns tuple of values map snapshot and synced property.
|
||||
*/
|
||||
getSnapshot() {
|
||||
if (this._snapshotLastUpdatedMs !== this.lastResponseTimeMs) {
|
||||
this._snapshotLastUpdatedMs = this.lastResponseTimeMs;
|
||||
this._snapshot = [new Map(this.values), this.synced];
|
||||
getSnapshot(): readonly [S, boolean, Error | null] {
|
||||
if (
|
||||
this._snapshotLastUpdatedMs !== this.lastUpdatedMs
|
||||
|| this._snapshot[1] !== this.synced
|
||||
|| this._snapshot[2] !== this.error
|
||||
) {
|
||||
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
|
||||
this._snapshot = [this.makeSnapshot(), this.synced, this.error];
|
||||
}
|
||||
return this._snapshot!;
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
/** Returns true if there are any active subscription callbacks */
|
||||
private _hasSubscriptions() {
|
||||
return this._callbacks.length > 0 || !this._filters.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the subscription with the controller based on current handler counts
|
||||
*/
|
||||
async _updateSubscription() {
|
||||
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
|
||||
if (!this.control.connector.valid) {
|
||||
return;
|
||||
}
|
||||
const entry = Link._eventsByClass.get(this.Event)!;
|
||||
|
||||
const entry = Link._eventsByClass.get(this.Event)!;
|
||||
try {
|
||||
this._syncing = this._callbacks.length > 0;
|
||||
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
|
||||
entry.name,
|
||||
this._syncing,
|
||||
this.lastResponseTimeMs
|
||||
entry.name, action, this.lastUpdatedMs, filters
|
||||
));
|
||||
this.error = null;
|
||||
if (!updatesSent) {
|
||||
this.synced = this._syncing;
|
||||
for (const callback of this._callbacks) {
|
||||
callback(null, this.synced);
|
||||
}
|
||||
this.synced = this._hasSubscriptions();
|
||||
this._notify(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
|
||||
logger.error(
|
||||
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
|
||||
);
|
||||
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
this.error = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ValueSubscriber<
|
||||
E extends { value: V },
|
||||
V extends SubscribableValue = E["value"],
|
||||
> extends EventSubscriber<E, Readonly<V> | null> {
|
||||
/** Current value of the subscribed resource */
|
||||
value: V | null = null;
|
||||
|
||||
protected override getLastUpdatedTimeMs(event: E) {
|
||||
return event.value.updatedAtMs;
|
||||
}
|
||||
|
||||
protected override processEvent(event: E) {
|
||||
this.value = event.value;
|
||||
}
|
||||
|
||||
protected override makeSnapshot() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export class MapSubscriber<
|
||||
E extends { updates: V[] },
|
||||
K extends string | number = E["updates"][number]["id"],
|
||||
V extends SubscribableValue = E["updates"][number],
|
||||
> extends EventSubscriber<E, ReadonlyMap<K, Readonly<V>>> {
|
||||
/** Current value of the subscribed resource */
|
||||
values = new Map<K, V>();
|
||||
|
||||
protected override getLastUpdatedTimeMs(event: E) {
|
||||
let maxTime = this.lastUpdatedMs;
|
||||
for (const update of event.updates) {
|
||||
if (update.updatedAtMs > maxTime) {
|
||||
maxTime = update.updatedAtMs;
|
||||
}
|
||||
}
|
||||
return maxTime;
|
||||
}
|
||||
|
||||
protected override processEvent(event: E) {
|
||||
for (const value of event.updates) {
|
||||
// Warn about updates which changes content but don't update updatedAtMs
|
||||
const existing = this.values.get(value.id as K);
|
||||
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
|
||||
logger.warn(
|
||||
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
|
||||
"updatedAtMs timestamp but differing content"
|
||||
);
|
||||
}
|
||||
if (value.isDeleted) {
|
||||
this.values.delete(value.id as K);
|
||||
} else {
|
||||
this.values.set(value.id as K, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override makeSnapshot() {
|
||||
return new Map(this.values);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
@@ -145,17 +132,14 @@ export async function gatherSystemInfo(
|
||||
const currentIdleCpuMs = cpus.map(({ times }) => times.idle);
|
||||
const deltaTotalCpuMs = minZip(previousTotalCpuMs, currentTotalCpuMs).map(([prev, curr]) => curr - prev);
|
||||
const deltaIdleCpuMs = minZip(previousIdleCpuMs, currentIdleCpuMs).map(([prev, curr]) => curr - prev);
|
||||
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(([total, idle]) => (total - idle) / total);
|
||||
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(
|
||||
([total, idle]) => (total <= 0 ? 0 : (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",
|
||||
|
||||
Vendored
+10
@@ -3,6 +3,16 @@ declare module "*.png" {
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.md" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare const webRoot: string;
|
||||
declare const staticRoot: string;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.27",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "^0.30.4",
|
||||
"@swc/core": "^1.13.3",
|
||||
"antd": "^5.24.2",
|
||||
@@ -37,6 +37,7 @@
|
||||
"rc-field-form": "^1.37.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router-dom": "^6.29.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^3.3.3",
|
||||
|
||||
@@ -9,8 +9,7 @@ import App from "./components/App";
|
||||
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
|
||||
import InputRole from "./components/InputRole";
|
||||
import InputModPack from "./components/InputModPack";
|
||||
import InputTargetVersion from "./components/InputTargetVersion";
|
||||
import InputPartialVersion from "./components/InputPartialVersion";
|
||||
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
|
||||
import { Control, ControlConnector } from "./util/websocket";
|
||||
|
||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||
@@ -111,6 +110,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
|
||||
|
||||
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
|
||||
const inputComponents: Record<string, InputComponent> = {
|
||||
"full_version": InputFullVersion,
|
||||
"partial_version": InputPartialVersion,
|
||||
"target_version": InputTargetVersion,
|
||||
"mod_pack": InputModPack,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Button, Space, Typography, message, Row, Col, Card } from "antd";
|
||||
import { ReloadOutlined, CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { PluginWebApi } from "@clusterio/lib";
|
||||
|
||||
import webUiPackage from "../../package.json";
|
||||
import DiscordLogo from "../images/discord.svg";
|
||||
import GitHubLogo from "../images/github.svg";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useHosts } from "../model/host";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useUsers } from "../model/user";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatOS(os?: string) {
|
||||
switch (os) {
|
||||
case "win32": return "Windows";
|
||||
case "darwin": return "macOS";
|
||||
case "linux": return "Linux";
|
||||
default: return os;
|
||||
}
|
||||
}
|
||||
|
||||
export default function AboutModal({ open, onClose, onOpenChangelog }: any) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [hosts] = useHosts();
|
||||
const [instances] = useInstances();
|
||||
const [users] = useUsers();
|
||||
|
||||
const [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
if (response.ok) {
|
||||
setPluginList(await response.json());
|
||||
}
|
||||
})();
|
||||
}, [webRoot]);
|
||||
|
||||
const browserInfo = useMemo(() => ({
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
}), []);
|
||||
|
||||
const debugText = useMemo(() => {
|
||||
const permissions = new Set(
|
||||
account.roles.flatMap(role => role.permissions)
|
||||
);
|
||||
|
||||
const date = new Date();
|
||||
const lines: string[] = [
|
||||
"Clusterio Debug Info",
|
||||
`- Timestamp: ${date.toISOString()}`,
|
||||
`- Local Time: ${date.toString()}`,
|
||||
"",
|
||||
|
||||
"Build",
|
||||
`- Version: ${webUiPackage.version}`,
|
||||
`- Build Date: ${process.env.BUILD_DATE}`,
|
||||
`- Commit Hash: ${process.env.COMMIT_HASH}`,
|
||||
`- Environment: ${process.env.BUILD_ENV}`,
|
||||
`- System: ${process.env.BUILD_OS}`,
|
||||
"",
|
||||
|
||||
"Runtime",
|
||||
`- Platform: ${browserInfo.platform}`,
|
||||
`- User Agent: ${browserInfo.userAgent}`,
|
||||
`- Screen: ${window.screen.width}x${window.screen.height}`,
|
||||
`- Viewport: ${window.innerWidth}x${window.innerHeight}`,
|
||||
"",
|
||||
|
||||
"Cluster",
|
||||
`- Url: ${new URL(webRoot, window.location.origin)}`,
|
||||
`- Hosts: ${hosts.size}`,
|
||||
`- Instances: ${instances.size}`,
|
||||
`- Plugins: ${pluginList.length}`,
|
||||
`- Users: ${users.size}`,
|
||||
"",
|
||||
|
||||
"Account",
|
||||
`- Name: ${account.name}`,
|
||||
`- Roles: ${account.roles.map(r => r.name).sort().join(", ") || "None"}`,
|
||||
`- Permissions: ${permissions.size}`,
|
||||
"",
|
||||
|
||||
"Permission List",
|
||||
...[...permissions].sort().map(p => `- ${p}`),
|
||||
"",
|
||||
];
|
||||
|
||||
lines.push("Plugin List");
|
||||
for (let plugin of pluginList) {
|
||||
lines.push(`- ${plugin.name}`);
|
||||
lines.push(` Version: ${plugin.version ?? "unknown"}`);
|
||||
lines.push(` Loaded: ${plugin.loaded ? "yes" : "no"}`);
|
||||
lines.push(` Controller: ${plugin.enabled ? "enabled" : "disabled"}`);
|
||||
lines.push(` Web: ${control.plugins.get(plugin.name) ? "enabled" : "disabled"}`);
|
||||
if (plugin.npmPackage) {
|
||||
lines.push(` Package: ${plugin.npmPackage}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}, [account, hosts, instances, users, pluginList, browserInfo]);
|
||||
|
||||
async function copyDebug() {
|
||||
await navigator.clipboard.writeText(debugText);
|
||||
message.success("Copied debug info");
|
||||
}
|
||||
|
||||
function forceRefresh() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return <Modal open={open} onCancel={onClose} footer={null} title="About Clusterio" width={800}>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="Build Info">
|
||||
<Space direction="vertical">
|
||||
<Text>Version: {`${webUiPackage.version} (${process.env.COMMIT_HASH})`}</Text>
|
||||
<Text>Date: {new Date(process.env.BUILD_DATE ?? "").toLocaleString()}</Text>
|
||||
<Text>Environment: {process.env.BUILD_ENV}</Text>
|
||||
<Text>System: {formatOS(process.env.BUILD_OS)}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="Cluster Info">
|
||||
<Space direction="vertical">
|
||||
<Text>Hosts: {hosts.size}</Text>
|
||||
<Text>Instances: {instances.size}</Text>
|
||||
<Text>Plugins: {pluginList.length}</Text>
|
||||
<Text>Users: {users.size}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }} wrap>
|
||||
<Space wrap>
|
||||
<Button icon={<InfoCircleOutlined />} onClick={onOpenChangelog}>
|
||||
Changelog
|
||||
</Button>
|
||||
<Button
|
||||
target="_blank"
|
||||
href="https://github.com/clusterio/clusterio/issues"
|
||||
icon={<span style={{ display: "flex", alignItems: "center" }}>
|
||||
<img src={GitHubLogo} style={{ width: 16 }} />
|
||||
</span>}
|
||||
>
|
||||
GitHub Issues
|
||||
</Button>
|
||||
<Button
|
||||
target="_blank"
|
||||
href="https://discord.gg/mzAsgnm"
|
||||
icon={<span style={{ display: "flex", alignItems: "center" }}>
|
||||
<img src={DiscordLogo} style={{ width: 16}} />
|
||||
</span>}
|
||||
>
|
||||
Support Server
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={forceRefresh}>
|
||||
Reload
|
||||
</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={copyDebug} type="primary">
|
||||
Copy Debug
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
|
||||
</Modal>;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) {
|
||||
confirmLoading={applying}
|
||||
onOk={handleAssign}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph style={{ maxWidth: "30em" }}>
|
||||
Select a Host to assign this instance to. Assignment
|
||||
|
||||
@@ -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 notify 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) {
|
||||
notify(err.message, "error");
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { Modal } from "antd";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import changelog from "../../../../CHANGELOG.md";
|
||||
|
||||
const cleanedChangelog = changelog
|
||||
.replace(/<!--[\s\S]*?-->/g, "") // remove comments
|
||||
.replace(/^#\s+Changelog\s*\n?/i, ""); // remove top-level title
|
||||
|
||||
/**
|
||||
* Displays a modal element which contains the change log
|
||||
*/
|
||||
export default function ChangeLogModal({ open, onClose }: any) {
|
||||
return <Modal
|
||||
title="Changelog"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<ReactMarkdown>
|
||||
{cleanedChangelog}
|
||||
</ReactMarkdown>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) {
|
||||
confirmLoading={creatingSave}
|
||||
onOk={createSave}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="saveName" label="Save Name">
|
||||
|
||||
@@ -2,6 +2,9 @@ import React from "react";
|
||||
import { Button, Card, Flex, Image, Typography } from "antd";
|
||||
|
||||
import example from "../images/console-error-edge.png";
|
||||
import DiscordLogo from "../images/discord.svg";
|
||||
import GitHubLogo from "../images/github.svg";
|
||||
|
||||
import PageLayout from "./PageLayout";
|
||||
import type { ErrorProps } from "./App";
|
||||
|
||||
@@ -38,12 +41,12 @@ export default function ErrorPage(props: ErrorProps & { throw?: boolean }) {
|
||||
<SupportLinkCard
|
||||
name="Github Issues"
|
||||
href="https://github.com/clusterio/clusterio/issues"
|
||||
image="https://github.githubassets.com/favicons/favicon-dark.svg"
|
||||
image={GitHubLogo}
|
||||
/>
|
||||
<SupportLinkCard
|
||||
name="Support Server"
|
||||
href="https://discord.gg/mzAsgnm"
|
||||
image="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
|
||||
image={DiscordLogo}
|
||||
/>
|
||||
</Flex>
|
||||
<br/>
|
||||
|
||||
@@ -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.sendTo({ hostId: props.id }, new lib.HostConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
ConfigClass={lib.HostConfig}
|
||||
retrieveConfig={retrieveConfig}
|
||||
setField={setField}
|
||||
setProp={setProp}
|
||||
setConfig={setConfig}
|
||||
id={props.id}
|
||||
available={props.available}
|
||||
/>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useContext, useRef, useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
|
||||
@@ -20,6 +19,10 @@ import {
|
||||
import { useHosts } from "../model/host";
|
||||
import { useSystems } from "../model/system";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import useTableQueryState from "../util/useTableQueryState";
|
||||
import useColumnSearch from "../util/useColumnSearch";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
@@ -199,12 +202,16 @@ function CopyButton({ text, message }: { text:string, message:string }) {
|
||||
|
||||
export default function HostsPage() {
|
||||
let account = useAccount();
|
||||
let navigate = useNavigate();
|
||||
let [hosts] = useHosts();
|
||||
const [systems] = useSystems();
|
||||
const [showRatios, setShowRatios] = useState(true);
|
||||
const [showNumbers, setShowNumbers] = useState(false);
|
||||
const [showCpuModel, setShowCpuModel] = useState(false);
|
||||
const tableState = useTableQueryState<lib.HostDetails>({
|
||||
namespace: "host", defaultSortKey: "name",
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
|
||||
const rowNav = useRowNavigation();
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
<PageHeader
|
||||
@@ -217,13 +224,20 @@ export default function HostsPage() {
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
defaultSortOrder: "ascend",
|
||||
sorter: (a, b) => strcmp(a.name, b.name),
|
||||
sortOrder: tableState.sortOrder("name"),
|
||||
filteredValue: tableState.filteredValue("name"),
|
||||
className: "table-link-cell",
|
||||
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
|
||||
{host.name}
|
||||
</Link>,
|
||||
...nameSearch,
|
||||
},
|
||||
{
|
||||
title: "CPU Model",
|
||||
dataIndex: "cpuModel",
|
||||
sorter: (a, b) => strcmp(systems.get(a.id)?.cpuModel ?? "", systems.get(b.id)?.cpuModel ?? ""),
|
||||
sortOrder: tableState.sortOrder("cpuModel"),
|
||||
render: (_, host) => systems.get(host.id)?.cpuModel,
|
||||
hidden: !showCpuModel,
|
||||
},
|
||||
@@ -231,6 +245,7 @@ export default function HostsPage() {
|
||||
title: "CPU%",
|
||||
key: "cpuPct",
|
||||
sorter: (a, b) => (systems.get(a.id)?.cpuRatio ?? 0) - (systems.get(b.id)?.cpuRatio ?? 0),
|
||||
sortOrder: tableState.sortOrder("cpuPct"),
|
||||
render: (_, host) => <MetricCpuRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -240,6 +255,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.cpuUsed ?? 0) - (systems.get(b.id)?.cpuUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("cores"),
|
||||
render: (_, host) => <MetricCpuUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -249,6 +265,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.memoryRatio ?? 0) - (systems.get(b.id)?.memoryRatio ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("memPct"),
|
||||
render: (_, host) => <MetricMemoryRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -258,6 +275,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.memoryUsed ?? 0) - (systems.get(b.id)?.memoryUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("memory"),
|
||||
render: (_, host) => <MetricMemoryUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -265,6 +283,7 @@ export default function HostsPage() {
|
||||
title: "Disk%",
|
||||
key: "diskPct",
|
||||
sorter: (a, b) => (systems.get(a.id)?.diskAvailable ?? 0) - (systems.get(b.id)?.diskAvailable ?? 0),
|
||||
sortOrder: tableState.sortOrder("diskPct"),
|
||||
render: (_, host) => <MetricDiskRatio system={systems.get(host.id)} />,
|
||||
hidden: !showRatios,
|
||||
},
|
||||
@@ -274,6 +293,7 @@ export default function HostsPage() {
|
||||
sorter: (a, b) => (
|
||||
(systems.get(a.id)?.diskUsed ?? 0) - (systems.get(b.id)?.diskUsed ?? 0)
|
||||
),
|
||||
sortOrder: tableState.sortOrder("disk"),
|
||||
render: (_, host) => <MetricDiskUsed system={systems.get(host.id)} />,
|
||||
hidden: !showNumbers,
|
||||
},
|
||||
@@ -287,11 +307,13 @@ export default function HostsPage() {
|
||||
</Tooltip>}
|
||||
</Space>,
|
||||
sorter: (a, b) => strcmp(a.version, b.version),
|
||||
sortOrder: tableState.sortOrder("version"),
|
||||
},
|
||||
{
|
||||
title: "Public address",
|
||||
dataIndex: "publicAddress",
|
||||
sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""),
|
||||
sortOrder: tableState.sortOrder("publicAddress"),
|
||||
},
|
||||
{
|
||||
title: "Connected",
|
||||
@@ -301,16 +323,14 @@ export default function HostsPage() {
|
||||
<RestartRequired system={systems.get(host.id)}/>
|
||||
</>,
|
||||
sorter: (a, b) => Number(a.connected) - Number(b.connected),
|
||||
sortOrder: tableState.sortOrder("connected"),
|
||||
},
|
||||
]}
|
||||
dataSource={[...hosts.values()]}
|
||||
rowKey={host => host.id}
|
||||
pagination={false}
|
||||
onRow={(record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
navigate(`/hosts/${record.id}/view`);
|
||||
},
|
||||
})}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
onRow={record => rowNav(`/hosts/${record.id}/view`)}
|
||||
/>
|
||||
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
|
||||
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Select } from "antd";
|
||||
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useInstances } from "../model/instance";
|
||||
|
||||
interface BaseInputInstanceProps extends InputComponentProps {
|
||||
includeAll?: boolean;
|
||||
}
|
||||
|
||||
function BaseInputInstance(props: BaseInputInstanceProps) {
|
||||
const [instances, synced] = useInstances();
|
||||
|
||||
const options = [
|
||||
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
|
||||
...[...instances.values()].map(instance => ({
|
||||
label: instance.name ?? `Instance ${instance.id}`,
|
||||
value: instance.id,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={value => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
options={options}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputInstanceWithAll(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} includeAll />;
|
||||
}
|
||||
|
||||
export function InputInstance(props: InputComponentProps) {
|
||||
return <BaseInputInstance {...props} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useModPacks } from "../model/mod_pack";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
|
||||
export default function InputModPack(props: InputComponentProps) {
|
||||
const [modPacks] = useModPacks();
|
||||
const [modPacks, synced] = useModPacks();
|
||||
return <Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
@@ -17,6 +17,7 @@ export default function InputModPack(props: InputComponentProps) {
|
||||
value: modPack.id,
|
||||
}))}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={props.disabled}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
export default function InputPartialVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useRoles } from "../model/roles";
|
||||
|
||||
export default function InputRole(props: InputComponentProps) {
|
||||
const [roles] = useRoles();
|
||||
const [roles, synced] = useRoles();
|
||||
|
||||
return <Select
|
||||
showSearch
|
||||
@@ -17,6 +17,7 @@ export default function InputRole(props: InputComponentProps) {
|
||||
value: role.id,
|
||||
}))}
|
||||
allowClear={props.fieldDefinition.optional}
|
||||
disabled={props.disabled}
|
||||
disabled={!synced || props.disabled}
|
||||
loading={!synced}
|
||||
/>;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
export default function InputTargetVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" option for TargetVersion
|
||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||
import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
function InputVersion<
|
||||
Version extends lib.TargetVersion,
|
||||
IncludeLatest extends (Version extends "latest" ? boolean : false),
|
||||
> (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
version: {
|
||||
isValid: (v: string) => v is Version;
|
||||
includeLatest: IncludeLatest;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
|
||||
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
|
||||
const [customVersion, setCustomVersion] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const selectRef = useRef<RefSelectProps>(null);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Release channels (e.g. stable, experimental) only apply to target versions
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) {
|
||||
const res = await control.latestReleases.get(5 * 60 * 1000);
|
||||
// Read the headless build's version directly rather than via a lib
|
||||
// helper: lib is a federation-shared module here and unused exports
|
||||
// can be tree-shaken out of it.
|
||||
setChannels(Object.entries(res).map(([name, builds]) => (
|
||||
{ name, version: builds.headless ?? Object.values(builds)[0] ?? "" }
|
||||
)));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
// Skip a patch equal to the group key (a bare major.minor version,
|
||||
// e.g. the ApiVersions fallback): the selectable group node already
|
||||
// represents it, and a duplicate value warns in the TreeSelect.
|
||||
children: patchVersions.filter((v) => v !== majorMinor).map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" and release channel options for TargetVersion
|
||||
if (props.version.includeLatest) {
|
||||
tree.unshift(
|
||||
{ title: "latest", value: "latest", key: "latest", children: [] },
|
||||
...channels.map(({ name, version }) => ({
|
||||
title: version ? `${name} (${version})` : name,
|
||||
value: name,
|
||||
key: name,
|
||||
children: [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
ref={selectRef}
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
open={open}
|
||||
onOpenChange={newOpen => {
|
||||
setOpen(newOpen);
|
||||
if (newOpen && typeof props.value === "string") {
|
||||
setCustomVersion(props.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (open && e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
popupRender={(menu) => {
|
||||
const isValid = props.version.isValid(customVersion);
|
||||
|
||||
const submit = () => {
|
||||
if (customVersion && isValid) {
|
||||
setTimeout(() => selectRef.current?.focus(), 0);
|
||||
props.onChange(customVersion);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<div style={{ display: "flex", gap: 8, padding: "0 8px 4px" }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Custom X.Y.Z"
|
||||
value={customVersion}
|
||||
status={customVersion && !isValid ? "error" : undefined}
|
||||
onChange={(e) => setCustomVersion(e.target.value)}
|
||||
onPressEnter={submit}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!customVersion || !isValid}
|
||||
onClick={submit}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputTargetVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isTargetVersion,
|
||||
includeLatest: true,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputPartialVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isPartialVersion,
|
||||
includeLatest: false,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputFullVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isFullVersion,
|
||||
includeLatest: false,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [switching, setSwitching] = useState<boolean>(false);
|
||||
const [popconfirm, setPopconfirm] = useState<string | null>(null);
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
@@ -88,35 +89,43 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
if (account.hasPermission("core.instance.kill")) {
|
||||
actions.push({
|
||||
key: "kill",
|
||||
label: "Kill",
|
||||
danger: true,
|
||||
disabled: instanceStatus === "stopped",
|
||||
label: (<Popconfirm
|
||||
title={<>
|
||||
Killing this instance may leave you save and player data corrupted.
|
||||
Are you sure you want to kill the instance?
|
||||
</>}
|
||||
placement="bottomRight"
|
||||
okText="Kill"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error killing instance")
|
||||
).finally(actionFinish);
|
||||
}}
|
||||
>
|
||||
Kill
|
||||
</Popconfirm>),
|
||||
onClick: () => setPopconfirm("kill"),
|
||||
});
|
||||
}
|
||||
|
||||
return <VariableDropdownButton
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>;
|
||||
return <div style={{ position: "relative", display: "inline-block" }}>
|
||||
<VariableDropdownButton
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={<>
|
||||
Killing this instance may leave you save and player data corrupted.
|
||||
Are you sure you want to kill the instance?
|
||||
</>}
|
||||
open={popconfirm === "kill"}
|
||||
placement="bottomRight"
|
||||
okText="Kill"
|
||||
okButtonProps={{ danger: true }}
|
||||
onCancel={() => setPopconfirm(null)}
|
||||
onConfirm={() => {
|
||||
control.sendTo(
|
||||
{ instanceId },
|
||||
new lib.InstanceKillRequest(),
|
||||
).catch(
|
||||
notifyErrorHandler("Error killing instance")
|
||||
).finally(actionFinish);
|
||||
setPopconfirm(null);
|
||||
}}
|
||||
>
|
||||
{/* Used to position the popup under the button */}
|
||||
<span style={{ position: "absolute", right: 0, top: "100%", width: 1, height: 1 }} />
|
||||
</Popconfirm>
|
||||
</div>;
|
||||
}
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user