factorix 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- metadata +431 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
module SerDes
|
|
5
|
+
# Serialize data to binary format
|
|
6
|
+
#
|
|
7
|
+
# This class provides methods to serialize various data types to Factorio's
|
|
8
|
+
# binary file format, following the specifications documented in the Factorio wiki.
|
|
9
|
+
class Serializer
|
|
10
|
+
# @!parse
|
|
11
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
12
|
+
# attr_reader :logger
|
|
13
|
+
include Import[:logger]
|
|
14
|
+
|
|
15
|
+
# Create a new Serializer instance
|
|
16
|
+
#
|
|
17
|
+
# @param stream [IO] An IO-like object that responds to #write
|
|
18
|
+
# @param logger [Dry::Logger::Dispatcher] optional logger
|
|
19
|
+
# @raise [ArgumentError] If the stream doesn't respond to #write
|
|
20
|
+
def initialize(stream, logger: nil)
|
|
21
|
+
super(logger:)
|
|
22
|
+
raise ArgumentError, "can't write to the given argument" unless stream.respond_to?(:write)
|
|
23
|
+
|
|
24
|
+
@stream = stream
|
|
25
|
+
logger.debug "Initializing Serializer"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Write raw bytes to the stream
|
|
29
|
+
#
|
|
30
|
+
# @param data [String] Binary data to write
|
|
31
|
+
# @raise [ArgumentError] If data is nil
|
|
32
|
+
# @return [void]
|
|
33
|
+
def write_bytes(data)
|
|
34
|
+
raise ArgumentError if data.nil?
|
|
35
|
+
return if data.empty?
|
|
36
|
+
|
|
37
|
+
@stream.write(data)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write an unsigned 8-bit integer
|
|
41
|
+
#
|
|
42
|
+
# @param uint8 [Integer] 8-bit unsigned integer
|
|
43
|
+
# @return [void]
|
|
44
|
+
def write_u8(uint8) = write_bytes([uint8].pack("C"))
|
|
45
|
+
|
|
46
|
+
# Write an unsigned 16-bit integer
|
|
47
|
+
#
|
|
48
|
+
# @param uint16 [Integer] 16-bit unsigned integer
|
|
49
|
+
# @return [void]
|
|
50
|
+
def write_u16(uint16) = write_bytes([uint16].pack("v"))
|
|
51
|
+
|
|
52
|
+
# Write an unsigned 32-bit integer
|
|
53
|
+
#
|
|
54
|
+
# @param uint32 [Integer] 32-bit unsigned integer
|
|
55
|
+
# @return [void]
|
|
56
|
+
def write_u32(uint32) = write_bytes([uint32].pack("V"))
|
|
57
|
+
|
|
58
|
+
# Write a space-optimized 16-bit unsigned integer
|
|
59
|
+
#
|
|
60
|
+
# @see https://wiki.factorio.com/Data_types#Space_Optimized
|
|
61
|
+
# @param uint16 [Integer] 16-bit unsigned integer
|
|
62
|
+
# @return [void]
|
|
63
|
+
def write_optim_u16(uint16)
|
|
64
|
+
if uint16 < 0xFF
|
|
65
|
+
write_u8(uint16 & 0xFF)
|
|
66
|
+
else
|
|
67
|
+
write_u8(0xFF)
|
|
68
|
+
write_u16(uint16)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Write a space-optimized 32-bit unsigned integer
|
|
73
|
+
#
|
|
74
|
+
# @see https://wiki.factorio.com/Data_types#Space_Optimized
|
|
75
|
+
# @param uint32 [Integer] 32-bit unsigned integer
|
|
76
|
+
# @return [void]
|
|
77
|
+
def write_optim_u32(uint32)
|
|
78
|
+
if uint32 < 0xFF
|
|
79
|
+
write_u8(uint32 & 0xFF)
|
|
80
|
+
else
|
|
81
|
+
write_u8(0xFF)
|
|
82
|
+
write_u32(uint32)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Write a boolean value
|
|
87
|
+
#
|
|
88
|
+
# @param bool [Boolean] Boolean value
|
|
89
|
+
# @return [void]
|
|
90
|
+
def write_bool(bool) = write_u8(bool ? 0x01 : 0x00)
|
|
91
|
+
|
|
92
|
+
# Write a string
|
|
93
|
+
#
|
|
94
|
+
# @param str [String] String to write (must be UTF-8 encoded)
|
|
95
|
+
# @raise [ArgumentError] If the string is not UTF-8 encoded
|
|
96
|
+
# @return [void]
|
|
97
|
+
def write_str(str)
|
|
98
|
+
if str.encoding != Encoding::UTF_8 && !(str.encoding == Encoding::ASCII_8BIT && str.force_encoding(Encoding::UTF_8).valid_encoding?)
|
|
99
|
+
logger.debug("String encoding error", expected: "UTF-8", got: str.encoding.name)
|
|
100
|
+
raise ArgumentError, "String must be UTF-8 encoded, got #{str.encoding}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
write_optim_u32(str.bytesize)
|
|
104
|
+
write_bytes(str.b)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Write a string property
|
|
108
|
+
#
|
|
109
|
+
# @see https://wiki.factorio.com/Property_tree#String
|
|
110
|
+
# @param str [String] String to write
|
|
111
|
+
# @return [void]
|
|
112
|
+
def write_str_property(str)
|
|
113
|
+
if str.empty?
|
|
114
|
+
write_bool(true)
|
|
115
|
+
else
|
|
116
|
+
write_bool(false)
|
|
117
|
+
write_str(str)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Write a double-precision floating point number
|
|
122
|
+
#
|
|
123
|
+
# @see https://wiki.factorio.com/Property_tree#Number
|
|
124
|
+
# @param dbl [Float] Double-precision floating point number
|
|
125
|
+
# @return [void]
|
|
126
|
+
def write_double(dbl) = write_bytes([dbl].pack("d"))
|
|
127
|
+
|
|
128
|
+
# Write a GameVersion object
|
|
129
|
+
#
|
|
130
|
+
# @param game_version [GameVersion] GameVersion object
|
|
131
|
+
# @return [void]
|
|
132
|
+
def write_game_version(game_version) = game_version.to_a.each {|u16| write_u16(u16) }
|
|
133
|
+
|
|
134
|
+
# Write a MODVersion object
|
|
135
|
+
#
|
|
136
|
+
# @param mod_version [MODVersion] MODVersion object
|
|
137
|
+
# @return [void]
|
|
138
|
+
def write_mod_version(mod_version) = mod_version.to_a.each {|u16| write_optim_u16(u16) }
|
|
139
|
+
|
|
140
|
+
# Write a list
|
|
141
|
+
#
|
|
142
|
+
# @see https://wiki.factorio.com/Property_tree#List
|
|
143
|
+
# @param list [Array] List of objects
|
|
144
|
+
# @return [void]
|
|
145
|
+
def write_list(list)
|
|
146
|
+
logger.debug("Writing list", size: list.size)
|
|
147
|
+
write_optim_u32(list.size)
|
|
148
|
+
list.each {|e| write_property_tree(e) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Write a dictionary
|
|
152
|
+
#
|
|
153
|
+
# @see https://wiki.factorio.com/Property_tree#Dictionary
|
|
154
|
+
# @param dict [Hash] Dictionary of key-value pairs
|
|
155
|
+
# @return [void]
|
|
156
|
+
def write_dictionary(dict)
|
|
157
|
+
logger.debug("Writing dictionary", size: dict.size)
|
|
158
|
+
write_u32(dict.size)
|
|
159
|
+
dict.each do |(key, value)|
|
|
160
|
+
write_str_property(key)
|
|
161
|
+
write_property_tree(value)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Write a signed long integer (8 bytes)
|
|
166
|
+
#
|
|
167
|
+
# @param long [Integer] Signed long integer
|
|
168
|
+
# @return [void]
|
|
169
|
+
def write_long(long) = write_bytes([long].pack("q<"))
|
|
170
|
+
|
|
171
|
+
# Write an unsigned long integer (8 bytes)
|
|
172
|
+
#
|
|
173
|
+
# @param ulong [Integer] Unsigned long integer
|
|
174
|
+
# @return [void]
|
|
175
|
+
def write_unsigned_long(ulong) = write_bytes([ulong].pack("Q<"))
|
|
176
|
+
|
|
177
|
+
# Write a property tree
|
|
178
|
+
#
|
|
179
|
+
# @param obj [Object] Object to write
|
|
180
|
+
# @raise [Factorix::UnknownPropertyType] If the object type is not supported
|
|
181
|
+
# @return [void]
|
|
182
|
+
def write_property_tree(obj)
|
|
183
|
+
logger.debug("Writing property tree", type: obj.class.name)
|
|
184
|
+
case obj
|
|
185
|
+
when nil
|
|
186
|
+
# Type 0 - None (null value)
|
|
187
|
+
write_u8(0)
|
|
188
|
+
write_bool(false)
|
|
189
|
+
when true, false
|
|
190
|
+
# Type 1 - Boolean
|
|
191
|
+
write_u8(1)
|
|
192
|
+
write_bool(false)
|
|
193
|
+
write_bool(obj)
|
|
194
|
+
when Float
|
|
195
|
+
# Type 2 - Number (double)
|
|
196
|
+
write_u8(2)
|
|
197
|
+
write_bool(false)
|
|
198
|
+
write_double(obj)
|
|
199
|
+
when String
|
|
200
|
+
# Type 3 - String
|
|
201
|
+
write_u8(3)
|
|
202
|
+
write_bool(false)
|
|
203
|
+
write_str_property(obj)
|
|
204
|
+
when Array
|
|
205
|
+
# Type 4 - List
|
|
206
|
+
write_u8(4)
|
|
207
|
+
write_bool(false)
|
|
208
|
+
write_list(obj)
|
|
209
|
+
when Hash
|
|
210
|
+
# Type 5 - Dictionary
|
|
211
|
+
write_u8(5)
|
|
212
|
+
write_bool(false)
|
|
213
|
+
write_dictionary(obj)
|
|
214
|
+
when SignedInteger
|
|
215
|
+
# Type 6 - Signed integer
|
|
216
|
+
write_u8(6)
|
|
217
|
+
write_bool(false)
|
|
218
|
+
write_long(obj.__getobj__)
|
|
219
|
+
when UnsignedInteger
|
|
220
|
+
# Type 7 - Unsigned integer
|
|
221
|
+
write_u8(7)
|
|
222
|
+
write_bool(false)
|
|
223
|
+
write_unsigned_long(obj.__getobj__)
|
|
224
|
+
else
|
|
225
|
+
logger.debug("Unknown property type", type: obj.class.name)
|
|
226
|
+
raise UnknownPropertyType, "Unknown property type: #{obj.class}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
module SerDes
|
|
7
|
+
# Signed integer wrapper
|
|
8
|
+
#
|
|
9
|
+
# This class wraps an Integer value to indicate it was originally stored
|
|
10
|
+
# as a signed integer (Type 6) in Factorio's Property Tree format.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a signed integer
|
|
13
|
+
# value = SignedInteger.new(42)
|
|
14
|
+
# value + 1 # => 43 (behaves like Integer)
|
|
15
|
+
#
|
|
16
|
+
# @example Negative values are allowed
|
|
17
|
+
# value = SignedInteger.new(-5)
|
|
18
|
+
class SignedInteger < SimpleDelegator
|
|
19
|
+
# Create a new SignedInteger
|
|
20
|
+
#
|
|
21
|
+
# @param value [Integer] The integer value
|
|
22
|
+
# @raise [ArgumentError] If value is not an Integer
|
|
23
|
+
def initialize(value)
|
|
24
|
+
raise ArgumentError, "value must be an Integer" unless value.is_a?(Integer)
|
|
25
|
+
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get the underlying integer value
|
|
30
|
+
#
|
|
31
|
+
# @return [Integer] The wrapped integer value
|
|
32
|
+
def value = __getobj__
|
|
33
|
+
|
|
34
|
+
# Compare with another SignedInteger or Integer
|
|
35
|
+
#
|
|
36
|
+
# @param other [SignedInteger, Integer] The value to compare with
|
|
37
|
+
# @return [Boolean] True if equal
|
|
38
|
+
def ==(other)
|
|
39
|
+
case other
|
|
40
|
+
when SignedInteger
|
|
41
|
+
value == other.value
|
|
42
|
+
when Integer
|
|
43
|
+
value == other
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Hash code for use in Hash keys
|
|
50
|
+
#
|
|
51
|
+
# @return [Integer] Hash code
|
|
52
|
+
def hash = [value, :signed].hash
|
|
53
|
+
|
|
54
|
+
# Check if equal (alias for ==)
|
|
55
|
+
alias eql? ==
|
|
56
|
+
|
|
57
|
+
# String representation
|
|
58
|
+
#
|
|
59
|
+
# @return [String] String representation
|
|
60
|
+
def inspect = "#<Factorix::SerDes::SignedInteger:0x%016x value=#{value}>" % object_id
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
module SerDes
|
|
7
|
+
# Unsigned integer wrapper
|
|
8
|
+
#
|
|
9
|
+
# This class wraps a non-negative Integer value to indicate it was originally
|
|
10
|
+
# stored as an unsigned integer (Type 7) in Factorio's Property Tree format.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating an unsigned integer
|
|
13
|
+
# value = UnsignedInteger.new(42)
|
|
14
|
+
# value + 1 # => 43 (behaves like Integer)
|
|
15
|
+
#
|
|
16
|
+
# @example Negative values raise an error
|
|
17
|
+
# UnsignedInteger.new(-5) # => ArgumentError
|
|
18
|
+
class UnsignedInteger < SimpleDelegator
|
|
19
|
+
# Create a new UnsignedInteger
|
|
20
|
+
#
|
|
21
|
+
# @param value [Integer] The integer value (must be non-negative)
|
|
22
|
+
# @raise [ArgumentError] If value is not an Integer
|
|
23
|
+
# @raise [ArgumentError] If value is negative
|
|
24
|
+
def initialize(value)
|
|
25
|
+
raise ArgumentError, "value must be an Integer" unless value.is_a?(Integer)
|
|
26
|
+
raise ArgumentError, "value must be non-negative" if value.negative?
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get the underlying integer value
|
|
32
|
+
#
|
|
33
|
+
# @return [Integer] The wrapped integer value
|
|
34
|
+
def value = __getobj__
|
|
35
|
+
|
|
36
|
+
# Compare with another UnsignedInteger or Integer
|
|
37
|
+
#
|
|
38
|
+
# @param other [UnsignedInteger, Integer] The value to compare with
|
|
39
|
+
# @return [Boolean] True if equal
|
|
40
|
+
def ==(other)
|
|
41
|
+
case other
|
|
42
|
+
when UnsignedInteger
|
|
43
|
+
value == other.value
|
|
44
|
+
when Integer
|
|
45
|
+
value == other
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Hash code for use in Hash keys
|
|
52
|
+
#
|
|
53
|
+
# @return [Integer] Hash code
|
|
54
|
+
def hash = [value, :unsigned].hash
|
|
55
|
+
|
|
56
|
+
# Check if equal (alias for ==)
|
|
57
|
+
alias eql? ==
|
|
58
|
+
|
|
59
|
+
# String representation
|
|
60
|
+
#
|
|
61
|
+
# @return [String] String representation
|
|
62
|
+
def inspect = "#<Factorix::SerDes::UnsignedInteger:0x%016x value=#{value}>" % object_id
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
ServiceCredential = Data.define(:username, :token)
|
|
7
|
+
|
|
8
|
+
# Service credentials for Factorio MOD downloads
|
|
9
|
+
#
|
|
10
|
+
# @see https://wiki.factorio.com/Mod_portal_API
|
|
11
|
+
class ServiceCredential
|
|
12
|
+
# @!attribute [r] username
|
|
13
|
+
# @return [String] Factorio service username
|
|
14
|
+
# @!attribute [r] token
|
|
15
|
+
# @return [String] Factorio service token
|
|
16
|
+
|
|
17
|
+
# Environment variable name for username
|
|
18
|
+
ENV_USERNAME = "FACTORIO_USERNAME"
|
|
19
|
+
private_constant :ENV_USERNAME
|
|
20
|
+
|
|
21
|
+
# Environment variable name for token
|
|
22
|
+
ENV_TOKEN = "FACTORIO_TOKEN"
|
|
23
|
+
private_constant :ENV_TOKEN
|
|
24
|
+
|
|
25
|
+
# Load service credentials with automatic source detection
|
|
26
|
+
#
|
|
27
|
+
# Tries environment variables first, falls back to player-data.json.
|
|
28
|
+
# Raises an error if only one environment variable is set (partial configuration).
|
|
29
|
+
#
|
|
30
|
+
# @return [ServiceCredential] new instance with credentials
|
|
31
|
+
# @raise [CredentialError] if only one of FACTORIO_USERNAME/FACTORIO_TOKEN is set
|
|
32
|
+
# @raise [CredentialError] if credentials are invalid or missing
|
|
33
|
+
def self.load
|
|
34
|
+
username_env = ENV.fetch(ENV_USERNAME, nil)
|
|
35
|
+
token_env = ENV.fetch(ENV_TOKEN, nil)
|
|
36
|
+
|
|
37
|
+
if username_env && token_env
|
|
38
|
+
from_env
|
|
39
|
+
elsif username_env || token_env
|
|
40
|
+
raise CredentialError, "Both #{ENV_USERNAME} and #{ENV_TOKEN} must be set (or neither)"
|
|
41
|
+
else
|
|
42
|
+
runtime = Application[:runtime]
|
|
43
|
+
from_player_data(runtime:)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Create a new ServiceCredential instance from environment variables
|
|
48
|
+
#
|
|
49
|
+
# @return [ServiceCredential] new instance with credentials from environment
|
|
50
|
+
# @raise [CredentialError] if username or token is not set or empty
|
|
51
|
+
def self.from_env
|
|
52
|
+
logger = Application["logger"]
|
|
53
|
+
logger.debug "Loading service credentials from environment"
|
|
54
|
+
|
|
55
|
+
username = ENV.fetch(ENV_USERNAME, nil)
|
|
56
|
+
token = ENV.fetch(ENV_TOKEN, nil)
|
|
57
|
+
|
|
58
|
+
if username.nil?
|
|
59
|
+
logger.error("Failed to load service credentials", reason: "#{ENV_USERNAME} not set")
|
|
60
|
+
raise CredentialError, "#{ENV_USERNAME} environment variable is not set"
|
|
61
|
+
end
|
|
62
|
+
if username.empty?
|
|
63
|
+
logger.error("Failed to load service credentials", reason: "#{ENV_USERNAME} is empty")
|
|
64
|
+
raise CredentialError, "#{ENV_USERNAME} environment variable is empty"
|
|
65
|
+
end
|
|
66
|
+
if token.nil?
|
|
67
|
+
logger.error("Failed to load service credentials", reason: "#{ENV_TOKEN} not set")
|
|
68
|
+
raise CredentialError, "#{ENV_TOKEN} environment variable is not set"
|
|
69
|
+
end
|
|
70
|
+
if token.empty?
|
|
71
|
+
logger.error("Failed to load service credentials", reason: "#{ENV_TOKEN} is empty")
|
|
72
|
+
raise CredentialError, "#{ENV_TOKEN} environment variable is empty"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
logger.info("Service credentials loaded successfully")
|
|
76
|
+
new(username:, token:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Create a new ServiceCredential instance from player-data.json
|
|
80
|
+
#
|
|
81
|
+
# @param runtime [Factorix::Runtime::Base] runtime instance
|
|
82
|
+
# @return [ServiceCredential] new instance with credentials from player-data.json
|
|
83
|
+
# @raise [Errno::ENOENT] if player-data.json does not exist
|
|
84
|
+
# @raise [CredentialError] if username or token is missing in player-data.json
|
|
85
|
+
def self.from_player_data(runtime:)
|
|
86
|
+
logger = Application["logger"]
|
|
87
|
+
logger.debug "Loading service credentials from player-data.json"
|
|
88
|
+
|
|
89
|
+
player_data_path = runtime.player_data_path
|
|
90
|
+
data = JSON.parse(player_data_path.read)
|
|
91
|
+
|
|
92
|
+
username = data["service-username"]
|
|
93
|
+
token = data["service-token"]
|
|
94
|
+
|
|
95
|
+
if username.nil?
|
|
96
|
+
logger.error("Failed to load credentials from player-data.json", reason: "service-username missing")
|
|
97
|
+
raise CredentialError, "service-username is missing in player-data.json"
|
|
98
|
+
end
|
|
99
|
+
if username.empty?
|
|
100
|
+
logger.error("Failed to load credentials from player-data.json", reason: "service-username empty")
|
|
101
|
+
raise CredentialError, "service-username is empty in player-data.json"
|
|
102
|
+
end
|
|
103
|
+
if token.nil?
|
|
104
|
+
logger.error("Failed to load credentials from player-data.json", reason: "service-token missing")
|
|
105
|
+
raise CredentialError, "service-token is missing in player-data.json"
|
|
106
|
+
end
|
|
107
|
+
if token.empty?
|
|
108
|
+
logger.error("Failed to load credentials from player-data.json", reason: "service-token empty")
|
|
109
|
+
raise CredentialError, "service-token is empty in player-data.json"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
logger.info("Service credentials loaded from player-data.json")
|
|
113
|
+
new(username:, token:)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method :new, :[], :from_env, :from_player_data
|
|
117
|
+
|
|
118
|
+
# @return [String] string representation with masked credentials
|
|
119
|
+
def inspect = %[#<#{self.class} username="*****" token="*****">]
|
|
120
|
+
|
|
121
|
+
alias to_s inspect
|
|
122
|
+
|
|
123
|
+
# @param pp [PP] pretty printer
|
|
124
|
+
# @return [void]
|
|
125
|
+
def pretty_print(pp) = pp.text(inspect)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/events"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Factorix
|
|
9
|
+
module Transfer
|
|
10
|
+
# File downloader with caching and progress tracking
|
|
11
|
+
#
|
|
12
|
+
# Downloads files from HTTPS URLs with automatic caching.
|
|
13
|
+
# Uses file locking to prevent concurrent downloads of the same file.
|
|
14
|
+
# HTTP redirects are handled automatically by the HTTP layer.
|
|
15
|
+
# Publishes progress events during download.
|
|
16
|
+
class Downloader
|
|
17
|
+
# @!parse
|
|
18
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
19
|
+
# attr_reader :logger
|
|
20
|
+
# # @return [Cache::FileSystem]
|
|
21
|
+
# attr_reader :cache
|
|
22
|
+
# # @return [HTTP::Client]
|
|
23
|
+
# attr_reader :client
|
|
24
|
+
include Import[:logger, cache: :download_cache, client: :download_http_client]
|
|
25
|
+
include Dry::Events::Publisher[:downloader]
|
|
26
|
+
|
|
27
|
+
register_event("download.started")
|
|
28
|
+
register_event("download.progress")
|
|
29
|
+
register_event("download.completed")
|
|
30
|
+
register_event("cache.hit")
|
|
31
|
+
register_event("cache.miss")
|
|
32
|
+
|
|
33
|
+
# Download a file from the given URL with caching support.
|
|
34
|
+
#
|
|
35
|
+
# If the file exists in cache, it will be copied from cache instead of downloading.
|
|
36
|
+
# If the cached file fails SHA1 verification, it will be invalidated and re-downloaded.
|
|
37
|
+
# If multiple processes attempt to download the same file, only one will download
|
|
38
|
+
# while others wait for the download to complete.
|
|
39
|
+
# HTTP redirects are followed automatically by the HTTP layer.
|
|
40
|
+
#
|
|
41
|
+
# @param url [URI::HTTPS] URL to download from
|
|
42
|
+
# @param output [Pathname] path to save the downloaded file
|
|
43
|
+
# @param expected_sha1 [String, nil] expected SHA1 digest for verification (optional)
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @raise [URLError] if the URL is not HTTPS
|
|
46
|
+
# @raise [HTTPClientError] for 4xx HTTP errors
|
|
47
|
+
# @raise [HTTPServerError] for 5xx HTTP errors
|
|
48
|
+
# @raise [DigestMismatchError] if SHA1 verification fails
|
|
49
|
+
def download(url, output, expected_sha1: nil)
|
|
50
|
+
unless url.is_a?(URI::HTTPS)
|
|
51
|
+
logger.error "Invalid URL: must be HTTPS"
|
|
52
|
+
raise URLError, "URL must be HTTPS"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
logger.info("Starting download", output: output.to_s)
|
|
56
|
+
key = cache.key_for(url.to_s)
|
|
57
|
+
|
|
58
|
+
case try_cache_hit(key, output, expected_sha1:)
|
|
59
|
+
when :hit
|
|
60
|
+
return
|
|
61
|
+
when :miss
|
|
62
|
+
logger.debug("Cache miss, downloading", output: output.to_s)
|
|
63
|
+
publish("cache.miss", output: output.to_s)
|
|
64
|
+
when :corrupted
|
|
65
|
+
logger.debug("Re-downloading after cache invalidation", output: output.to_s)
|
|
66
|
+
publish("cache.miss", output: output.to_s)
|
|
67
|
+
else
|
|
68
|
+
raise RuntimeError, "Unexpected cache state"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
cache.with_lock(key) do
|
|
72
|
+
return if try_cache_hit(key, output, expected_sha1:) == :hit
|
|
73
|
+
|
|
74
|
+
with_temporary_file do |temp_file|
|
|
75
|
+
download_file_with_progress(url, temp_file)
|
|
76
|
+
verify_sha1(temp_file, expected_sha1) if expected_sha1
|
|
77
|
+
cache.store(key, temp_file)
|
|
78
|
+
cache.fetch(key, output)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Download file with progress tracking
|
|
84
|
+
#
|
|
85
|
+
# @param url [URI::HTTPS] URL to download from
|
|
86
|
+
# @param output [Pathname] path to save the downloaded file
|
|
87
|
+
# @return [void]
|
|
88
|
+
private def download_file_with_progress(url, output)
|
|
89
|
+
total_size = nil
|
|
90
|
+
current_size = 0
|
|
91
|
+
|
|
92
|
+
client.get(url) do |response|
|
|
93
|
+
content_length = response["Content-Length"]
|
|
94
|
+
total_size = content_length ? Integer(content_length, 10) : nil
|
|
95
|
+
|
|
96
|
+
publish("download.started", total_size:)
|
|
97
|
+
|
|
98
|
+
output.open("wb") do |file|
|
|
99
|
+
response.read_body do |chunk|
|
|
100
|
+
file.write(chunk)
|
|
101
|
+
current_size += chunk.bytesize
|
|
102
|
+
publish("download.progress", current_size:, total_size:)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
publish("download.completed", total_size: current_size)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Attempt to retrieve file from cache with SHA1 verification.
|
|
111
|
+
#
|
|
112
|
+
# If the cached file exists but fails SHA1 verification, the cache entry
|
|
113
|
+
# is invalidated and :corrupted is returned to trigger re-download.
|
|
114
|
+
#
|
|
115
|
+
# @param key [String] cache key
|
|
116
|
+
# @param output [Pathname] path to save the cached file
|
|
117
|
+
# @param expected_sha1 [String, nil] expected SHA1 digest for verification (optional)
|
|
118
|
+
# @return [Symbol] :hit if cache hit with valid SHA1, :miss if not cached, :corrupted if SHA1 mismatch
|
|
119
|
+
private def try_cache_hit(key, output, expected_sha1:)
|
|
120
|
+
return :miss unless cache.fetch(key, output)
|
|
121
|
+
|
|
122
|
+
logger.info("Cache hit", output: output.to_s)
|
|
123
|
+
verify_sha1(output, expected_sha1) if expected_sha1
|
|
124
|
+
total_size = cache.size(key)
|
|
125
|
+
publish("cache.hit", output: output.to_s, total_size:)
|
|
126
|
+
:hit
|
|
127
|
+
rescue DigestMismatchError => e
|
|
128
|
+
logger.warn("Cache corrupted, invalidating", output: output.to_s, error: e.message)
|
|
129
|
+
cache.delete(key)
|
|
130
|
+
:corrupted
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Verify SHA1 digest of a file
|
|
134
|
+
#
|
|
135
|
+
# @param file [Pathname] file to verify
|
|
136
|
+
# @param expected_sha1 [String] expected SHA1 digest
|
|
137
|
+
# @return [void]
|
|
138
|
+
# @raise [DigestMismatchError] if digest does not match
|
|
139
|
+
private def verify_sha1(file, expected_sha1)
|
|
140
|
+
actual_sha1 = Digest(:SHA1).file(file).hexdigest
|
|
141
|
+
return if actual_sha1 == expected_sha1
|
|
142
|
+
|
|
143
|
+
logger.error("SHA1 digest mismatch", expected: expected_sha1, actual: actual_sha1)
|
|
144
|
+
raise DigestMismatchError, "SHA1 mismatch: expected #{expected_sha1}, got #{actual_sha1}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Create a temporary file for downloading, ensuring cleanup after use.
|
|
148
|
+
#
|
|
149
|
+
# @yield [Pathname] the temporary file path
|
|
150
|
+
# @return [void]
|
|
151
|
+
private def with_temporary_file
|
|
152
|
+
dir = Pathname(Dir.mktmpdir("factorix"))
|
|
153
|
+
temp_file = dir.join("download")
|
|
154
|
+
temp_file.binwrite("")
|
|
155
|
+
yield temp_file
|
|
156
|
+
ensure
|
|
157
|
+
temp_file&.unlink if temp_file&.exist?
|
|
158
|
+
dir&.rmdir if dir&.exist?
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|