reflex-packager 0.2.0 → 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/package.yml +310 -0
  3. data/.github/workflows/release-gem.yml +2 -49
  4. data/.github/workflows/tag.yml +11 -28
  5. data/.github/workflows/test.yml +16 -37
  6. data/.gitignore +2 -0
  7. data/ChangeLog.md +28 -0
  8. data/Gemfile.lock +26 -0
  9. data/README.md +232 -27
  10. data/Rakefile +21 -0
  11. data/VERSION +1 -1
  12. data/bin/reflex +2 -32
  13. data/examples/hello/icon.png +0 -0
  14. data/examples/hello/main.rb +18 -0
  15. data/examples/hello/reflex.yml +3 -0
  16. data/lib/reflex/packager/app_store_connect.rb +162 -0
  17. data/lib/reflex/packager/cli.rb +42 -9
  18. data/lib/reflex/packager/config.rb +233 -36
  19. data/lib/reflex/packager/data_compiler.rb +59 -0
  20. data/lib/reflex/packager/data_file.rb +183 -0
  21. data/lib/reflex/packager/data_loader.rb +127 -0
  22. data/lib/reflex/packager/gems.rb +282 -0
  23. data/lib/reflex/packager/library.rb +120 -0
  24. data/lib/reflex/packager/macos.rb +592 -94
  25. data/lib/reflex/packager/platform.rb +289 -10
  26. data/lib/reflex/packager/profile.rb +38 -26
  27. data/lib/reflex/packager/profiles/reflex.rb +43 -0
  28. data/lib/reflex/packager/rake.rb +115 -0
  29. data/lib/reflex/packager/stores/app_store.rb +60 -0
  30. data/lib/reflex/packager/stores/itchio.rb +53 -0
  31. data/lib/reflex/packager/templates/macos/AppStore.entitlements.erb +8 -0
  32. data/lib/reflex/packager/templates/macos/AppStoreExportOptions.plist.erb +14 -0
  33. data/lib/reflex/packager/templates/macos/ExportOptions.plist.erb +20 -0
  34. data/lib/reflex/packager/templates/macos/InfoPlist.strings.erb +5 -0
  35. data/lib/reflex/packager/templates/macos/boot.rb.erb +29 -0
  36. data/lib/reflex/packager/templates/macos/compile.rb.erb +7 -0
  37. data/lib/reflex/packager/templates/macos/main.mm.erb +10 -7
  38. data/lib/reflex/packager/templates/macos/project.yml.erb +60 -3
  39. data/lib/reflex/packager/templates/windows/app.manifest.erb +25 -0
  40. data/lib/reflex/packager/templates/windows/app.rc.erb +38 -0
  41. data/lib/reflex/packager/templates/windows/boot.rb.erb +32 -0
  42. data/lib/reflex/packager/templates/windows/main.cpp.erb +47 -0
  43. data/lib/reflex/packager/templates/windows/runtime.manifest.erb +7 -0
  44. data/lib/reflex/packager/windows.rb +370 -0
  45. data/lib/reflex/packager.rb +18 -1
  46. data/reflex-packager.gemspec +8 -7
  47. data/test/helper.rb +50 -5
  48. data/test/test_packager_app_store_connect.rb +108 -0
  49. data/test/test_packager_cli.rb +38 -3
  50. data/test/test_packager_config.rb +166 -27
  51. data/test/test_packager_data_file.rb +96 -0
  52. data/test/test_packager_data_loader.rb +114 -0
  53. data/test/test_packager_library.rb +81 -0
  54. data/test/test_packager_macos.rb +714 -86
  55. data/test/test_packager_platform.rb +16 -0
  56. data/test/test_packager_rake.rb +165 -0
  57. data/test/test_packager_stores_app_store.rb +66 -0
  58. data/test/test_packager_stores_itchio.rb +38 -0
  59. data/test/test_packager_windows.rb +685 -0
  60. metadata +72 -15
  61. data/.github/workflows/utils.rb +0 -127
  62. data/lib/reflex/packager/templates/macos/Podfile.erb +0 -30
@@ -14,6 +14,16 @@ module Reflex
14
14
  #
15
15
  class CLI
16
16
 
