reflex-packager 0.1.5 → 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 -40
  4. data/.github/workflows/tag.yml +11 -28
  5. data/.github/workflows/test.yml +16 -28
  6. data/.gitignore +2 -0
  7. data/ChangeLog.md +35 -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 -17
  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 +66 -26
  18. data/lib/reflex/packager/config.rb +239 -38
  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 -92
  25. data/lib/reflex/packager/platform.rb +289 -10
  26. data/lib/reflex/packager/profile.rb +54 -17
  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 -17
  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 +54 -7
  48. data/test/test_packager_app_store_connect.rb +108 -0
  49. data/test/test_packager_cli.rb +40 -5
  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 -87
  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
@@ -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
@@ -0,0 +1,127 @@
1
+ require_relative 'data_file'
2
+
3
+
4
+ module Reflex
5
+
6
+
7
+ module Packager
8
+
9
+
10
+ # Lets require, require_relative and load read the Ruby scripts of an app
11
+ # from its data file, as the instruction sequences the packager compiled
12
+ # them into, named as the scripts but with .rbc, in place of the scripts,
13
+ # which the app directory has none of. The other files of the app are in
14
+ # it as they are.
15
+ #
16
+ # The scripts are compiled with their paths relative to the app directory,
17
+ # which a package runs in, so that __FILE__ and __dir__ have them as they
18
+ # are, and the others, as the libraries, are required as they are.
19
+ #
20
+ # A package carries this file in the reflex library of it, with DataFile
21
+ # beside, which is all it requires.
22
+ #
23
+ module DataLoader
24
+
25
+ DATA_FILE = 'data.bin'
26
+
27
+ class << self
28
+
29
+ # Reads the scripts of the app in +dir+ from the data file in it.
30
+ #
31
+ # @param [String] dir the app directory, which has data.bin
32
+ #
33
+ def setup(dir)
34
+ # as the current directory has it, for the paths to compare
35
+ @dir = File.realpath dir
36
+ @data = DataFile.new File.join(@dir, DATA_FILE)
37
+
38
+ version = @data.read('.ruby-version') || '(unknown)'
39
+ raise "the data file is for Ruby #{version}, not #{RUBY_VERSION}" if
40
+ version != RUBY_VERSION
41
+
42
+ Kernel.prepend Require
43
+ end
44
+
45
+ attr_reader :dir, :data
46
+
47
+ # The name of the script in the data file for +feature+ to require, or
48
+ # nil.
49
+ #
50
+ def find(feature)
51
+ feature = feature.to_s
52
+ return script(File.expand_path feature) if
53
+ File.absolute_path?(feature) || feature.start_with?('./', '../')
54
+
55
+ $LOAD_PATH.each do |load_path|
56
+ name = script File.expand_path(feature, load_path.to_s)
57
+ return name if name
58
+ end
59
+ nil
60
+ end
61
+
62
+ # The name of the script in the data file at +path+, or nil.
63
+ #
64
+ def script(path)
65
+ path = File.expand_path path, @dir
66
+ return nil unless path.start_with? "#{@dir}/"
67
+ name = path[(@dir.size + 1)..].sub(/\.rb\z/, '') + '.rbc'
68
+ @data.include?(name) ? name : nil
69
+ end
70
+
71
+ # Requires the script +name+ in the data file unless it is already.
72
+ #
73
+ def require_script(name)
74
+ path = File.join @dir, name.sub(/\.rbc\z/, '.rb')
75
+ return false if $LOADED_FEATURES.include? path
76
+
77
+ # before running it, as require does, for a script it requires to
78
+ # require it back
79
+ $LOADED_FEATURES << path
80
+ begin
81
+ load_script name
82
+ rescue Exception
83
+ $LOADED_FEATURES.delete path
84
+ raise
85
+ end
86
+ true
87
+ end
88
+
89
+ def load_script(name)
90
+ RubyVM::InstructionSequence.load_from_binary(@data.read name).eval
91
+ end
92
+
93
+ end# self
94
+
95
+ module Require
96
+
97
+ private
98
+
99
+ def require(feature)
100
+ name = DataLoader.find feature
101
+ name ? DataLoader.require_script(name) : super
102
+ end
103
+
104
+ def require_relative(feature)
105
+ location = caller_locations(1, 1).first
106
+ base = File.expand_path(location.absolute_path || location.path, DataLoader.dir)
107
+ path = File.expand_path feature, File.dirname(base)
108
+ name = DataLoader.script path
109
+ name ? DataLoader.require_script(name) : require(path)
110
+ end
111
+
112
+ def load(file, wrap = false)
113
+ name = DataLoader.script File.expand_path(file)
114
+ return super unless name
115
+ DataLoader.load_script name
116
+ true
117
+ end
118
+
119
+ end# Require
120
+
121
+ end# DataLoader
122
+
123
+
124
+ end# Packager
125
+
126
+
127
+ end# Reflex
@@ -0,0 +1,282 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'open3'
4
+ require 'rbconfig'
5
+ require 'reflex/packager/library'
6
+
7
+
8
+ module Reflex
9
+
10
+
11
+ module Packager
12
+
13
+
14
+ # Gems shipped with an app as they are: the ones of the Gemfile of the
15
+ # app, and the standard gems, which were default gems once. Included by a
16
+ # platform, which puts them where its load path finds them, and has the
17
+ # profile and the config they are looked for with.
18
+ #
19
+ module Gems
20
+
21
+ # Default gems that became bundled gems, as the NEWS of each Ruby says.
22
+ # Gem::BUNDLED_GEMS::SINCE, which Ruby 3.3 and later have, misses some
23
+ # of them; it adds the ones of Ruby versions newer than this list.
24
+ #
25
+ FORMER_DEFAULT_GEMS = [
26
+ *%w[rexml rss], # 3.0
27
+ *%w[net-ftp net-imap net-pop net-smtp matrix prime debug], # 3.1
28
+ *%w[racc], # 3.3
29
+ *%w[mutex_m getoptlong base64 bigdecimal observer abbrev
30
+ resolv-replace rinda drb nkf syslog csv], # 3.4
31
+ *%w[ostruct pstore benchmark logger rdoc win32ole irb reline
32
+ readline fiddle win32-registry] # 4.0
33
+ ]
34
+
35
+ # Former default gems for development rather than for an app to run
36
+ # with, left out of the standard gems; with what they depend on, as
37
+ # prism and rbs for irb and rdoc, they would add over 10 MB. An app
38
+ # that runs with them lists them in its Gemfile.
39
+ #
40
+ DEVELOPMENT_GEMS = %w[irb rdoc debug]
41
+
42
+ # Extensions of the binaries a gem with a native extension has.
43
+ #
44
+ NATIVE_EXTS = %w[.so .bundle .dll]
45
+
46
+ # Names of the libraries and the gems, put on the load path in this
47
+ # order: a library before the ones it depends on, as bundler puts them,
48
+ # and the libraries before the gems, which the runtime is not to be
49
+ # hidden by.
50
+ #
51
+ def lib_names()
52
+ [*library_names.reverse, *gem_names]
53
+ end
54
+
55
+ # Names of the gems copy_gems copies, put on the load path in this order.
56
+ #
57
+ def gem_names()
58
+ [*('bundler' if gemfile), *gem_dirs.keys]
59
+ end
60
+
61
+ # Require paths of the gems by name: the ones of the Gemfile of the app,
62
+ # then the standard gems.
63
+ #
64
+ def gem_dirs()
65
+ @gem_dirs ||= standard_gem_dirs
66
+ .reject {|name, _| app_gem_dirs.key? name}
67
+ .then {app_gem_dirs.merge _1}
68
+ end
69
+
70
+ # Require paths of the gems in the default group of the Gemfile of the
71
+ # app, by name. A gem with a native extension, where it is not
72
+ # supported, is left out if the Ruby a platform embeds has it.
73
+ #
74
+ def app_gem_dirs()
75
+ @app_gem_dirs ||= gemfile_specs.each.with_object({}) do |spec, dirs|
76
+ name, paths = spec.values_at 'name', 'require_paths'
77
+ next unless shipped? spec
78
+ if !native_gems? && native_gem?(paths)
79
+ next if embedded_gem? name
80
+ raise Error, "gem '#{name}' has a native extension, which is not supported"
81
+ end
82
+ dirs[name] = paths
83
+ end
84
+ end
85
+
86
+ # Require paths of the standard gems installed, by name, leaving out the
87
+ # ones with a native extension where it is not supported.
88
+ #
89
+ def standard_gem_dirs()
90
+ return {} unless standard_gems?
91
+ @standard_gem_dirs ||= standard_specs.each.with_object({}) do |spec, dirs|
92
+ name, paths = spec.values_at 'name', 'require_paths'
93
+ next unless shipped? spec
94
+ next if !native_gems? && native_gem?(paths)
95
+ dirs[name] = paths
96
+ end
97
+ end
98
+
99
+ # The libraries the app runs on: the ones of the profile, and the ones in
100
+ # the Gemfile of the app, with what they depend on.
101
+ #
102
+ def libraries()
103
+ @libraries ||= Library.collect(*gemfile_library_roots, known: profile.libraries)
104
+ .then {profile.libraries + _1}
105
+ end
106
+
107
+ # Native extensions to register (Init_<name>).
108
+ #
109
+ def extensions()
110
+ libraries.filter_map(&:extension)
111
+ end
112
+
113
+ def library_names()
114
+ libraries.map(&:name)
115
+ end
116
+
117
+ private
118
+
119
+ # Whether to ship the standard gems, which the Ruby a platform embeds may
120
+ # have in its standard library already.
121
+ #
122
+ def standard_gems?()
123
+ true
124
+ end
125
+
126
+ # Whether the Ruby a platform embeds has the gem +name+ in its standard
127
+ # library, which the app runs with in place of the one of its Gemfile
128
+ # that cannot be shipped.
129
+ #
130
+ def embedded_gem?(name)
131
+ false
132
+ end
133
+
134
+ # Whether a gem with a native extension can be shipped.
135
+ #
136
+ def native_gems?()
137
+ false
138
+ end
139
+
140
+ def native_gem?(paths)
141
+ paths.any? do |path|
142
+ Dir.glob('**/*', base: path).any? {NATIVE_EXTS.include? File.extname(_1)}
143
+ end
144
+ end
145
+
146
+ # Leaves out bundler, default gems, which are in the standard library
147
+ # already, and the libraries, known by their extension.rb.
148
+ #
149
+ def shipped?(spec)
150
+ name, default_gem, paths = spec.values_at 'name', 'default_gem', 'require_paths'
151
+ return false if name == 'bundler' || default_gem
152
+ library_names.none? do |lib|
153
+ paths.any? {File.file? File.join(_1, lib, 'extension.rb')}
154
+ end
155
+ end
156
+
157
+ # Copies the gems as they are into the directories the block returns for
158
+ # their names, which the platform puts on the load path.
159
+ #
160
+ def copy_gems(&lib_dir)
161
+ gem_dirs.each do |name, paths|
162
+ dest = lib_dir.call name
163
+ FileUtils.mkdir_p dest
164
+ paths.each {FileUtils.cp_r File.join(_1, '.'), dest}
165
+ end
166
+ write_bundler_setup lib_dir.call('bundler') if gemfile
167
+ end
168
+
169
+ # An app with a Gemfile may require bundler/setup, which would find the
170
+ # bundler of the standard library, and no Gemfile to set up with. The
171
+ # gems are on the load path already, so the one found first does
172
+ # nothing.
173
+ #
174
+ def write_bundler_setup(dir)
175
+ path = File.join dir, 'bundler', 'setup.rb'
176
+ FileUtils.mkdir_p File.dirname(path)
177
+ File.write path, "# the gems of the Gemfile are on the load path already\n"
178
+ end
179
+
180
+ # Directories of the gems in the Gemfile which are libraries, known by
181
+ # their extension.rb, and not the ones of the profile; the ones RUBYLIB
182
+ # points to come first, as for the libraries of the profile.
183
+ #
184
+ def gemfile_library_roots()
185
+ names = profile.libraries.map(&:name)
186
+ gemfile_specs.filter_map do |spec|
187
+ name = Library.library_name spec['root']
188
+ next if !name || names.include?(name)
189
+ Library.source_root(spec['name']) || spec['root']
190
+ end
191
+ end
192
+
193
+ def gemfile()
194
+ path = File.join config.dir, 'Gemfile'
195
+ File.file?(path) ? path : nil
196
+ end
197
+
198
+ SPECS_TO_JSON = <<~RUBY
199
+ def specs_to_json(specs)
200
+ $stdout.write JSON.generate(specs.map {|s|
201
+ {
202
+ name: s.name,
203
+ root: s.full_gem_path,
204
+ default_gem: s.default_gem?,
205
+ require_paths: s.full_require_paths
206
+ }
207
+ })
208
+ end
209
+ RUBY
210
+
211
+ GEMFILE_SPECS = <<~RUBY
212
+ require 'bundler'
213
+ require 'json'
214
+ #{SPECS_TO_JSON}
215
+ Bundler.ui = Bundler::UI::Silent.new
216
+ begin
217
+ specs_to_json Bundler.definition.specs_for([:default])
218
+ rescue Bundler::BundlerError => e
219
+ abort e.message
220
+ end
221
+ RUBY
222
+
223
+ STANDARD_SPECS = <<~RUBY
224
+ require 'json'
225
+ #{SPECS_TO_JSON}
226
+ names, excluded = JSON.parse ARGV[0]
227
+ begin
228
+ require 'bundled_gems'
229
+ names |= Gem::BUNDLED_GEMS::SINCE.keys
230
+ rescue LoadError
231
+ end
232
+ names -= excluded
233
+
234
+ # with what they depend on, which may be neither a default gem nor
235
+ # one of these
236
+ specs = {}
237
+ deps = names.map {Gem::Dependency.new _1}
238
+ until deps.empty?
239
+ dep = deps.shift
240
+ next if specs.key? dep.name
241
+ specs[dep.name] = begin
242
+ dep.to_spec
243
+ rescue Gem::LoadError
244
+ end
245
+ deps.concat specs[dep.name].runtime_dependencies if specs[dep.name]
246
+ end
247
+ specs_to_json specs.values.compact
248
+ RUBY
249
+
250
+ # The gems of the default group of the Gemfile of the app.
251
+ #
252
+ def gemfile_specs()
253
+ return [] unless gemfile
254
+ run_ruby GEMFILE_SPECS, env: {'BUNDLE_GEMFILE' => gemfile},
255
+ error: 'failed to resolve the Gemfile (bundle install?)'
256
+ end
257
+
258
+ # The standard gems installed, and the gems they depend on.
259
+ #
260
+ def standard_specs()
261
+ run_ruby STANDARD_SPECS, JSON.generate([FORMER_DEFAULT_GEMS, DEVELOPMENT_GEMS]),
262
+ error: 'failed to look for the standard gems'
263
+ end
264
+
265
+ # Runs the script in a ruby of its own, out of the reach of any bundler
266
+ # this process runs under, which would hide the gems out of its bundle.
267
+ #
268
+ def run_ruby(script, *args, env: {}, error:)
269
+ env = ENV.keys.grep(/\ABUNDLER?_/).to_h {[_1, nil]}
270
+ .merge 'RUBYOPT' => nil, **env
271
+ out, err, status = Open3.capture3 env, RbConfig.ruby, '-e', script, *args
272
+ raise Error, "#{error}: #{err.strip}" unless status.success?
273
+ JSON.parse out
274
+ end
275
+
276
+ end# Gems
277
+
278
+
279
+ end# Packager
280
+
281
+
282
+ end# Reflex
@@ -0,0 +1,120 @@
1
+ require 'rubygems'
2
+
3
+
4
+ module Reflex
5
+
6
+
7
+ module Packager
8
+
9
+
10
+ # A library a packaged app runs on: a gem with lib/<name>/extension.rb, as
11
+ # xot, rays and reflex are, whose Ruby code a package bundles and whose
12
+ # extension, if it has lib/<name>/ext.rb, a package links.
13
+ #
14
+ class Library
15
+
16
+ # @param [String] name the directory of the library under lib/
17
+ # @param [String] root the directory of the gem
18
+ #
19
+ def initialize(name, root)
20
+ @name, @root = name, root
21
+ end
22
+
23
+ attr_reader :name, :root
24
+
25
+ # The extension to register (Init_<extension>), or nil.
26
+ #
27
+ def extension()
28
+ return nil unless File.file? File.join(root, 'lib', name, 'ext.rb')
29
+ "#{name.tr '-', '_'}_ext"
30
+ end
31
+
32
+ def to_s()
33
+ name
34
+ end
35
+
36
+ # Returns the libraries the gems at +roots+ depend on, as their gemspecs
37
+ # have them, and the gems themselves, each after the ones it depends on,
38
+ # leaving out the +known+ ones.
39
+ #
40
+ # A gem is looked for in the directories on the load path first, as
41
+ # RUBYLIB points to the ones of a repository being worked on, and then in
42
+ # the installed gems.
43
+ #
44
+ # @param [Array<String>] roots the directories of the gems
45
+ # @param [Array<Library>] known the libraries collected already
46
+ #
47
+ # @return [Array<Library>] libraries
48
+ #
49
+ def self.collect(*roots, known: [])
50
+ libraries, visited, sources = [], known.to_h {[_1.root, true]}, source_roots
51
+ visit = -> (root, spec) do
52
+ name = library_name root
53
+ next if !name || visited[root]
54
+ visited[root] = true
55
+ spec.runtime_dependencies.each {|dep| find_gem(dep, sources)&.then {visit.call(*_1)}}
56
+ libraries << new(name, root)
57
+ end
58
+ roots.map {File.expand_path _1}.each {visit.call _1, spec_of(_1)}
59
+ libraries
60
+ end
61
+
62
+ # The directory of the gem +name+ on the load path, which is not an
63
+ # installed one, as RUBYLIB points to, or nil.
64
+ #
65
+ def self.source_root(name)
66
+ source_roots[name]
67
+ end
68
+
69
+ # The directory under lib/ with extension.rb, or nil.
70
+ #
71
+ def self.library_name(root)
72
+ path = Dir.glob(File.join root, 'lib', '*', 'extension.rb').first
73
+ path && File.basename(File.dirname path)
74
+ end
75
+
76
+ # Returns [root, spec] of the gem +dep+ asks for, or nil.
77
+ #
78
+ def self.find_gem(dep, sources)
79
+ root = sources[dep.name]
80
+ return [root, spec_of(root)] if root
81
+ spec = dep.to_spec
82
+ [spec.full_gem_path, spec]
83
+ rescue Gem::LoadError
84
+ nil
85
+ end
86
+
87
+ # The gems on the load path which are not installed ones, by name.
88
+ #
89
+ def self.source_roots()
90
+ $LOAD_PATH
91
+ .map {File.dirname File.expand_path(_1.to_s)}
92
+ .uniq
93
+ .reject {installed? _1}
94
+ .select {Dir.glob(File.join _1, '*.gemspec').any?}
95
+ .to_h {[spec_of(_1).name, _1]}
96
+ end
97
+
98
+ def self.spec_of(root)
99
+ installed = Gem::Specification.find {_1.full_gem_path == root}
100
+ return installed if installed
101
+ path = Dir.glob(File.join root, '*.gemspec').first or
102
+ raise Error, "no gemspec in '#{root}'"
103
+ # a gemspec may run git ls-files, which lists the files of the repository it is run in
104
+ Dir.chdir(root) {Gem::Specification.load path} or
105
+ raise Error, "failed to load '#{path}'"
106
+ end
107
+
108
+ def self.installed?(root)
109
+ Gem.path.any? {root.start_with? File.join(_1, 'gems', '')}
110
+ end
111
+
112
+ private_class_method :find_gem, :source_roots, :spec_of, :installed?
113
+
114
+ end# Library
115
+
116
+
117
+ end# Packager
118
+
119
+
120
+ end# Reflex