Compare commits

...
Author SHA1 Message Date
Cooldude2606 eb77ca566c 2.0.0-alpha.26 2026-06-23 00:39:47 +01:00
Cooldude2606 9e727f9a13 Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
2026-06-13 15:18:22 +01:00
Cooldude2606 d35e8caa2b Merge pull request #914 from Cooldude2606/feature/view-changelog
Add viewable changelog
2026-06-12 16:14:19 +01:00
Cooldude2606 b9c073e26f Fix typo in ci publish workflow 2026-06-12 16:08:37 +01:00
Cooldude2606 b160fecac4 Make icons local to package 2026-06-12 16:04:39 +01:00
Cooldude2606 dab727f90f Fix import in AboutModal 2026-06-10 22:39:26 +01:00
Cooldude2606 0e36df8ae3 Add AboutModal 2026-06-10 22:35:00 +01:00
Cooldude2606 f7d3516c24 Remove internal div from changelog 2026-06-10 20:18:05 +01:00
Cooldude2606 e6665d0803 Fix incorrect use of path.parse within create template (#919) 2026-06-10 20:01:53 +01:00
Cooldude2606 6fd4654c21 Merge pull request #913 from Cooldude2606/refactor/subscriptions-2
Refactor subscription handling and add new filter classes
2026-06-02 22:51:13 +01:00
Cooldude2606 b5c99fad2a Fix incorrect doc comment 2026-06-02 22:50:41 +01:00
Cooldude2606 f588f7f8d4 Guard against div0 in cpuUsage (#918) 2026-06-02 22:49:45 +01:00
Cooldude2606 4fe83cbcbc Add duration filter to first seen column 2026-06-01 23:12:47 +01:00
Cooldude2606 069a050f39 Unify column for instance and no instance 2026-06-01 23:04:57 +01:00
Cooldude2606 577615803b Add loading={!synced} to table and inputs 2026-06-01 22:45:04 +01:00
Cooldude2606 680ade5975 Add instance filter to users page 2026-06-01 22:44:13 +01:00
Cooldude2606 6914645c19 Make Username reusable and fix active icon issue 2026-06-01 21:58:27 +01:00
Cooldude2606 1d0b6b9a62 Add user status filters 2026-06-01 21:47:33 +01:00
Cooldude2606 dd80d49989 Add viewable changelog 2026-05-31 03:52:42 +01:00
Cooldude2606 e3d5f434a4 Better toString and test coverage 2026-05-29 20:14:23 +01:00
Cooldude2606 11b8a0ff97 Fix bad snapshot initialisation 2026-05-29 19:15:27 +01:00
Cooldude2606 e54b292fce Fix tests relating to permissions 2026-05-29 18:38:07 +01:00
Cooldude2606 41222343e3 Add tests 2026-05-29 17:45:14 +01:00
Cooldude2606 4676f1c820 Add filteredHandler to EventSubscriber 2026-05-29 14:09:19 +01:00
Cooldude2606 e49b82842d Add ValueSubscriber and MapSubscriber 2026-05-28 01:12:05 +01:00
Cooldude2606 e0a39e5630 Add option for events to define get filters 2026-05-28 01:11:38 +01:00
Cooldude2606 21b050b220 Call subscriptionUpdate for all update cases 2026-05-28 00:38:15 +01:00
Cooldude2606 9bed1223fd Add option to replace filters 2026-05-28 00:33:45 +01:00
Cooldude2606 4735f0725d Add SubscriptionFilters class 2026-05-28 00:25:29 +01:00
Cooldude2606 1f27617a27 Use addressIndex and a map for subscriptions 2026-05-27 23:35:31 +01:00
Cooldude2606 ee460d677d Rename libAddress.index to requestIndex and add addressIndex 2026-05-27 23:23:29 +01:00
Cooldude2606 f2ca465ec6 Merge pull request #909 from Cooldude2606/fix/small-things
Multiple small changes
2026-05-14 13:08:06 +01:00
Cooldude2606 40e6961711 Fix conflict between defaultFilteredValue and responsive 2026-05-14 12:51:04 +01:00
Cooldude2606 1bb51988ac Fix popconfirm on instance kill button 2026-04-25 23:32:30 +01:00
Cooldude2606 2c30fa4a94 Add user section to changelog script 2026-04-25 22:38:23 +01:00
Cooldude2606 a22186ca01 Add a newline after each section title in change log 2026-04-25 22:19:49 +01:00
Cooldude2606 746b11ff65 Add scrollable div to player auth server list 2026-04-25 22:18:32 +01:00
Cooldude2606 de51741835 Use workspace:^ rather than workspace:* 2026-04-25 22:06:36 +01:00
Cooldude2606 a633c42904 Fix default filter for online users table 2026-04-25 22:03:02 +01:00
Cooldude2606 7522424517 2.0.0-alpha.25 2026-04-24 17:41:16 +01:00
Cooldude2606 57f15d7ba4 Merge pull request #894 from Cooldude2606/fix/892
Fix accessing main inventory when in map view
2026-04-24 11:30:56 +01:00
Cooldude2606 1af672aa1d Always exit remote view on sync 2026-04-23 17:25:13 +01:00
Cooldude2606 41f8f493aa Fix nil table key error in factorio 1.1 2026-04-23 00:19:20 +01:00
Cooldude2606 8be95c140a Fix edge cases with physical location and controller 2026-04-22 22:37:42 +01:00
Cooldude2606 add0088704 Switch to xpcall for better trace backs 2026-04-22 00:49:45 +01:00
Cooldude2606 037f06808e Support remote controller type within deserialize 2026-04-22 00:45:31 +01:00
Daniel 01456818a8 Merge pull request #897 from Cooldude2606/feature/custom-version-input
Allow custom versions to be selected as a target
2026-04-20 22:26:11 +02:00
Cooldude2606 5c3182fb56 Merge pull request #902 from Cooldude2606/feature/improved-role-page
Improved RoleViewPage
2026-04-19 22:52:19 +01:00
Cooldude2606 f515b2db80 Disable the select all and clear buttons using canUpdate 2026-04-19 22:47:05 +01:00
Daniel eb2c2b5aec Merge pull request #901 from Cooldude2606/fix/instance-config-update
Add missing instanceConfigUpdated to InstanceConfigSetRequest
2026-04-19 22:48:22 +02:00
Cooldude2606 6f181f357b Change colour of groups to match input colour 2026-04-19 16:53:27 +01:00
Cooldude2606 1bcc152368 Improved RoleViewPage
Add groups, search, better description format, and default / dangerous icons.
2026-04-19 03:28:09 +01:00
Cooldude2606 26df9d2caa Add missing instanceConfigUpdated to InstanceConfigSetRequest 2026-04-18 22:05:05 +01:00
Cooldude2606 45adb1e88c Merge pull request #896 from Cooldude2606/feature/ctl-double-interrupt
Add double interrupt logic to ctl to force exit
2026-04-15 22:22:58 +01:00
Cooldude2606 6f5b8ce68d Update log statement to fatal 2026-04-15 22:15:14 +01:00
Cooldude2606 16a249d5bf Have version selectors share a common base component 2026-04-14 23:32:16 +01:00
Cooldude2606 a52e4320ec Improve UX on keyboard 2026-04-14 22:50:39 +01:00
Cooldude2606 e8cabb7196 Standardise shutdown signal guarding 2026-04-14 22:10:01 +01:00
Cooldude2606 7e82ad89ff Change config errors to be subclasses of RequestError (#895)
This prevents them from being logged when raised during an event handler.
2026-04-14 21:38:11 +01:00
Cooldude2606 2219b58000 Fix two missed cases of get_main_inventory 2026-04-14 21:31:26 +01:00
Cooldude2606 3667b4733a Fix webui error on setting host config (#893) 2026-04-14 21:21:00 +01:00
Hornwitser 490e9c0e5f Remove download headless Factorio in installer (#890)
This feature was made obsolete by hosts being able to download a
configured target version on instance startup in #831.
2026-04-13 12:27:20 +02:00
Cooldude2606 7e87201c24 Allow custom versions to be selected as a target
Add a custom version input field which can be used to enter any version number.
2026-04-12 23:18:13 +01:00
Cooldude2606 e9653c13eb Add double interrupt logic to ctl to force exit 2026-04-12 22:37:50 +01:00
Cooldude2606 255cc4c325 Fix accessing main inventory when in map view 2026-04-12 22:15:24 +01:00
Cooldude2606 fc05b229f1 2.0.0-alpha.24 2026-04-10 12:54:27 +01:00
Hornwitser da4599f219 Fix missing ) in changelog script parser output 2026-04-09 23:46:35 +00:00
Hornwitser a7620d3cdc Add target variables in changelog script parser
Add variables specifying the repository and branch that the changelog
script parser operates on in order to make it easier to use this script
on other projects.
2026-04-09 23:46:35 +00:00
Hornwitser 2e48b728dd Remove fs-extra and klaw (#882)
These packages are obsoleted by the promises API to the built-in Node fs
module.  The fs-extra package is also causing problems with broken APIs
that don't behave the same as Node's fs module, and throwing errors
without code properties on them.
2026-04-09 22:53:13 +02:00
Cooldude2606 130dca4261 Merge pull request #885 from Cooldude2606/feature/load-plugin-validation
Allow plugins to specify requiring save patching / script commands
2026-04-09 21:48:27 +01:00
Cooldude2606 2afd7aab38 Fix failing tests 2026-03-24 18:25:53 +00:00
Cooldude2606 622d372776 Add feature flags to first party plugins 2026-03-24 17:44:30 +00:00
Cooldude2606 790c2834f2 Add validator to load_plugin 2026-03-24 17:36:19 +00:00
Hornwitser 71dfb8ca24 Support node: prefix in web_ui (#881)
The Node.js documentation's examples all use this prefix, and there are
now builtin modules in Node.js only accessible via it.  Add support for
using it in lib code shared in the web_ui.
2026-03-21 01:39:24 +01:00
Cooldude2606 81e419dad4 Merge pull request #856 from Cooldude2606/feature/config-validation
Add extended config validation
2026-03-21 00:15:16 +00:00
Cooldude2606 93271730eb Make configs readonly inside validators 2026-03-20 18:56:55 +00:00
Cooldude2606 5d0985cafc Fix tests not checking that validators are called 2026-03-20 17:42:48 +00:00
Cooldude2606 6ade75e4cd Fix incorrect config validators 2026-03-20 17:07:54 +00:00
Hornwitser 86eefab9f5 Refactor file write streams (#877)
A lot of code is repeated in various different ways in order to handle
streaming content to files without overwriting them, whether it's from
web resources or local streams.

Refactor the handling of these streams to all use the same utility
function for creating a write stream, and for the places downloading
urls use the same downloadFile function.
2026-03-19 21:55:52 +01:00
Hornwitser 7701010fee Merge pull request #875 from Hornwitser/refactor-export
Refactor export
2026-03-19 21:55:04 +01:00
Hornwitser b3dc262862 Remove error prone refCount logic
This can simply use the length of the callbacks array instead.
2026-03-19 16:10:54 +01:00
Cooldude2606 f0fb52ab74 Revert change to mock exports 2026-03-18 00:17:17 +00:00
Cooldude2606 ed4fe3230d Fixed test causing missed coverage 2026-03-18 00:03:44 +00:00
Cooldude2606 8217acef3f Add coverage of config requests 2026-03-17 23:42:41 +00:00
Hornwitser 019737286f Handle missing export in locale and metadata store
In the future, removing exports is likely going to be a feature.  Handle
the case where the export data is removed from a mod pack in the locale
and prototype metadata stores.
2026-03-17 23:32:04 +01:00
Hornwitser d90ac998aa Handle incorrect usage of subscribe in stores
If the unsubscribe function is called multiple times the indexOf
function will return -1 which will end up removing random other
subscription callbacks.  Prevent the incorrect callabck removial
in this case.
2026-03-17 23:23:03 +01:00
Hornwitser 781b98b1c1 Explicitly compare refCount with 0 2026-03-17 23:21:56 +01:00
Cooldude2606 4986c9af99 Complete coverage of config classes 2026-03-17 21:31:46 +00:00
Cooldude2606 4868ed2d63 Merge branch 'master' into feature/config-validation 2026-03-16 23:22:54 +00:00
Cooldude2606 2510d47023 Add tests 2026-03-16 23:20:40 +00:00
Hornwitser 0d28a3ac3c Refactor web_ui handling of exports
The handling of exported data such as locales and icons in web_ui was
based on a model where there is only one set of data exported per
cluster.  This model changed to have one set of exports per mod pack
when mod packs were introduced, but the existing web_ui code was
bodged to use exports from a "default mod pack" on the controller.

Refactor the code loading exported data to take the mod pack as an
argument for selecting which exported data to load.  The different kinds
of prototype metadata that can be loaded is consolidated into one
`useExportPrototypeMetadata` hook.  The logic for displaying icons is
put into a `FactorioIcon` component.  And the obsolete
`useExportManifest` hook is removed.

These changes allow the `/icon-reference` page to display exports on a
per mod pack basis.  Which has now been linked on the bottom of the mod
pack view page.
2026-03-16 17:11:47 +01:00
Hornwitser f4ee2005e3 Fix bad changelog entries for alpha 23 [skip ci] (#873) 2026-03-16 13:38:41 +01:00
Hornwitser 4b85367d4e Expose lib.isDeepStrictEqual
This utility is useful when comparing deep structures for equality in
places like the web_ui.
2026-03-13 18:34:17 +01:00
Hornwitser 433cc3c712 Add missing model exports to web_ui package
A lot of the models were not available to plugins due to the index page
not exposing them on the package.
2026-03-13 18:33:57 +01:00
Hornwitser ab5162e581 Add changelog script parsing entries from PRs (#871)
Add script which uses GitHub's REST API to fetch issues and pull
requests that have changed since the last release, parse out the
changelog entries from merged pull requests, and print a formatted
version of the changelog suitable for copying into the release
description, the CHANGELOG.md file and the Discord announcement.

The script expects Pull Requests to have a `### Changelog` section in
them with a code block containing changelog entries.  As an added bonus
the script can parse out `#<number>` references so that you do not need to
write it out in the ugly `[#id](long-url-with-id-repeated)` format.

Updates documentation and pull request template to describe the
changelog format and usage of the formatting script.

Includes improvements from review and a second look.
2026-03-13 15:33:24 +01:00
Hornwitser 3b8058de81 Refactor icon export to be based on prototypes
Use the defines.prototypes data structure from the game to find which
prototype sub-types belong in the same base type, and export icons based
on these base type.

This renames the planets category to space-location, signal to
virtual-signal and plits item up into item and fluid.  And changes the
exported metadata to include prototypes with no icons, as future code
might want to use it to for example enumerate all item prototypes.
2026-03-12 18:26:04 +01:00
Hornwitser f4916594b8 Update jimp to version 1.6.0
Resolves #854
2026-03-12 14:45:48 +01:00
Hornwitser 4c01f6a789 Fix export missing localised name
The change in #838 removed localised names from the exported metadata.
Add it back again so that items with overriden names are translated.
2026-03-12 14:45:48 +01:00
Hornwitser 076fb933dc Share structured data types in export
Place shared types of the export functionality into lib and use in the
web interface to make sure the data is not incorrectly handled.

Resolves #526
2026-03-12 14:45:48 +01:00
Hornwitser bcabad567e Distinguish simple and layered icons
This allows the type to be automatically inferred and removes the need
for type casts.
2026-03-12 14:45:48 +01:00
Cooldude2606 414ef0bc8b Some fixes to config setting 2026-03-11 00:28:36 +00:00
Cooldude2606 9d807fc6d6 Update webui config to use bulk set 2026-03-10 23:38:22 +00:00
Cooldude2606 c43f752eaf Add ConfigSet requests 2026-03-10 23:16:58 +00:00
Cooldude2606 6e8290cc08 Add full staging to config class 2026-03-10 22:56:50 +00:00
Cooldude2606 bbd3cc7fdb Remove pnpm version from publish workflow 2026-03-10 19:13:29 +00:00
Cooldude2606 d76902cebe Add imperfect dependency validation 2026-03-05 00:01:55 +00:00
Cooldude2606 0fa96521c5 Rename extendedValidation to validator 2026-03-04 21:08:20 +00:00
Cooldude2606 61232e3537 Export validators on Config class as static 2026-03-04 21:07:30 +00:00
Cooldude2606 014b18caf8 Fix type of controller.auth_secret 2026-03-04 21:02:39 +00:00
Cooldude2606andHornwitser 687a384fe4 Use an async arrow function for webui rules
Co-authored-by: Hornwitser <github@hornwitser.no>
2026-03-04 14:34:09 +00:00
Cooldude2606 b2daf26e6c Add warnings to web ui 2026-03-04 00:28:29 +00:00
Cooldude2606 d0604f892b Add simple validators to config 2026-03-03 23:55:23 +00:00
Cooldude2606 d05a529c52 Improve config field definition type 2026-03-03 22:22:59 +00:00
174 changed files with 7662 additions and 3237 deletions

No files matched your search

+10 -5
View File
@@ -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
- ...
```
+1 -5
View File
@@ -13,8 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
@@ -22,7 +20,7 @@ jobs:
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
@@ -33,8 +31,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
+125 -9
View File
@@ -31,6 +31,122 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## 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 +159,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).
+6 -4
View File
@@ -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 });
}
}
+2
View File
@@ -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
+3 -1
View File
@@ -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
+6 -6
View File
@@ -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",
+6 -30
View File
@@ -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();
+15 -19
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -20,42 +20,38 @@
"node": ">=20"
},
"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"
+4 -2
View File
@@ -1,5 +1,5 @@
import path from "path";
import fs from "fs-extra";
import fs from "node:fs/promises";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
@@ -94,10 +94,12 @@ export default class BaseConnection extends lib.Link {
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
// Open source file before creating the stream in case it throws.
let source = (await fs.open(modPath)).createReadStream();
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = fs.createReadStream(modPath);
stream.source = source;
stream.mime = "application/zip";
stream.size = String(mod.size);
+53 -4
View File
@@ -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);
}
+38 -10
View File
@@ -7,7 +7,7 @@ import finalhandler from "finalhandler";
import compression from "compression";
import events from "events";
import fs from "fs-extra";
import fs from "node:fs/promises";
import http from "http";
import https from "https";
import jwt from "jsonwebtoken";
@@ -123,7 +123,7 @@ export default class Controller {
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
await fs.mkdir(databaseDirectory, { recursive: true });
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
@@ -173,7 +173,7 @@ export default class Controller {
).bootstrap());
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
await fs.mkdir(modsDirectory, { recursive: true });
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
// Add default mod packs
@@ -268,6 +268,11 @@ export default class Controller {
this.users.records.on("update", this.usersUpdated.bind(this));
}
get authSecret() {
// Auth secret is never null after startup
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
}
async start(args: ControllerArgs) {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
@@ -597,7 +602,7 @@ export default class Controller {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -614,7 +619,7 @@ export default class Controller {
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
@@ -870,6 +875,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 +891,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 +900,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 +950,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 +964,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 +980,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 +996,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 +1010,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 +1060,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,
);
+3 -2
View File
@@ -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")
);
}
+5 -23
View File
@@ -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");
}
+14 -39
View File
@@ -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
View File
@@ -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);
}
+1 -2
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.26",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -17,7 +17,6 @@
},
"dependencies": {
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -20,20 +20,16 @@
"node": ">=20"
},
"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 -1
View File
@@ -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";
+2 -2
View File
@@ -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`);
+3 -27
View File
@@ -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}`}`);
},
+3 -25
View File
@@ -1,4 +1,4 @@
import fs from "fs-extra";
import fs from "node:fs/promises";
import asTableModule from "as-table";
import events from "events";
import path from "path";
@@ -151,7 +151,7 @@ modCommands.add(new lib.Command({
let response = await fetch(url, {
method: "POST",
body: fs.createReadStream(args.file),
body: (await fs.open(args.file)).createReadStream(),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
@@ -194,30 +194,8 @@ modCommands.add(new lib.Command({
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server");
}
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
await lib.downloadFile(url, filename, "overwrite");
logger.info(`Downloaded ${filename}`);
},
+4 -4
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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)
+7 -9
View File
@@ -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.26",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -21,17 +21,16 @@
"node": ">=20"
},
"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:"
}
+48 -57
View File
@@ -1,4 +1,4 @@
import fs from "fs-extra";
import fs from "node:fs/promises";
import path from "path";
import events from "events";
import pidusage from "pidusage";
@@ -294,7 +294,7 @@ export default class Host extends lib.Link {
static async bootstrap(hostConfig: lib.HostConfig) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.ensureDir(modsDirectory);
await fs.mkdir(modsDirectory, { recursive: true });
return [
await lib.ModStore.fromDirectory(modsDirectory),
] as const;
@@ -387,6 +387,7 @@ export default class Host extends lib.Link {
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
@@ -503,18 +504,41 @@ export default class Host extends lib.Link {
return this.config.toRemote("control");
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
private static validateHostConfigSetField(fieldName: string) {
if (fieldName === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
}
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
Host.validateHostConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
}
} else {
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
}
}
this.config.commitStaging();
} finally {
this.config.revertStaging();
}
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
Host.validateHostConfigSetField(request.field);
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
Host.validateHostConfigSetField(field);
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
@@ -591,19 +615,10 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new Error(`Empty body downloading stream ${streamId} from controller`);
}
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
await events.once(writeStream, "open");
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
await lib.downloadFile(url, filePath, "overwrite");
const modInfo = await this.modStore.loadFile(file);
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
@@ -832,7 +847,7 @@ export default class Host extends lib.Link {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -849,7 +864,7 @@ export default class Host extends lib.Link {
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
@@ -916,12 +931,17 @@ export default class Host extends lib.Link {
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
try {
await fs.move(
// Link save in the new location.
await fs.link(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
{ overwrite: false },
);
// Unlink save in old location.
await fs.unlink(path.join(instance.path, "saves", oldName));
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.RequestError(`${newName} already exists`);
}
if (err.code === "ENOENT") {
throw new lib.RequestError(`${oldName} does not exist`);
}
@@ -936,10 +956,10 @@ export default class Host extends lib.Link {
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
try {
await fs.copy(
await fs.copyFile(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
{ overwrite: false, errorOnExist: true },
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
);
} catch (err: any) {
if (err.code === "ENOENT") {
@@ -965,16 +985,15 @@ export default class Host extends lib.Link {
try {
if (copy) {
await fs.copy(
await fs.copyFile(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
fs.constants.COPYFILE_FICLONE
);
} else {
await fs.move(
await fs.rename(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
);
}
} catch (err: any) {
@@ -1024,35 +1043,9 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
}
let savesDir = path.join(instance.path, "saves");
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
try {
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name));
const savedPath = await lib.downloadFile(url, path.join(savesDir, name), "rename");
name = path.basename(savedPath);
await this.sendSaveListUpdate(instanceId, savesDir);
return name;
}
@@ -1062,11 +1055,9 @@ export default class Host extends lib.Link {
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let content = fs.createReadStream(path.join(instance.path, "saves", name));
let content;
try {
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
// "open" with "ready" when fs-extra is replaced with node's fs module.
await events.once(content, "open");
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1098,7 +1089,7 @@ export default class Host extends lib.Link {
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.remove(instance.path);
await fs.rm(instance.path, { recursive: true, maxRetries: 10 });
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
+12 -7
View File
@@ -1,4 +1,4 @@
import fs from "fs-extra";
import fs from "node:fs/promises";
import path from "path";
import pidusage from "pidusage";
import util from "util";
@@ -755,8 +755,8 @@ end`.replace(/\r?\n/g, " ");
* @param instanceDir -
*/
static async populate_folders(instanceDir: string) {
await fs.ensureDir(path.join(instanceDir, "script-output"));
await fs.ensureDir(path.join(instanceDir, "saves"));
await fs.mkdir(path.join(instanceDir, "script-output"), { recursive: true });
await fs.mkdir(path.join(instanceDir, "saves"), { recursive: true });
}
/**
@@ -786,7 +786,7 @@ end`.replace(/\r?\n/g, " ");
const mods = await this._host.fetchMods(modPack.mods.values());
await fs.ensureDir(this.path("mods"));
await fs.mkdir(this.path("mods"), { recursive: true });
// Remove all files
for (let entry of await fs.readdir(this.path("mods"), { withFileTypes: true })) {
@@ -825,12 +825,12 @@ end`.replace(/\r?\n/g, " ");
}
// Write mod-list.json
await fs.outputFile(this.path("mods", "mod-list.json"), JSON.stringify({
await fs.writeFile(this.path("mods", "mod-list.json"), JSON.stringify({
mods: [...this.activeModPack.mods.values()],
}, null, "\t"));
// Write mod-settings.dat
await fs.outputFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
}
/**
@@ -921,7 +921,12 @@ end`.replace(/\r?\n/g, " ");
now.getUTCMinutes().toLocaleString("en", { minimumIntegerDigits: 2 }),
saveName,
);
await fs.copy(this.path("saves", saveName), this.path("saves", newName));
// This will overwrite the save if this runs twice in the same minute.
await fs.copyFile(
this.path("saves", saveName),
this.path("saves", newName),
fs.constants.COPYFILE_FICLONE,
);
saveName = newName;
}
+133 -168
View File
@@ -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;
}
+294
View File
@@ -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,
},
};
+7 -7
View File
@@ -1,7 +1,7 @@
// Library for patching Factorio saves with scenario code.
import events from "events";
import fs from "fs-extra";
import fs from "node:fs/promises";
import JSZip from "jszip";
import path from "path";
import semver from "semver";
@@ -93,7 +93,9 @@ export class SaveModule {
static async fromPlugin(plugin: BaseInstancePlugin) {
let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json"));
let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module");
if (!await fs.pathExists(moduleDirectory)) {
try {
await fs.access(moduleDirectory);
} catch (err: any) {
return null;
}
@@ -431,15 +433,13 @@ export async function patch(savePath: string, modules: SaveModule[]) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
// Write back the save
let tempSavePath = savePath.replace(/(\.zip)?$/, ".tmp.zip");
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let fd = await fs.open(tempSavePath, "w");
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
try {
let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false }));
let pipe = stream.pipe(writeStream);
await events.once(pipe, "finish");
await fs.fsync(fd);
} finally {
await fs.close(fd);
writeStream.destroy();
}
await fs.rename(tempSavePath, savePath);
}
+13 -12
View File
@@ -1,5 +1,6 @@
// Factorio Server interface
import fs from "fs-extra";
import fs from "node:fs/promises";
import { type FSWatcher, watch as fsWatch, writeFileSync } from "node:fs";
import child_process from "child_process";
import path from "path";
import JSZip from "jszip";
@@ -116,7 +117,7 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
const zip = await JSZip.loadAsync(buffer);
// Extract each file/folder
await fs.ensureDir(targetDir);
await fs.mkdir(targetDir, { recursive: true });
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
@@ -127,9 +128,9 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
const entryPath = path.join(targetDir, strippedPath);
if (entry.dir) {
await fs.ensureDir(entryPath);
await fs.mkdir(entryPath, { recursive: true });
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
@@ -153,7 +154,7 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
const tmpFilePath = `${targetDir}.tmp`;
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(tmpFilePath, buffer);
await fs.ensureDir(targetDir);
await fs.mkdir(targetDir, { recursive: true });
// Execute tar CLI to extract
try {
@@ -165,7 +166,7 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
throw err;
} finally {
// Clean up temp file
await fs.remove(tmpFilePath);
await fs.rm(tmpFilePath);
}
}
@@ -631,7 +632,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
// https://forums.factorio.com/viewtopic.php?t=123673
_whitelistWatcher: fs.FSWatcher | null = null;
_whitelistWatcher: FSWatcher | null = null;
_whitelist = new Set<string>();
_logger: lib.Logger;
@@ -885,7 +886,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
const filePath = this.writePath("server-whitelist.json");
try {
this._whitelistWatcher = fs.watch(filePath);
this._whitelistWatcher = fsWatch(filePath);
} catch (err: any) {
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
@@ -907,7 +908,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
let newWhitelistJson;
try {
newWhitelistJson = await fs.readJSON(filePath);
newWhitelistJson = JSON.parse(await fs.readFile(filePath, "utf8"));
} catch (err: any) {
this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`);
return;
@@ -1527,20 +1528,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
});
// Must be sync to allow process spawn without awaiting
// eslint-disable-next-line node/no-sync
fs.writeFileSync(this.writePath("config.ini"), content);
writeFileSync(this.writePath("config.ini"), content);
}
_writeMapSettingsSync(mapGenSettings?: object, mapSettings?: object) {
// Must be sync to allow process spawn without awaiting
if (mapGenSettings) {
// eslint-disable-next-line node/no-sync
fs.writeFileSync(
writeFileSync(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t")
);
}
if (mapSettings) {
// eslint-disable-next-line node/no-sync
fs.writeFileSync(
writeFileSync(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t")
);
}
+2
View File
@@ -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
View File
@@ -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 });
}
}
}
+2
View File
@@ -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);
+8 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.26",
"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:"
}
+3 -3
View File
@@ -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}`
);
+2 -2
View File
@@ -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";
/**
+5 -44
View File
@@ -1,10 +1,9 @@
import fs from "fs-extra";
import fs from "node:fs/promises";
import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { downloadFile, safeOutputFile } from "./file_ops";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
@@ -155,33 +154,6 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
return new this(modsDirectory, files);
}
/**
* Stream a url's content into a file.
*
* @param url - Web path to stream from.
* @param filePath - File path to stream to.
*/
private static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
* Download a matching version from a selection of available versions.
*
@@ -202,27 +174,16 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
}
try {
const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`);
const url = new URL(selectedRelease.download_url, "https://mods.factorio.com/");
url.searchParams.set("username", username);
url.searchParams.set("token", token);
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`;
const filePath = path.join(this.modsDirectory, selectedRelease.file_name);
await ModStore.downloadFile(url, tempFileName);
await fs.rename(tempFileName, finalFileName);
await downloadFile(url, filePath, "overwrite");
await this.loadFile(selectedRelease.file_name);
} catch (err: any) {
logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`);
// Attempt to clean up temp file if it exists
try {
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
await fs.unlink(tempFileName);
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist
logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`);
}
}
}
}
+339 -46
View File
@@ -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. */
@@ -244,6 +273,110 @@ export class Config<
}
}
/** A staging version of the config with changes tracked, invalided by revertStaging */
private _staging?: { config: Config<Fields>, changes: Set<string>};
/** Get the latest version of staging */
// TODO replace with "using config.startStating()"
get staging(): Config<Fields> {
if (this._staging) { return this._staging.config; }
const config = new this.constructor(this.location, this.fields);
const changes = new Set<string>();
config.on("fieldChanged", name => changes.add(name));
this._staging = { config, changes };
return config;
}
private static _fieldDependents: Map<string, {
name: string,
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
}[]>;
/** Map of all fields whose validators depend on the given field */
static get fieldDependents(): typeof this._fieldDependents {
if (this._fieldDependents) {
return this._fieldDependents;
}
const dependents = new Map<string, { name: string, def: FieldDefinition }[]>();
for (const [name, def] of Object.entries(this.fieldDefinitions)) {
if (!def.validator || !def.dependsOn) { continue; }
for (const otherName of def.dependsOn) {
const fieldDependents = dependents.get(otherName);
if (fieldDependents) {
fieldDependents.push({name, def});
} else {
dependents.set(otherName, [{name, def}]);
}
}
}
// The type is correct because of "!def.validator", not clear why this doesn't infer
this._fieldDependents = dependents as any;
return this._fieldDependents;
}
/**
* Coerce and validate a value according to a field definition.
*
* @param name - Name of the field being validated.
* @param def - Definition describing the field type and constraints.
* @param value - Value to validate and coerce.
* @throws {InvalidValue} if the value cannot be coerced to the correct type
* @returns The validated and coerced value.
* @private
*/
static _coerceValue<T extends FieldDefinition>(name: string, def: T, value: unknown) {
// Empty strings are treated as null
if (value === "") {
value = null;
}
// If def is an enum then it must be a value within it
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
// If it is a string, then attempt to coerce it
if (typeof value === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err: any) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
}
}
// Check if it is allowed to be null
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
}
// Check if is the correct type
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
return value as FieldType<T["type"]>;
}
/**
* Check access for a field
*
@@ -288,8 +421,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 +447,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 +647,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 +719,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 +738,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;
}
}
+115 -25
View File
@@ -1,7 +1,9 @@
// Core definitions for the configuration system
import * as classes from "./classes";
import * as validators from "./validators";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import { Static } from "@sinclair/typebox";
import { isTargetVersion } from "../data/version";
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
@@ -18,7 +20,7 @@ export interface ControllerConfigFields {
"controller.public_url": string | null;
"controller.tls_certificate": string | null;
"controller.tls_private_key": string | null;
"controller.auth_secret": string;
"controller.auth_secret": string | null;
"controller.heartbeat_interval": number;
"controller.session_timeout": number;
"controller.metrics_timeout": number;
@@ -73,18 +75,21 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"controller.database_directory": {
title: "Database directory",
description: "Directory where item and configuration data is stored.",
type: "string",
initialValue: "database",
validator: validators.filePath,
},
"controller.autosave_interval": {
title: "Autosave Interval",
description: "Interval in seconds to autosave data in memory to disk.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.http_port": {
title: "HTTP Port",
@@ -93,6 +98,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
type: "number",
optional: true,
initialValue: 8080,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.https_port": {
title: "HTTPS Port",
@@ -100,6 +109,10 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.bind_address": {
title: "Bind Address",
@@ -107,6 +120,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
// TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost
},
"controller.trusted_proxies": {
title: "Trusted Proxies",
@@ -114,12 +128,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
"Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on",
type: "string",
optional: true,
// TODO extendedValidation, see parseTrustedProxies
},
"controller.public_url": {
title: "Public URL",
description: "Public facing URL the controller is hosted on, including the protocol.",
type: "string",
optional: true,
// TODO extendedValidation, valid url including the protocol
},
"controller.tls_certificate": {
title: "TLS Certificate",
@@ -127,6 +143,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.tls_private_key": {
title: "TLS Private Key",
@@ -134,6 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.auth_secret": {
access: ["controller"],
@@ -151,30 +169,35 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.session_timeout": {
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.metrics_timeout": {
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from hosts.",
type: "number",
initialValue: 8,
validator: validators.greaterThanZero,
},
"controller.system_metrics_interval": {
title: "System Metrics Interval",
description: "Interval in seconds to collect and update system metrics for the Web UI",
type: "number",
initialValue: 10,
validator: validators.greaterThanEqualZero,
},
"controller.proxy_stream_timeout": {
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.factorio_username": {
title: "Factorio Username",
@@ -219,12 +242,14 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Duration in minutes to cache mod portal API responses.",
type: "number",
initialValue: 30,
validator: validators.greaterThanEqualZero, // Zero is allowed here to disable cache
},
"controller.mod_portal_page_size": {
title: "Mod Portal Page Size",
description: "Maximum number of results per page when querying the Factorio mod portal API.",
type: "number",
initialValue: 1000,
validator: validators.greaterThanZero,
},
"controller.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -299,6 +324,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Path to directory to look for factorio installs",
type: "string",
initialValue: "factorio",
validator: validators.filePath,
},
"host.mods_directory": {
title: "Mods Directory",
@@ -306,18 +332,21 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"host.instances_directory": {
description: "Path to directory to store instances in.",
restartRequired: true,
type: "string",
initialValue: "instances",
validator: validators.filePath,
},
"host.controller_url": {
description: "URL to connect to the controller at",
restartRequired: true,
type: "string",
initialValue: "http://localhost:8080/",
// TODO extendedValidation, valid url including the protocol
},
"host.controller_token": {
access: ["host"],
@@ -325,6 +354,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "enter token here",
// TODO extendedValidation, valid JWT with matching host id
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
@@ -338,6 +368,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
"ranges separated with a dash.",
type: "string",
initialValue: "34100-34199",
// TODO extendedValidation, port range
},
"host.factorio_username": {
title: "Factorio Username",
@@ -363,6 +394,7 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"host.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -456,6 +488,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "number",
optional: true,
hidden: true,
// Internal value, extendedValidation not used
},
"instance.auto_start": {
description: "Automatically start this instance when the host hosting it is started up",
@@ -473,6 +506,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "string",
initialValue: "latest",
inputComponent: "target_version",
validator: function(value) {
if (!isTargetVersion(value)) {
throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z");
}
},
},
"factorio.executable_path": {
description:
@@ -481,6 +519,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"factorio.shutdown_timeout": {
description:
@@ -488,24 +527,27 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"the process. Set to 0 to disable.",
type: "number",
initialValue: 300,
optional: true,
validator: validators.greaterThanEqualZero,
},
"factorio.game_port": {
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.host_assigned_game_port": {
access: ["host"],
type: "number",
optional: true,
// Internal value, extendedValidation not used
},
"factorio.rcon_port": {
description: "TCP port to run RCON on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.rcon_password": {
credential: ["host", "controller"],
@@ -520,6 +562,14 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initialValue: 5,
dependsOn: ["factorio.settings", "factorio.settings.autosave_slots"],
validator: function(value, config) {
const factorioSettings = config.get("factorio.settings");
const autosaveSlots = factorioSettings.autosave_slots;
if (typeof autosaveSlots === "number" && value < autosaveSlots) {
throw new Error("Value cannot be less than the number of autosave slots");
}
},
},
"factorio.mod_pack_id": {
title: "Mod Pack",
@@ -568,6 +618,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
],
type: "object",
initialValue: {}, // See create instance handler in controller.
// TODO extendedValidation, we could check keys and types here if we wanted
},
"factorio.verbose_logging": {
description: "Enable verbose logging on the Factorio server",
@@ -610,6 +661,7 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "number",
initialValue: 5,
validator: validators.greaterThanZero,
},
};
}
@@ -644,12 +696,13 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanEqualZero,
},
};
}
function validateFields(
function validateFieldNames(
pluginName: string,
fields: Record<string, classes.FieldDefinition>,
) {
@@ -663,36 +716,73 @@ function validateFields(
}
/**
* Add config fields defined by the provided plugin infos
* Add the config fields defined by the by a specific plugin infos
*/
export function addPluginFieldDefinitions(
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
Config: typeof classes.Config<any>
) {
// Define plugin.load_plugin
const fieldDef: classes.FieldDefinition = {
title: "Load Plugin",
type: "boolean",
initialValue: true,
restartRequired: true,
};
// The validator is dynamic based on the feature requirements of the plugin
if (kind === "instanceConfigFields" && pluginInfo.features) {
fieldDef.dependsOn = [];
const savePatching = pluginInfo.features.includes("SavePatching");
if (savePatching) {
fieldDef.dependsOn.push("factorio.enable_save_patching");
}
const scriptCommands = pluginInfo.features.includes("ScriptCommands");
if (scriptCommands) {
fieldDef.dependsOn.push("factorio.enable_script_commands");
}
if (scriptCommands || savePatching) {
fieldDef.validator = (enabled, config) => {
if (!enabled) {
return;
}
if (savePatching && !config.get("factorio.enable_save_patching")) {
throw new Error(`Plugin ${pluginInfo.name} requires save patching`);
}
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
}
};
}
}
Config.fieldDefinitions[`${pluginInfo.name}.load_plugin`] = fieldDef;
// Merge in other fields from the plugin
const fields = pluginInfo[kind];
if (fields) {
validateFieldNames(pluginInfo.name, fields);
Object.assign(Config.fieldDefinitions, fields);
}
}
/**
* Add all config fields defined by the provided plugin infos
*
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
*/
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
function pluginConfig(
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
Config: typeof ControllerConfig | typeof HostConfig | typeof InstanceConfig,
) {
(Config.fieldDefinitions as any)[`${pluginInfo.name}.load_plugin`] = {
title: "Load Plugin",
restartRequired: true,
type: "boolean",
initialValue: true,
};
const fields = pluginInfo[kind];
if (fields) {
validateFields(pluginInfo.name, fields);
Object.assign(Config.fieldDefinitions, fields);
}
}
for (let pluginInfo of pluginInfos) {
pluginConfig(pluginInfo, "controllerConfigFields", ControllerConfig);
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
pluginConfig(pluginInfo, "hostConfigFields", HostConfig);
addPluginFieldDefinitions(pluginInfo, "hostConfigFields", HostConfig);
}
if (pluginInfo.instanceEntrypoint) {
pluginConfig(pluginInfo, "instanceConfigFields", InstanceConfig);
addPluginFieldDefinitions(pluginInfo, "instanceConfigFields", InstanceConfig);
}
}
}
+66
View File
@@ -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 -1
View File
@@ -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";
+4 -3
View File
@@ -5,6 +5,7 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import type { ExportSettings } from "../export";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
@@ -357,7 +358,7 @@ export default class ModPack {
* Setting prototypes exported from the game.
* @param logger - Logger used to report warnings on.
*/
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) {
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
let prototypes = Object.entries(settingPrototypes)
.filter(([type, _]) => knownTypes.includes(type))
@@ -370,7 +371,7 @@ export default class ModPack {
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`);
continue;
}
if (prototype.default_value === undefined) {
if ((prototype as any).default_value === undefined) {
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
continue;
}
@@ -389,7 +390,7 @@ export default class ModPack {
if (type === "color-setting") {
value = libLuaTools.normalizeColor(value as object);
}
this.settings[settingType].set(prototype.name, { value });
this.settings[settingType].set(prototype.name, { value } as ModSetting);
}
}
+3 -2
View File
@@ -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));
}
}
@@ -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;
+10 -2
View File
@@ -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;
}
/**
+22
View File
@@ -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;
+1 -1
View File
@@ -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 -1
View File
@@ -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";
+129
View File
@@ -0,0 +1,129 @@
import { FactorioColor } from "./lua_tools";
/**
* A localised string.
* @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html)
*/
export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]];
/**
* Shared properties of all prototypes.
* @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html)
*/
export interface FactorioPrototypeBase {
[index: string]: unknown,
type: string,
name: string,
order?: string,
localised_name?: FactorioLocalisedString,
localised_description?: FactorioLocalisedString,
factoriopedia_description?: FactorioLocalisedString,
subgroup?: string,
hidden?: boolean,
hidden_in_factoriopedia?: boolean,
parameter?: boolean,
factoriopedia_simulation?: unknown,
}
/**
* Stage the mod setting is part of.
* @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property)
*/
export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user";
/**
* Boolean mod setting prototype.
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
*/
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
type: "bool-setting",
setting_type?: FactorioModSettingType,
default_value: boolean,
forced_value?: boolean,
}
/**
* Integer mod setting prototype.
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
*/
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
type: "int-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* Real number mod setting prototype.
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
*/
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
type: "double-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* String mod setting prototype.
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
*/
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
type: "string-setting",
setting_type?: FactorioModSettingType,
default_value: string,
allow_black?: boolean,
auto_trim?: string,
allowed_values?: string[],
}
/**
* Color mod setting prototype.
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
*/
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
type: "color-setting",
setting_type?: FactorioModSettingType,
default_value: FactorioColor,
forced_value?: boolean,
}
export type FactorioModSettingPrototype =
| FactorioBoolSettingPrototype
| FactorioIntSettingPrototype
| FactorioDoubleSettingPrototype
| FactorioStringSettingPrototype
| FactorioColorSettingPrototype
;
/**
* A list of prototypes as it appears in Factorio's Lua API.
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
*/
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
export type FactorioDefines = {
[index: string]: FactorioDefinesValue,
prototypes: Record<string, Record<string, number>>,
};
export type ExportSettings = FactorioPrototypes<FactorioModSettingPrototype>;
export type ExportPrototypes = FactorioPrototypes;
export type ExportLocale = [string, string][];
export interface ExportMetadataEntry {
name: string,
type: string,
localised_name?: FactorioLocalisedString,
icon?: {
x: number,
y: number,
size: number,
path?: string,
},
}
export type ExportMetadata = Record<string, ExportMetadataEntry[]>;
export type ExportDefines = FactorioDefines;
+126 -22
View File
@@ -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 -3
View File
@@ -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());
}
+29
View File
@@ -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.
*
+2 -2
View File
@@ -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);
}
+3
View File
@@ -23,6 +23,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.ControllerRestartRequest,
controller.ControllerUpdateRequest,
controller.ControllerConfigGetRequest,
controller.ControllerConfigSetRequest,
controller.ControllerConfigSetFieldRequest,
controller.ControllerConfigSetPropRequest,
controller.HostGenerateTokenRequest,
@@ -43,6 +44,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.HostRestartRequest,
host.HostUpdateRequest,
host.HostConfigGetRequest,
host.HostConfigSetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
host.HostListRequest,
@@ -59,6 +61,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceDetailsUpdatesEvent,
instance.InstanceCreateRequest,
instance.InstanceConfigGetRequest,
instance.InstanceConfigSetRequest,
instance.InstanceConfigSetFieldRequest,
instance.InstanceConfigSetPropRequest,
instance.InstanceAssignRequest,
+8 -5
View File
@@ -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");
+5 -4
View File
@@ -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);
+10
View File
@@ -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[];
}
+1 -1
View File
@@ -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";
+9 -8
View File
@@ -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 });
}
+389 -153
View File
@@ -1,13 +1,142 @@
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 } 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 {
private constructor(
private _all: boolean,
private _filters: Set<string>,
) {}
static jsonSchema = Type.Array(Type.String());
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(false, new Set(json));
}
toJSON() {
return [...this._filters];
}
/** Creates a subscription filter accepting all strings */
static all() {
return new this(true, new Set());
}
isAll() {
return this._all;
}
/** Creates a subscription filter rejecting all strings */
static empty() {
return new this(false, new Set());
}
isEmpty() {
return !this._all && this._filters.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._filters.size === 0) {
return "[SubscriptionFilters Empty]";
}
return `[SubscriptionFilters Set<${this._filters.size}>]`;
}
/** Returns true if this filter accepts the given string */
accepts(value: string) {
return this._all || this._filters.has(value);
}
/** Returns true if this filter extends the other */
extends(other: this) {
return this._all || (!other._all && [...other._filters.values()].every(value => this._filters.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._filters) {
if (this._filters.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._filters.clear();
return;
}
for (const filter of other._filters) {
this._filters.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._filters.clear();
return;
}
if (this._all) {
return; // Cannot subtract from all
}
for (const filter of other._filters) {
this._filters.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 +149,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 +163,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 +252,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 +269,7 @@ export class SubscriptionController {
}
this._events.set(entry.name, {
subscriptionUpdate: subscriptionUpdate,
subscriptions: [],
subscriptions: new Map(),
});
}
@@ -124,7 +277,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 +286,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 +305,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 +317,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 +397,27 @@ 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;
/** 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] = [this.makeSnapshot(), false];
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 +437,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 +449,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 +487,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 +511,145 @@ 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] {
if (this._snapshotLastUpdatedMs !== this.lastUpdatedMs || this._snapshot[1] !== this.synced) {
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
this._snapshot = [this.makeSnapshot(), this.synced];
}
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
));
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}`);
}
}
}
}
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);
}
}
+9 -25
View File
@@ -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(
+1
View File
@@ -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",
+10
View File
@@ -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 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.23",
"version": "2.0.0-alpha.26",
"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",
+2 -2
View File
@@ -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>;
}
@@ -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}
/>;
}
+5 -2
View File
@@ -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,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}
/>;
}
+3 -2
View File
@@ -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,179 @@
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 [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]);
// 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,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
if (props.version.includeLatest) {
tree.unshift({ title: "latest", value: "latest", key: "latest", 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>;
}
@@ -26,7 +26,8 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
import Link from "./Link";
const { logger } = lib;
const { Text } = Typography;
@@ -1334,6 +1335,12 @@ export default function ModPackViewPage() {
prototypes={prototypes}
locale={locale}
/>
<Typography.Title level={4}>
Inspect exported data
</Typography.Title>
<Link to={`/mods/mod-packs/${modPackId}/icon-reference`}>
Icon reference
</Link>
</Form>
<div
+1 -1
View File
@@ -21,7 +21,7 @@ import SectionHeader from "./SectionHeader";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
import InputPartialVersion from "./InputPartialVersion";
import { InputPartialVersion } from "./InputVersion";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -8,6 +8,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
type PluginExtraProps = {
component: string;
search?: string;
instance?: lib.InstanceDetails;
user?: lib.UserDetails;
host?: lib.HostDetails;
+271 -71
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useContext, useState } from "react";
import React, { useEffect, useContext, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { Button, Checkbox, Input, Popconfirm, Space, Spin, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -13,20 +13,165 @@ import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import { notifyErrorHandler } from "../util/notify";
/** Splits a permission name into parts, removing the last part */
function getGroupName(name: string) {
const parts = name.split(".");
return parts.length <= 2
? parts.slice(0, 2).join(".")
: parts.slice(0, parts.length - 1).join(".");
}
/** Converts a group name into Title Case with _ and . removed */
function formatGroupTitle(name: string) {
return name
.replace(/\./g, " / ")
.replace(/_/g, " ")
.split(" ")
.filter(Boolean)
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
/** Check if a permission is dangerous, in the future we might have metadata for this */
function isDangerous(permission: lib.Permission) {
return ["delete", "kill", "stop", "revoke", "update", "core.admin"]
.some(word => permission.name.includes(word));
}
/** A single permission item inside a group; includes a checkbox, name, and description */
function PermissionItem({
permission, baseline, checked, disabled, onChange,
}: {
permission: lib.Permission;
checked: boolean;
baseline: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
const modified = checked !== baseline;
return (
<div style={{
marginBottom: 8,
padding: modified ? "4px 6px" : undefined,
background: modified ? "#2a1912" : undefined,
borderRadius: 4,
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<Checkbox checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)}>
<Space size={6}>
<span>{permission.title}</span>
{permission.grantByDefault && (
<Tooltip title="Granted by default">
<StarOutlined style={{ color: "#faad14" }} />
</Tooltip>
)}
{isDangerous(permission) && (
<Tooltip title="Dangerous permission">
<ExclamationCircleOutlined style={{ color: "#ff4d4f" }} />
</Tooltip>
)}
</Space>
</Checkbox>
</div>
<div style={{ marginLeft: 24, color: "#888", fontSize: 12 }}>
{permission.description}
</div>
</div>
);
}
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
function PermissionGroup({
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
}: {
groupName: string;
permissions: lib.Permission[];
canUpdate: boolean;
baselineState: Record<string, boolean>;
permissionState: Record<string, boolean>;
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setEdited?: (value: boolean) => void;
}) {
function setGroup(value: boolean) {
setPermissionState((prev: any) => {
const next = { ...prev };
for (let p of permissions) {
next[p.name] = value;
}
return next;
});
}
return (
<div style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<strong>{formatGroupTitle(groupName)}</strong>
<Space>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(true)}>Select all</Button>
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(false)}>Clear</Button>
</Space>
</Space>
<div style={{ marginTop: 8 }}>
{permissions.map((p: any) => (
<PermissionItem
key={p.name}
permission={p}
checked={permissionState[p.name]}
baseline={baselineState[p.name]}
disabled={!canUpdate}
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
/>
))}
</div>
</div>
);
}
/** The role page view */
export default function RoleViewPage() {
let params = useParams();
let roleId = Number(params.id);
const params = useParams();
const roleId = Number(params.id);
let navigate = useNavigate();
let account = useAccount();
let control = useContext(ControlContext);
const navigate = useNavigate();
const account = useAccount();
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
let [edited, setEdited] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }];
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
const canUpdate = Boolean(account.hasPermission("core.role.update"));
useEffect(() => {
if (!role) {
return;
}
const base = Object.fromEntries(
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
);
setName(role.name);
setDescription(role.description);
setPermissionState(base);
setBaselineState(base);
}, [role, allPermissions]);
// If no role then display either the loading screen or the not found screen
if (!role) {
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
if (!synced) {
return <PageLayout nav={nav}>
<PageHeader title={String(roleId)} />
@@ -40,81 +185,136 @@ export default function RoleViewPage() {
</PageLayout>;
}
// TODO: Update displayed perms when role perms change and have not been edited locally
let initialValues = {
name: role["name"],
description: role["description"],
permissions: {
...Object.fromEntries([...lib.permissions.values()].map(perm => [
perm.name, [...role.permissions].includes(perm.name),
])),
},
};
// Apply the filter by searching all permission fields
const filtered = allPermissions.filter(p => {
const q = search.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.title.toLowerCase().includes(q)
|| p.description.toLowerCase().includes(q);
});
let canUpdate = account.hasPermission("core.role.update");
return <PageLayout nav={nav}>
<Form
initialValues={initialValues}
onValuesChange={() => { setEdited(true); }}
onFinish={values => {
let newPermissions = [];
for (let [name, value] of Object.entries(values.permissions)) {
if (value) { newPermissions.push(name); }
}
// Organise the permissions into groups
const groups = new Map<string, typeof filtered>();
for (let p of filtered) {
const g = getGroupName(p.name);
if (!groups.has(g)) {
groups.set(g, []);
}
groups.get(g)!.push(p);
}
control.send(
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
).then(() => {
setEdited(false);
}).catch(notifyErrorHandler("Error applying changes"));
}}
>
// Sort permissions alphabetically within their groups
const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
for (let [, permissions] of sortedGroups) {
permissions.sort((a, b) => a.title.localeCompare(b.title));
}
// Check if any changes have been made to the permissions or role metadata
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
const metaChanged = name !== role.name || description !== role.description;
const edited = permissionChanged || metaChanged;
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
.then(() => {
setBaselineState(permissionState);
})
.catch(notifyErrorHandler("Error applying changes"));
}
/** Revert any pending changes */
function revertChanges() {
setPermissionState(baselineState);
if (role) {
setName(role.name);
setDescription(role.description);
}
}
return (
<PageLayout nav={[{ name: "Roles", path: "/roles" }, { name: role.name }]}>
<PageHeader
title={role.name}
extra={<Space wrap>
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
{account.hasPermission("core.role.delete") && <Popconfirm
title="Delete role?"
placement="bottomRight"
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.RoleDeleteRequest(roleId)
).then(() => {
navigate("/roles");
}).catch(notifyErrorHandler("Error deleting role"));
control.send(new lib.RoleDeleteRequest(roleId))
.then(() => navigate("/roles"))
.catch(notifyErrorHandler("Error deleting role"));
}}
>
<Button danger >
<DeleteOutlined />
</Button>
<Button danger><DeleteOutlined /></Button>
</Popconfirm>}
</Space>}
/>
<Form.Item name="name" label="Name">
<Input disabled={!canUpdate}/>
</Form.Item>
<Form.Item name="description" label="Description">
<Input disabled={!canUpdate}/>
</Form.Item>
{edited && (
<div style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "#2a1912",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
zIndex: 1000,
}}>
<span style={{ color: "#FFF" }}>
You have unsaved changes
</span>
<Space>
<Button onClick={revertChanges}>
Revert
</Button>
<Button type="primary" onClick={applyChanges}>
Apply
</Button>
</Space>
</div>
)}
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
</div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
</div>
<h3>Permissions</h3>
{[...lib.permissions.values()].map(({name, title, description}) => (
<Form.Item
name={["permissions", name]}
key={name}
label={title}
tooltip={description}
valuePropName="checked"
labelCol={{ sm: { span: 22, push: 2 }}}
wrapperCol={{ sm: { span: 2, pull: 22 }}}
labelAlign="left"
colon={false}
>
<Checkbox disabled={!canUpdate} />
</Form.Item>
))}
</Form>
<PluginExtra component="RoleViewPage" role={role} />
</PageLayout>;
<Input.Search placeholder="Search permissions" allowClear
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
<Space direction="vertical" style={{ width: "100%" }}>
{sortedGroups.map(([groupName, permissions]) => (
<PermissionGroup
key={groupName}
groupName={groupName}
permissions={permissions}
permissionState={permissionState}
baselineState={baselineState}
setPermissionState={setPermissionState}
canUpdate={canUpdate}
/>
))}
</Space>
<PluginExtra component="RoleViewPage" role={role} search={search}/>
</PageLayout>
);
}
+46 -6
View File
@@ -1,18 +1,21 @@
import React, { Fragment, useContext, useEffect, useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Button, Dropdown, Layout, Menu, MenuProps, Tooltip } from "antd";
import UserOutlined from "@ant-design/icons/UserOutlined";
import webUiPackage from "../../package.json";
import { UserOutlined, DownloadOutlined } from "@ant-design/icons";
import { useAccount } from "../model/account";
import ErrorBoundary from "./ErrorBoundary";
import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
import { pages } from "../pages";
import { DraggingContext } from "../model/is_dragging";
import { saveJson } from "../util/save_file";
import { useAccount } from "../model/account";
import { DraggingContext } from "../model/is_dragging";
import webUiPackage from "../../package.json";
import { ControlConfig } from "@clusterio/lib";
import { DownloadOutlined } from "@ant-design/icons";
type MenuItem = Required<MenuProps>["items"][number];
@@ -43,6 +46,9 @@ export default function SiteLayout() {
let plugins = useContext(ControlContext).plugins;
const control = useContext(ControlContext);
const [dragging, setDragging] = useState(0);
const [aboutOpen, setAboutOpen] = useState(false);
const [wasAboutOpen, setWasAboutOpen] = useState(false);
const [changeLogOpen, setChangeLogOpen] = useState(false);
function SetSidebar(props: { path: string | null }) {
useEffect(() => {
@@ -61,6 +67,8 @@ export default function SiteLayout() {
"control.controller_url": (new URL(webRoot, document.location.href)).href,
});
saveJson("config-control.json", config.toJSON());
} else if (key === "about") {
setAboutOpen(true);
} else if (key === "logOut") {
account.logOut();
}
@@ -73,6 +81,7 @@ export default function SiteLayout() {
</Tooltip>,
key: "ctlConfig",
},
{ label: "About", key: "about" },
{ type: "divider" },
{ label: "Log out", danger: true, key: "logOut" },
],
@@ -141,11 +150,42 @@ export default function SiteLayout() {
}
}}
>
<AboutModal
open={aboutOpen}
onClose={() => {
setAboutOpen(false);
setWasAboutOpen(false);
}}
onOpenChangelog={() => {
setChangeLogOpen(true);
setWasAboutOpen(aboutOpen);
setAboutOpen(false);
}}
/>
<ChangeLogModal
open={changeLogOpen}
onClose={() => {
setChangeLogOpen(false);
setAboutOpen(wasAboutOpen);
}}
/>
<DraggingContext.Provider value={dragging > 0}>
<Header className="header">
<div className="site-logo" />
<span className="site-name">Clusterio</span>
<span className="site-version">{webUiPackage.version}</span>
<Tooltip
title="View changelog"
placement="bottom"
align={{ offset: [0, -10] }}
>
<span
className="site-version"
style={{ cursor: "pointer" }}
onClick={() => setChangeLogOpen(true)}
>
{webUiPackage.version}
</span>
</Tooltip>
<Menu
theme="dark"
mode="horizontal"
@@ -0,0 +1,184 @@
import React, { useRef } from "react";
import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
import { UserDetails } from "@clusterio/lib";
const { Text } = Typography;
export interface UserFilter {
search?: string;
admin?: boolean;
whitelisted?: boolean;
banned?: boolean;
}
function encodeFilter(value: UserFilter): string {
return JSON.stringify(value);
}
function decodeFilter(value: string): UserFilter {
try {
return JSON.parse(value);
} catch {
return {};
}
}
function triStateToSegmented(value?: boolean) {
if (value === true) { return "yes"; }
if (value === false) { return "no"; }
return "any";
}
function segmentedToTriState(value: string): boolean | undefined {
if (value === "yes") { return true; }
if (value === "no") { return false; }
return undefined;
}
function matchesTriState(value: boolean, filter?: boolean) {
return filter === undefined || value === filter;
}
export function onFilterUser(
value: string | number | boolean,
record: UserDetails
) {
const filter = decodeFilter(String(value));
// Name filter
if (filter.search) {
if (!record.name.toLowerCase().includes(filter.search.toLowerCase())) {
return false;
}
}
// Status filter
return matchesTriState(record.isAdmin, filter.admin)
&& matchesTriState(record.isWhitelisted, filter.whitelisted)
&& matchesTriState(record.isBanned, filter.banned);
}
export function Username({
user,
withStatus = false,
} : {
user: UserDetails,
withStatus: boolean,
}) {
return (
<Space>
{user.name}
{withStatus && (
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
)}
</Space>
);
}
export function useUserFilter(withStatus?: boolean) {
const searchInput = useRef<InputRef>(null);
function filterDropdown({
selectedKeys,
setSelectedKeys,
confirm,
clearFilters,
}: {
selectedKeys: string[];
setSelectedKeys: (keys: string[]) => void;
confirm: FilterDropdownProps["confirm"];
clearFilters: FilterDropdownProps["clearFilters"];
}) {
const filter: UserFilter = selectedKeys[0]
? decodeFilter(selectedKeys[0])
: {};
function update(next: Partial<UserFilter>) {
const encoded = encodeFilter({ ...filter, ...next });
if (encoded === "{}") {
clearFilters?.({ closeDropdown: false });
} else {
setSelectedKeys([encoded]);
}
confirm({ closeDropdown: false });
}
return (
<div style={{ padding: 8, width: 240 }} onKeyDown={(e) => e.stopPropagation()}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
ref={searchInput}
placeholder="Search username"
value={filter.search}
allowClear
onSearch={() => confirm({ closeDropdown: true })}
onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
{withStatus &&<>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.admin)}
onChange={value => update({ admin: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Admin</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.whitelisted)}
onChange={value => update({ whitelisted: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Whitelist</Text>
</Space>
<Space size={8} align="center">
<Segmented
size="small"
value={triStateToSegmented(filter.banned)}
onChange={value => update({ banned: segmentedToTriState(value) })}
options={[
{ icon: <CloseOutlined />, value: "no", className: "seg-no" },
{ icon: <MinusOutlined />, value: "any" },
{ icon: <CheckOutlined />, value: "yes", className: "seg-yes" },
]}
/>
<Text>Banned</Text>
</Space>
</>}
</Space>
</div>
);
}
const filterDropdownProps = {
onOpenChange: (open: boolean) => {
if (open) {
setTimeout(() => searchInput.current?.select(), 100);
}
},
};
return { filterDropdown, filterDropdownProps };
}
+13 -4
View File
@@ -19,6 +19,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import UsersTable from "./UsersTable";
import { InputInstanceWithAll } from "./InputInstance";
function CreateUserButton() {
let control = useContext(ControlContext);
@@ -366,18 +367,26 @@ function BulkUserActionButton() {
}
export default function UsersPage() {
let account = useAccount();
const account = useAccount();
const [instanceId, setInstanceId] = useState<number | null>(null);
return <PageLayout nav={[{ name: "Users" }]}>
<PageHeader
title="Users"
extra={<Space>
{account.hasPermission("core.user.create") ? <CreateUserButton /> : undefined}
{account.hasAllPermission("core.instance.list", "core.instance.subscribe")
&& <InputInstanceWithAll
value={instanceId}
onChange={value => setInstanceId(value as number)}
fieldDefinition={{ optional: true } as any}
disabled={false}
/>}
{account.hasPermission("core.user.create") && <CreateUserButton />}
{account.hasAnyPermission("core.user.bulk_import", "core.user.bulk_export")
? <BulkUserActionButton /> : undefined}
&& <BulkUserActionButton />}
</Space>}
/>
<UsersTable />
<UsersTable instanceId={instanceId ?? undefined}/>
<PluginExtra component="UsersPage" />
</PageLayout>;
}
+97 -171
View File
@@ -1,6 +1,5 @@
import React, { useRef } from "react";
import { Table, Tag, Space, Input, InputRef } from "antd";
import type { FilterDropdownProps } from "antd/es/table/interface";
import React from "react";
import { Table, Tag } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
@@ -9,20 +8,16 @@ import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
import { formatDuration } from "../util/time_format";
import {
formatFirstSeen,
formatLastSeen,
sortFirstSeen,
sortLastSeen,
useUsers,
calculateFirstSeen, formatFirstSeen, sortFirstSeen,
calculateLastSeen, formatLastSeen, sortLastSeen,
useUsers, getUserStats, isUserOnline,
} from "../model/user";
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
import Link from "./Link";
export interface UsersTableProps {
/**
* Optional instance id.
* – If provided, instance-specific columns (Play Time, Join Count, First Seen) are shown.
* – If omitted, global player statistics columns (Online Time, First/Last Seen) are shown.
*/
/** Optional instance id. If provided, stats will be filtered to that instance only */
instanceId?: number;
/** Show only players that are currently online, works with instanceId to show online of a particular instance */
onlyOnline?: boolean;
@@ -35,71 +30,71 @@ export interface UsersTableProps {
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
const [roles] = useRoles();
const [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
const navigate = useNavigate();
const searchInput = useRef<InputRef>(null);
const [users] = useUsers();
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
const data = [...users.values()];
// Determine online predicate based on instanceId
const isUserOnline = (user: lib.UserDetails) => {
if (instanceId !== undefined) {
return user.instances && user.instances.has(instanceId);
}
return user.instances && user.instances.size > 0;
};
// Prepare filters for roles column
const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id }));
const durationFilters = [
{ text: "Online", value: "online" },
{ text: "24h", value: "24h" },
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
{ text: "Anytime", value: "any" },
];
function onFilterDuration(
value: string,
record: lib.UserDetails,
calculateDuration: (user: lib.UserDetails, instanceId?: number) => number | undefined,
) {
const online = isUserOnline(record, instanceId);
if (value === "online") {
return online;
}
// Online players should appear in all buckets
if (online) {
return true;
}
const ts = calculateDuration(record, instanceId);
if (!ts) { return false; }
const diff = Date.now() - ts;
switch (value) {
case "24h":
return diff <= 24 * 60 * 60 * 1000;
case "7d":
return diff <= 7 * 24 * 60 * 60 * 1000;
case "30d":
return diff <= 30 * 24 * 60 * 60 * 1000;
case "any":
return true;
default:
return false;
}
}
const columns: any[] = [
{
title: "Name",
key: "name",
render: (_: any, user: lib.UserDetails) => (
<Space>
{user.name}
<span>
{user.isAdmin && <Tag color="gold">Admin</Tag>}
{user.isWhitelisted && <Tag>Whitelisted</Tag>}
{user.isBanned && <Tag color="red">Banned</Tag>}
</span>
</Space>
<Username user={user} withStatus />
),
defaultSortOrder: "ascend",
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name
.toLowerCase()
.includes((value as string).toLowerCase()),
filterDropdownProps: {
onOpenChange: (open: boolean) => open && setTimeout(() => searchInput.current?.select(), 100),
},
filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: {
selectedKeys: string[],
setSelectedKeys: (keys: string[]) => void,
confirm: FilterDropdownProps["confirm"],
clearFilters: FilterDropdownProps["clearFilters"],
}) => (
<div style={{ padding: 4 }} onKeyDown={(e) => e.stopPropagation()}>
<Input.Search
allowClear
ref={searchInput}
placeholder={"Search username"}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys([e.target.value])}
onSearch={() => confirm({ closeDropdown: false })}
onClear={() => {
clearFilters?.({ closeDropdown: false });
confirm({ closeDropdown: true });
}}
/>
</div>
),
onFilter: onFilterUser,
filterDropdownProps,
filterDropdown,
},
{
title: "Roles",
@@ -117,123 +112,53 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
))
),
},
];
// Helper to get last seen timestamp
function getLastSeenTimestamp(user: lib.UserDetails): number | undefined {
const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats;
if (!stats) { return undefined; }
if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) {
return stats.lastLeaveAt.getTime();
}
if (stats.lastJoinAt) {
return stats.lastJoinAt.getTime();
}
return undefined;
}
if (instanceId !== undefined) {
columns.push(
{
title: "Play Time",
key: "playTime",
render: (_: any, user: lib.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
},
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => {
const instanceStats = user.instanceStats.get(instanceId);
return instanceStats?.joinCount ?? 0;
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
},
},
{
title: "First Seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = a.instanceStats.get(instanceId);
const statsB = b.instanceStats.get(instanceId);
const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0;
const firstSeenB = statsB?.firstJoinAt?.getTime() ?? 0;
return firstSeenA - firstSeenB;
},
},
);
} else {
columns.push(
{
title: "Online time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => (user.playerStats?.onlineTimeMs
? formatDuration(user.playerStats.onlineTimeMs)
: null),
// eslint-disable-next-line max-len
sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0),
responsive: ["lg"],
},
{
title: "First seen",
key: "firstSeen",
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b),
},
);
}
columns.push(
{
title: "Last seen",
title: "Online Time",
key: "onlineTime",
render: (_: any, user: lib.UserDetails) => {
const userStats = getUserStats(user, instanceId);
return userStats?.onlineTimeMs ? formatDuration(userStats.onlineTimeMs) : "-";
},
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
const statsB = getUserStats(b, instanceId);
return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0);
},
},
{
title: "Join Count",
key: "joinCount",
render: (_: any, user: lib.UserDetails) => (
getUserStats(user, instanceId)?.joinCount ?? 0
),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => {
const statsA = getUserStats(a, instanceId);
const statsB = getUserStats(b, instanceId);
return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0);
},
},
{
title: "First Seen",
key: "firstSeen",
filterMultiple: false,
filters: durationFilters,
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateFirstSeen),
render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b, instanceId, instanceId),
},
{
title: "Last Seen",
key: "lastSeen",
filterMultiple: false,
filters: durationFilters,
defaultFilteredValue: onlyOnline ? ["online"] : undefined,
filters: [
{ text: "Online", value: "online" },
{ text: "24h", value: "24h" },
{ text: "7d", value: "7d" },
{ text: "30d", value: "30d" },
],
onFilter: (value: string | number | boolean, record: lib.UserDetails) => {
const online = isUserOnline(record);
if (value === "online") {
return online;
}
// Online players should appear in all buckets
if (online) {
return true;
}
const ts = getLastSeenTimestamp(record);
if (!ts) { return false; }
const diff = Date.now() - ts;
switch (value) {
case "24h":
return diff <= 24 * 60 * 60 * 1000;
case "7d":
return diff <= 7 * 24 * 60 * 60 * 1000;
case "30d":
return diff <= 30 * 24 * 60 * 60 * 1000;
default:
return false;
}
},
onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen),
sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId),
render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId),
responsive: ["lg"],
// Responsive breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847
// responsive: ["lg"],
},
);
];
const defaultPagination = pagination === undefined
? {
@@ -252,6 +177,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
pagination={defaultPagination}
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
onRow={(user) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest("a")) {
+3
View File
@@ -0,0 +1,3 @@
<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 0C7.16 0 0 7.16 0 16C0 23.08 4.58 29.06 10.94 31.18C11.74 31.32 12.04 30.84 12.04 30.42C12.04 30.04 12.02 28.78 12.02 27.44C8 28.18 6.96 26.46 6.64 25.56C6.46 25.1 5.68 23.68 5 23.3C4.44 23 3.64 22.26 4.98 22.24C6.24 22.22 7.14 23.4 7.44 23.88C8.88 26.3 11.18 25.62 12.1 25.2C12.24 24.16 12.66 23.46 13.12 23.06C9.56 22.66 5.84 21.28 5.84 15.16C5.84 13.42 6.46 11.98 7.48 10.86C7.32 10.46 6.76 8.82 7.64 6.62C7.64 6.62 8.98 6.2 12.04 8.26C13.32 7.9 14.68 7.72 16.04 7.72C17.4 7.72 18.76 7.9 20.04 8.26C23.1 6.18 24.44 6.62 24.44 6.62C25.32 8.82 24.76 10.46 24.6 10.86C25.62 11.98 26.24 13.4 26.24 15.16C26.24 21.3 22.5 22.66 18.94 23.06C19.52 23.56 20.02 24.52 20.02 26.02C20.02 28.16 20 29.88 20 30.42C20 30.84 20.3 31.34 21.1 31.18C27.42 29.06 32 23.06 32 16C32 7.16 24.84 0 16 0V0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

+16 -4
View File
@@ -4,11 +4,21 @@ export { default as notify, notifyErrorHandler } from "./util/notify";
export { default as BaseWebPlugin } from "./BaseWebPlugin";
export * from "./util/websocket";
export * from "./model/instance";
export * from "./model/host";
export * from "./model/locale";
export * from "./model/item_metadata";
export * from "./model/account";
export * from "./model/export_locale";
export * from "./model/export_prototype_metadata";
export * from "./model/host";
export * from "./model/instance";
export * from "./model/is_dragging";
export * from "./model/mod_pack";
export * from "./model/mods";
export * from "./model/roles";
export * from "./model/saves";
export * from "./model/system";
export * from "./model/user";
export * from "./store/export_locale_stores";
export * from "./store/export_prototype_metadata_store";
export { default as ControlContext } from "./components/ControlContext";
@@ -30,3 +40,5 @@ export { default as SavesList } from "./components/SavesList";
export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
@@ -0,0 +1,13 @@
import type { ModPack } from "@clusterio/lib";
import { useCallback, useSyncExternalStore } from "react";
import { exportLocaleStore } from "../store/export_locale_stores";
export function useExportLocale(modPack: ModPack | undefined) {
const localePath = modPack?.exportManifest?.assets["locale"];
const subscribe = useCallback(
(callback: () => void) => exportLocaleStore.subscribe(modPack?.id, localePath, callback),
[modPack?.id, localePath],
);
return useSyncExternalStore(subscribe, () => exportLocaleStore.getSnapshot(modPack?.id));
}
@@ -1,27 +0,0 @@
import { useEffect, useContext, useState } from "react";
import * as lib from "@clusterio/lib";
import ControlContext from "../components/ControlContext";
let exportManifestCache: lib.ExportManifest|null = null;
let loaded = false;
export function useExportManifest(): lib.ExportManifest|null {
const control = useContext(ControlContext);
let [exportManifest, setExportManifest] = useState<lib.ExportManifest|null>(exportManifestCache);
useEffect(() => {
async function load() {
let modPack = await control.send(new lib.ModPackGetDefaultRequest());
if (modPack.exportManifest) {
exportManifestCache = modPack.exportManifest;
setExportManifest(exportManifestCache);
}
loaded = true;
}
if (!loaded) {
load();
}
}, []);
return exportManifest;
}
@@ -0,0 +1,17 @@
import { useCallback, useSyncExternalStore } from "react";
import { ModPack } from "@clusterio/lib";
import { exportPrototypeMetadataStore } from "../store/export_prototype_metadata_store";
/**
* Load the unified spritesheet metadata and inject CSS rules for every icon.
* Required for {@link FactorioIcon}.
*/
export function useExportPrototypeMetadata(modPack: ModPack | undefined) {
const subscribe = useCallback(
(callback: () => void) => exportPrototypeMetadataStore.subscribe(modPack, callback),
[modPack]
);
return useSyncExternalStore(subscribe, () => exportPrototypeMetadataStore.getSnapshot(modPack?.id));
}
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more