17
+ # The options to notarize the app by, and the steps of the notarization
18
+ # they take.
19
+ #
20
+ NOTARIZE_OPTIONS = {
21
+ notarize: %i[submit export],
22
+ 'notarize-submit': %i[submit],
23
+ 'notarize-wait': %i[wait],
24
+ 'notarize-export': %i[export]
25
+ }
26
+
17
27
  def initialize(profile)
18
28
  @profile = profile
19
29
  end
@@ -61,20 +71,43 @@ module Reflex
61
71
  end
62
72
 
63
73
  def package(argv)
64
- profile = @profile
65
- argv, params = parse argv, "Usage: #{profile.command} package [options] [DIR]" do
66
- on '--platform PLATFORM', 'target platform (default: macos)'
67
- on '--config PATH', "config file path (default: DIR/#{profile.config_files.first})"
68
- on '--generate-only', 'generate project files but do not build'
69
- on '--verbose', 'verbose output'
74
+ profile, platform = @profile, Packager.default_platform
75
+ argv, params = parse argv, "Usage: #{profile.command} package [options] [DIR]" do
76
+ on '--platform PLATFORM',
77
+ "target platform: #{PLATFORMS.keys.join ' or '} (default: #{platform})"
78
+ on '--config PATH', "config file path (default: DIR/#{profile.config_files.first})"
79
+ on '--generate-only', 'generate project files but do not build'
80
+ on '--pack', 'put the files of the app together in a data file, for a release'
81
+ on '--generic', 'package the app signed with Developer ID, also with --appstore'
82
+ on '--appstore', 'package the app for the Mac App Store, only it unless --generic'
83
+ on '--notarize', 'have Apple check the app with Developer ID, on macos'
84
+ on '--notarize-submit', 'only submit the app to be notarized, not waiting'
85
+ on '--notarize-wait ID', 'only wait for the notarization ID, by the API key'
86
+ on '--notarize-export', 'only wait for the notarization submitted and export the app'
87
+ on '--verbose', 'verbose output'
70
88
  end
71
89
 
90
+ notarize = NOTARIZE_OPTIONS.filter_map {|key, value| value if params[key]}
91
+ raise Error, "only one of #{NOTARIZE_OPTIONS.keys.map {"--#{_1}"}.join ', '}" if
92
+ notarize.size > 1
93
+
94
+ id = params[:'notarize-wait']
95
+ raise Error, '--notarize-wait packages nothing, not with --appstore' if
96
+ id && params[:appstore]
97
+ return AppStoreConnect.new.wait_until_notarized id if id
98
+
72
99
  dir = argv.shift || '.'
73
- platform = (params[:platform] || 'macos').to_sym
100
+ platform = (params[:platform] || platform).to_sym
74
101
  klass = PLATFORMS[platform] || raise(Error, "unknown platform: '#{platform}'")
75
102
  config = Config.load profile, dir, params[:config]
76
- klass.new(config, verbose: params[:verbose])
77
- .package generate_only: params[:'generate-only']
103
+ klass.new(
104
+ config,
105
+ verbose: params[:verbose],
106
+ pack: params[:pack],
107
+ notarize: notarize.first,
108
+ generic: params[:generic],
109
+ appstore: params[:appstore]
110
+ ).package generate_only: params[:'generate-only']
78
111
  end
79
112
 
80
113
  private
@@ -1,3 +1,4 @@
1
+ require 'pathname'
1
2
  require 'yaml'
2
3
 
3
4
 
@@ -20,15 +21,15 @@ module Reflex
20
21
  {
21
22
  name: name,
22
23
  bundle_id: hash[:bundle_id] || default_bundle_id(profile, name),
23
- version: '0.1.0',
24
+ version: (hash[:version].nil? && version_file(dir)) || '0.1.0',
25
+ copyright: nil,
24
26
  main: profile.main,
25
27
  icon: nil,
26
28
  files: nil,
27
- pods: {
28
- :cruby => {tag: nil, branch: nil, git: nil, path: nil},
29
- profile.pod_key => {tag: nil, branch: nil, git: nil, path: nil}
30
- },
31
- macos: MacOSConfig.defaults
29
+ excludes: nil,
30
+ macos: MacOSConfig.defaults,
31
+ windows: WindowsConfig.defaults,
32
+ upload: UploadConfig.defaults
32
33
  }
33
34
  end
34
35
 
@@ -51,40 +52,68 @@ module Reflex
51
52
  def initialize(profile, dir, hash = nil, stderr: $stderr)
