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.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. 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