52
53
  raise Error, "no such directory: '#{dir}'" unless File.directory? dir
53
54
 
54
- @profile = profile
55
- @dir = File.expand_path dir
56
- hash = symbolize_keys hash || {}
57
- hash = validate_input hash, Config.defaults(profile, @dir, hash), stderr: stderr
58
-
59
- @name = hash[:name] .to_s
60
- @bundle_id = hash[:bundle_id].to_s
61
- @version = hash[:version] .to_s
62
- @main = hash[:main] .to_s
63
- @icon = hash[:icon] &.to_s
55
+ @profile = profile
56
+ @dir = File.expand_path dir
57
+ hash = symbolize_keys hash || {}
58
+ localizations = hash.delete :localizations
59
+ hash = validate_input hash, Config.defaults(profile, @dir, hash), stderr: stderr
60
+
61
+ # YAML reads 1.10 as a number, the same as 1.1
62
+ raise Error, "version #{hash[:version]} is read as a number, quote it" if
63
+ hash[:version].is_a? Float
64
+
65
+ @name = hash[:name] .to_s
66
+ @bundle_id = hash[:bundle_id] .to_s
67
+ @version = hash[:version] .to_s
68
+ @copyright = hash[:copyright]&.to_s
69
+ @main = hash[:main] .to_s
70
+ @icon = hash[:icon] &.to_s
64
71
  @files = hash[:files]&.then {Array(_1).map(&:to_s)}
65
- @pods = hash[:pods].transform_values &:compact
66
- @macos = MacOSConfig.new hash[:macos]
72
+ @excludes = glob_patterns hash[:excludes], :excludes
73
+ @macos = MacOSConfig.new hash[:macos]
74
+ @windows = WindowsConfig.new hash[:windows]
75
+ @upload = UploadConfig.new hash[:upload]
76
+
77
+ @localizations = localize localizations, stderr: stderr
67
78
  validate
68
79
  end
69
80
 
70
- attr_reader :profile, :dir, :name, :bundle_id, :version, :main, :icon,
71
- :files, :macos, :pods
81
+ attr_reader :profile, :dir, :name, :bundle_id, :version, :copyright,
82
+ :main, :icon, :files, :excludes, :macos, :windows, :upload
83
+
84
+ # The values that differ by language, in English, the language of the
85
+ # ones out of localizations, and in each language of the localizations
86
+ # with the English ones for what they leave out.
87
+ #
88
+ # @return [Hash<String, Hash>] {language => {name:, copyright:}}
89
+ #
90
+ attr_reader :localizations
91
+
92
+ # The version to show: the numbers of the version, up to three.
93
+ #
94
+ def display_version()
95
+ @version.split('.').first(3).join('.')
96
+ end
97
+
98
+ # The version to tell builds apart with: the version, a fourth number
99
+ # of which tells a build apart from another of the same version.
100
+ #
101
+ def build_version()
102
+ @version
103
+ end
72
104
 
73
105
  # Returns paths to be bundled into the application, relative to the
74
- # project directory.
106
+ # project directory: the files only, with the ones in the directories
107
+ # matched, for excludes to take out a file in them.
75
108
  #
76
109
  # @return [Array<String>] relative paths
77
110
  #
78
111
  def app_files()
79
- excludes = EXCLUDES + @profile.config_files
80
- [@main, *@files]
81
- .flat_map {Dir.glob _1, base: @dir}
82
- .reject {_1.start_with?('.') || excludes.include?(_1)}
83
- .uniq
84
- .sort
112
+ excluded = glob_files [*EXCLUDES, *@profile.config_files, *@excludes]
113
+ (glob_files([@main, *@files]) - excluded).reject {_1.start_with? '.'}.sort
85
114
  end
86
115
 
87
- EXCLUDES = %w[build dist]
116
+ EXCLUDES = %w[dist]
88
117
 
89
118
  private
90
119
 
@@ -101,6 +130,19 @@ module Reflex
101
130
  "#{profile.bundle_id_prefix}.#{id}"
102
131
  end
103
132
 
133
+ # The version in the VERSION file of the app, which the config may leave
134
+ # it to, as the gems have theirs in one.
135
+ #
136
+ def self.version_file(dir)
137
+ path = File.join dir, 'VERSION'
138
+ return nil unless File.file? path
139
+ # without the BOM an editor on Windows may write
140
+ version = File.read(path, encoding: 'bom|utf-8').strip
141
+ raise Error, "invalid version in the VERSION file: '#{version}'" if
142
+ version !~ VERSION_FORMAT
143
+ version
144
+ end
145
+
104
146
  def symbolize_keys(hash)
105
147
  hash.map {|k, v|
106
148
  [
@@ -110,6 +152,26 @@ module Reflex
110
152
  }.to_h
111
153
  end
112
154
 
155
+ # A language tag, as ja or zh-Hans.
156
+ #
157
+ LANGUAGE_FORMAT = /\A[a-z]{2,3}(-[A-Za-z0-9]{2,8})*\z/
158
+
159
+ def localize(localizations, stderr: nil)
160
+ english = {name: @name, copyright: @copyright}
161
+ raise Error, "not a Hash: '/localizations'" unless
162
+ localizations.nil? || localizations.is_a?(Hash)
163
+
164
+ (localizations || {}).each.with_object({'en' => english}) do |(lang, hash), result|
165
+ lang = lang.to_s
166
+ raise Error, "invalid language: '#{lang}'" if lang !~ LANGUAGE_FORMAT
167
+ raise Error, "'en' is the language of the values out of localizations" if
168
+ lang == 'en'
169
+ result[lang] = validate_input(
170
+ hash, english, parent: "/localizations/#{lang}", stderr: stderr
171
+ ).transform_values {_1&.to_s}
172
+ end
173
+ end
174
+
113
175
  def validate_input(hash, defaults, parent: '', stderr: nil)
114
176
  return defaults.dup if hash.nil?
115
177
  raise Error, "not a Hash: '#{parent}'" unless hash.is_a? Hash
@@ -127,16 +189,50 @@ module Reflex
127
189
  end
128
190
  end
129
191
 
192
+ def glob_patterns(patterns, key)
193
+ return nil if patterns.nil?
194
+ Array(patterns).map do |pattern|
195
+ raise Error, "not a glob pattern in '/#{key}': #{pattern.inspect}" if
196
+ pattern.is_a?(Hash) || pattern.is_a?(Array) || pattern.to_s.strip.empty?
197
+ pattern.to_s
198
+ end
199
+ end
200
+
201
+ # The files matched by +patterns+ in the project directory, and the
202
+ # ones in the directories matched but the dotfiles, as lib/a.rb for
203
+ # ./lib/a.rb too, for the excludes to be compared with.
204
+ #
205
+ def glob_files(patterns)
206
+ patterns
207
+ .flat_map {Dir.glob _1, base: @dir}
208
+ .map {Pathname(_1).cleanpath.to_s}
209
+ .flat_map {|path|
210
+ # by base:, not to take the name of the directory as a pattern
211
+ [path, *Dir.glob('**/*', base: File.join(@dir, path)).map {File.join path, _1}]
212
+ }
213
+ .reject {File.directory? File.join(@dir, _1)}
214
+ .uniq
215
+ end
216
+
217
+ # Numbers only, which macOS and Windows compare versions by.
218
+ #
219
+ VERSION_FORMAT = /\A\d+(\.\d+)*\z/
220
+
130
221
  def validate()
222
+ # the name of the .app on macos, which can not have a path separator
223
+ raise Error, "invalid name: '#{@name}'" if @name.strip.empty? || @name =~ %r{[/:]}
224
+
131
225
  raise Error, "invalid bundle_id: '#{@bundle_id}'" if
132
226
  @bundle_id !~ /\A[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+\z/
133
227
 
134
- raise Error, "invalid version: '#{@version}'" if
135
- @version !~ /\A\d+(\.\d+)*\z/
228
+ raise Error, "invalid version: '#{@version}'" if @version !~ VERSION_FORMAT
136
229
 
137
230
  raise Error, "main script not found: '#{@main}'" if
138
231
  !File.file?(File.join @dir, @main)
139
232
 
233
+ raise Error, "main script excluded: '#{@main}'" if
234
+ @excludes && (glob_files([@main]) - glob_files(@excludes)).empty?
235
+
140
236
  raise Error, "icon not found: '#{@icon}'" if
141
237
  @icon && !File.file?(File.join @dir, @icon)
142
238
  end
@@ -148,10 +244,20 @@ module Reflex
148
244
  #
149
245
  class MacOSConfig
150
246
 
247
+ # The keys of codesign in the config, and the environment variables
248
+ # that override them.
249
+ #
250
+ CODESIGN_ENV_KEYS = {
251
+ identity: 'APPLE_CODESIGN_IDENTITY',
252
+ team_id: 'APPLE_TEAM_ID'
253
+ }
254
+
151
255
  def self.defaults()
152
256
  {
153
257
  deployment_target: '11.0',
154
- archs: 'arm64',
258
+ archs: %w[arm64 x86_64],
259
+ cruby: nil,
260
+ category: nil,
155
261
  codesign: {
156
262
  identity: '-',
157
263
  team_id: nil
@@ -160,22 +266,113 @@ module Reflex
160
266
  end
161
267
 
162
268
  def initialize(hash)
163
- @deployment_target = hash[:deployment_target] .to_s
164
- @archs = Array(hash[:archs]).map &:to_s
165
- @codesign_identity = hash[:codesign][:identity].to_s
166
- @codesign_team_id = hash[:codesign][:team_id]&.to_s
269
+ codesign = hash[:codesign].merge CODESIGN_ENV_KEYS.transform_values {env _1}.compact
270
+
271
+ @deployment_target = hash[:deployment_target] .to_s
272
+ @archs = Array(hash[:archs]).map &:to_s
273
+ @cruby = hash[:cruby] &.to_s
274
+ @category = hash[:category] &.to_s
275
+ @codesign_identity = codesign[:identity] .to_s
276
+ @codesign_team_id = codesign[:team_id] &.to_s
167
277
  validate
168
278
  end
169
279
 
170
- attr_reader :deployment_target, :archs, :codesign_identity, :codesign_team_id
280
+ # The version of CRuby, or the path of a checkout of the cruby
281
+ # repository, to embed.
282
+ #
283
+ attr_reader :cruby
284
+
285
+ attr_reader :deployment_target, :archs
286
+
287
+ # The category of the app in its Info.plist, as public.app-category.games,
288
+ # which Finder groups apps by and the App Store requires.
289
+ #
290
+ attr_reader :category
291
+
292
+ # The identity and the team to sign the app with, which
293
+ # APPLE_CODESIGN_IDENTITY and APPLE_TEAM_ID override, for the name of
294
+ # the developer in the identity not to be in the config.
295
+ #
296
+ attr_reader :codesign_identity, :codesign_team_id
171
297
 
172
298
  def validate()
173
299
  raise Error, "invalid archs: '#{@archs}'" if @archs.empty?
300
+ raise Error, "invalid category: '#{@category}'" if
301
+ @category && @category !~ /\Apublic\.app-category\.[a-z\-]+\z/
302
+ end
303
+
304
+ private
305
+
306
+ # The environment variable +name+, or nil if empty, as a secret of
307
+ # GitHub Actions not set is.
308
+ #
309
+ def env(name)
310
+ ENV[name].then {_1 unless _1.to_s.empty?}
174
311
  end
175
312
 
176
313
  end# MacOSConfig
177
314
 
178
315
 
316
+ # Windows specific configuration.
317
+ #
318
+ class WindowsConfig
319
+
320
+ def self.defaults()
321
+ {
322
+ console: false
323
+ }
324
+ end
325
+
326
+ def initialize(hash)
327
+ @console = hash[:console]
328
+ validate
329
+ end
330
+
331
+ # Whether the executable keeps a console window, which shows what an app
332
+ # prints and the errors it dies of while it is being worked on.
333
+ #
334
+ def console?()
335
+ @console
336
+ end
337
+
338
+ def validate()
339
+ raise Error, "invalid console: '#{@console}'" unless
340
+ [true, false].include? @console
341
+ end
342
+
343
+ end# WindowsConfig
344
+
345
+
346
+ # Configuration of the stores to upload the app to.
347
+ #
348
+ class UploadConfig
349
+
350
+ def self.defaults()
351
+ {
352
+ itchio: {
353
+ project: nil
354
+ }
355
+ }
356
+ end
357
+
358
+ def initialize(hash)
359
+ @itchio_project = hash[:itchio][:project]&.to_s
360
+ validate
361
+ end
362
+
363
+ # The project of itch.io to upload the app to, as user/game of
364
+ # https://user.itch.io/game, which butler takes.
365
+ #
366
+ attr_reader :itchio_project
367
+
368
+ def validate()
369
+ raise Error, "invalid upload.itchio.project: '#{@itchio_project}'" if
370
+ @itchio_project && @itchio_project !~ %r{\A[a-z0-9_\-]+/[a-z0-9_\-]+\z}
371
+ end
372
+
373
+ end# UploadConfig
374
+
375
+
179
376
  # Raised on invalid configuration or packaging failure.
180
377
  #
181
378
  class Error < StandardError; end
@@ -0,0 +1,59 @@
1
+ require_relative 'data_file'
2
+
3
+
4
+ module Reflex
5
+
6
+
7
+ module Packager
8
+
9
+
10
+ # Compiles the Ruby scripts of an app into instruction sequences for its
11
+ # data file, which DataLoader reads them from, named as the scripts but
12
+ # with .rbc.
13
+ #
14
+ # They are compiled by the Ruby running this, which has to be the one a
15
+ # package runs them on, with their paths relative to the app directory,
16
+ # as the one the package runs in has them.
17
+ #
18
+ # A package carries none of this, which requires nothing but DataFile,
19
+ # for an app to compile its scripts with the Ruby in it.
20
+ #
21
+ module DataCompiler
22
+
23
+ class << self
24
+
25
+ # The names of the Ruby scripts of the app in +dir+.
26
+ #
27
+ def scripts(dir)
28
+ Dir.glob('**/*.rb', base: dir).select {File.file? File.join(dir, _1)}.sort
29
+ end
30
+
31
+ # Compiles the Ruby scripts of the app in +dir+ into the files of its
32
+ # data file, with the version of the Ruby.
33
+ #
34
+ def compile(dir)
35
+ data = scripts(dir).to_h do |name|
36
+ source = File.read File.join(dir, name), encoding: Encoding::UTF_8
37
+ iseq = RubyVM::InstructionSequence.compile source, name, name
38
+ [name.sub(/\.rb\z/, '.rbc'), iseq.to_binary]
39
+ end
40
+ data['.ruby-version'] = RUBY_VERSION
41
+ data
42
+ end
43
+
44
+ # Writes the data file at +path+ with the Ruby scripts of the app in
45
+ # +dir+ compiled, all of them before writing it.
46
+ #
47
+ def write(dir, path)
48
+ DataFile.write path, compile(dir)
49
+ end
50
+
51
+ end# self
52
+
53
+ end# DataCompiler
54
+
55
+
56
+ end# Packager
57
+
58
+
59
+ end# Reflex
@@ -0,0 +1,183 @@
1
+ module Reflex
2
+
3
+
4
+ module Packager
5
+
6
+
7
+ # A file the files of an app are put together in, as app/data.bin, which
8
+ # a package reads them from as they are, not extracting them.
9
+ #
10
+ # It has a header, the bytes of the files one after another, and the
11
+ # index of them, each byte of which but the header's is substituted with
12
+ # another by a table from the seed in the header, which keeps them from
13
+ # being read as they are, though it is no encryption.
14
+ #
15
+ # A package carries this file to read its data file, so it requires
16
+ # nothing.
17
+ #
18
+ class DataFile
19
+
20
+ MAGIC = 'RXDF'
21
+
22
+ VERSION = 1
23
+
24
+ # The magic, the version, the seed, and the offset and the size of the
25
+ # index.
26
+ #
27
+ HEADER = 'a4 L< L< Q< Q<'
28
+
29
+ HEADER_SIZE = 28
30
+
31
+ class << self
32
+
33
+ # Writes the files to +path+.
34
+ #
35
+ # @param [String] path data file path
36
+ # @param [Hash<String, String>] files {name => bytes}, a name as 'dir/file.png'
37
+ # @param [Integer] seed of the substitution of the bytes, a random one by default
38
+ #
39
+ def write(path, files, seed: rand(1..0xffffffff))
40
+ tr_substitutes seed # raises on an invalid one before writing
41
+
42
+ index, offset = [], HEADER_SIZE
43
+ files.each do |name, bytes|
44
+ raise ArgumentError, "invalid name: #{name.inspect}" if
45
+ name =~ /[\t\n]/ || name.start_with?('/') || name.split('/').include?('..')
46
+ index << "#{name}\t#{offset}\t#{bytes.bytesize}\n"
47
+ offset += bytes.bytesize
48
+ end
49
+ index = index.join.b
50
+
51
+ File.open path, 'wb' do |f|
52
+ f.write [MAGIC, VERSION, seed, offset, index.bytesize].pack HEADER
53
+ files.each_value {f.write encode(_1, seed)}
54
+ f.write encode(index, seed)
55
+ end
56
+ end
57
+
58
+ def open(path, &block)
59
+ file = new path
60
+ return file unless block
61
+ begin
62
+ block.call file
63
+ ensure
64
+ file.close
65
+ end
66
+ end
67
+
68
+ # +bytes+ with each of them substituted with another by the table from
69
+ # +seed+.
70
+ #
71
+ def encode(bytes, seed)
72
+ bytes.b.tap {_1.tr! TR_BYTES, tr_substitutes(seed)}
73
+ end
74
+
75
+ # +bytes+ encoded, with the substitution undone.
76
+ #
77
+ def decode(bytes, seed)
78
+ bytes.b.tap {_1.tr! tr_substitutes(seed), TR_BYTES}
79
+ end
80
+
81
+ private
82
+
83
+ def tr_substitutes(seed)
84
+ (@tr_substitutes ||= {})[seed] ||= tr_bytes substitutes(seed)
85
+ end
86
+
87
+ # The bytes in an order shuffled by xorshift from +seed+, which is the
88
+ # same on any Ruby, as Random may not be.
89
+ #
90
+ def substitutes(seed)
91
+ raise ArgumentError, "invalid seed: #{seed}" unless (1..0xffffffff).include? seed
92
+ x = seed
93
+ 255.downto(1).with_object((0..255).to_a) {|i, bytes|
94
+ x ^= (x << 13) & 0xffffffff
95
+ x ^= x >> 17
96
+ x ^= (x << 5) & 0xffffffff
97
+ j = x % (i + 1)
98
+ bytes[i], bytes[j] = bytes[j], bytes[i]
99
+ }.pack('C*')
100
+ end
101
+
102
+ # String#tr takes '\', '-' and '^' as escape, range and negation.
103
+ #
104
+ def tr_bytes(bytes)
105
+ bytes.gsub(/[\\\-^]/n) {"\\#{_1}"}
106
+ end
107
+
108
+ end# self
109
+
110
+ TR_BYTES = tr_bytes (0..255).map(&:chr).join.b
111
+
112
+ def initialize(path)
113
+ @path = path
114
+ @file = File.open path, 'rb'
115
+ @mutex = Mutex.new
116
+ @seed, @index = read_index
117
+ rescue
118
+ @file&.close
119
+ raise
120
+ end
121
+
122
+ attr_reader :path
123
+
124
+ def names()
125
+ @index.keys
126
+ end
127
+
128
+ def include?(name)
129
+ @index.key? name
130
+ end
131
+
132
+ # The size of the file in bytes, or nil.
133
+ #
134
+ def size(name)
135
+ @index[name]&.last
136
+ end
137
+
138
+ # The bytes of the file, or nil.
139
+ #
140
+ def read(name)
141
+ offset, size = @index[name]
142
+ return nil unless offset
143
+ DataFile.decode read_at(offset, size), @seed
144
+ end
145
+
146
+ def close()
147
+ @file.close
148
+ end
149
+
150
+ private
151
+
152
+ def read_at(offset, size)
153
+ @mutex.synchronize do
154
+ @file.seek offset
155
+ @file.read(size) || '' # nil at the end
156
+ end
157
+ end
158
+
159
+ def read_index()
160
+ header = read_at 0, HEADER_SIZE
161
+ raise "not a data file: #{@path}" unless header.bytesize == HEADER_SIZE
162
+
163
+ magic, version, seed, offset, size = header.unpack HEADER
164
+ raise "not a data file: #{@path}" unless magic == MAGIC
165
+ raise "unknown data file version: #{version}" unless version == VERSION
166
+
167
+ index = DataFile.decode(read_at(offset, size), seed)
168
+ .force_encoding(Encoding::UTF_8)
169
+ .lines(chomp: true)
170
+ .to_h {
171
+ name, offset, size = _1.split "\t"
172
+ [name, [offset.to_i, size.to_i]]
173
+ }
174
+ return seed, index
175
+ end
176
+
177
+ end# DataFile
178
+
179
+
180
+ end# Packager
181
+
182
+
183
+ end# Reflex