ocran 1.4.5 → 1.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +74 -0
  3. data/README.md +296 -9
  4. data/ext/extconf.rb +5 -1
  5. data/lib/ocran/aot_toolchain.rb +154 -0
  6. data/lib/ocran/command_output.rb +8 -1
  7. data/lib/ocran/compatibility_scan.rb +414 -0
  8. data/lib/ocran/cosmo_toolchain.rb +29 -7
  9. data/lib/ocran/dir_builder.rb +88 -38
  10. data/lib/ocran/direction.rb +232 -33
  11. data/lib/ocran/ed_resource.rb +113 -0
  12. data/lib/ocran/ed_resource_builder.rb +199 -0
  13. data/lib/ocran/file_path_set.rb +9 -5
  14. data/lib/ocran/gem_spec_queryable.rb +7 -3
  15. data/lib/ocran/host_config_helper.rb +11 -1
  16. data/lib/ocran/inno_setup_script_builder.rb +24 -14
  17. data/lib/ocran/launcher_batch_builder.rb +8 -11
  18. data/lib/ocran/npm_package.rb +106 -0
  19. data/lib/ocran/option.rb +433 -35
  20. data/lib/ocran/pe_imports.rb +96 -0
  21. data/lib/ocran/pe_resources.rb +226 -0
  22. data/lib/ocran/refine_pathname.rb +29 -32
  23. data/lib/ocran/roundhouse_builder.rb +316 -0
  24. data/lib/ocran/runner.rb +96 -11
  25. data/lib/ocran/runtime_environment.rb +14 -6
  26. data/lib/ocran/runtime_sdk.rb +252 -0
  27. data/lib/ocran/spinel_builder.rb +183 -0
  28. data/lib/ocran/spinel_compatibility.rb +149 -0
  29. data/lib/ocran/static_runtime/boot.rb +443 -0
  30. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  31. data/lib/ocran/static_runtime_builder.rb +814 -0
  32. data/lib/ocran/stub_builder.rb +70 -25
  33. data/lib/ocran/version.rb +1 -1
  34. data/lib/ocran/wasm_builder.rb +570 -0
  35. data/lib/ocran/wasm_compatibility.rb +198 -0
  36. data/lib/ocran/windows_command_escaping.rb +21 -0
  37. data/lib/ocran/zip_payload_builder.rb +18 -9
  38. data/lib/ocran/zip_writer.rb +48 -9
  39. data/src/Makefile +18 -2
  40. data/src/Makefile.mswin +65 -0
  41. data/src/inst_dir.c +34 -6
  42. data/src/path_utils.c +144 -0
  43. data/src/script_info.c +5 -0
  44. data/src/stub.c +77 -19
  45. data/src/stub.rc +1 -1
  46. data/src/system_utils.c +276 -155
  47. data/src/system_utils.h +46 -3
  48. data/src/system_utils_posix.c +243 -148
  49. data/src/unpack.c +9 -0
  50. metadata +23 -5
@@ -2,13 +2,14 @@
2
2
  require "pathname"
3
3
  require "fileutils"
4
4
  require_relative "build_constants"
5
+ require_relative "windows_command_escaping"
5
6
 
6
7
  module Ocran
7
8
  # Builder that outputs all files to a plain directory instead of a self-extracting
8
9
  # executable. A launch script (`.sh` on POSIX, `.bat` on Windows) is written at
9
10
  # the root of the directory so the packaged app can be started directly.
10
11
  class DirBuilder
11
- include BuildConstants
12
+ include BuildConstants, WindowsCommandEscaping
12
13
 
13
14
  WINDOWS = Gem.win_platform?
14
15
 
@@ -17,8 +18,20 @@ module Ocran
17
18
  # by Direction to build the optional wrapper executable.
18
19
  attr_reader :env, :exec_args
19
20
 
20
- def initialize(path)
21
+ # +script_name+ names the launch script (without extension); it defaults
22
+ # to the name of the script that is executed. Direction passes the
23
+ # user's script, because what is executed can be a generated launcher
24
+ # (see Direction#generate_rubyopt_launcher).
25
+ #
26
+ # +chdir_before+ and +chdir_exe_dir+ mirror the stub flags of the same
27
+ # names (--chdir-first, --chdir-exe-dir): the launch script changes into
28
+ # the directory of the packed script, or into its own directory, which
29
+ # is where the output directory keeps the application.
30
+ def initialize(path, script_name: nil, chdir_before: false, chdir_exe_dir: false)
21
31
  @path = Pathname(path)
32
+ @script_name = script_name
33
+ @chdir_before = chdir_before
34
+ @chdir_exe_dir = chdir_exe_dir
22
35
  @path.mkpath
23
36
  @env = {}
24
37
  @exec_args = nil
@@ -55,17 +68,33 @@ module Ocran
55
68
  @exec_args = [image.to_s, script.to_s, argv.map(&:to_s)]
56
69
  end
57
70
 
58
- # Create a zip archive from a source directory.
71
+ # PowerShell command for create_zip on Windows. The paths come in through
72
+ # the environment rather than being spliced into the command, so no
73
+ # character in them (a quote in a user name, brackets) can change its
74
+ # meaning; -LiteralPath keeps wildcards in the source path literal.
75
+ POWERSHELL_ZIP_COMMAND =
76
+ "$ErrorActionPreference = 'Stop'; " \
77
+ "Get-ChildItem -LiteralPath $env:OCRAN_ZIP_SOURCE -Force | " \
78
+ "Compress-Archive -DestinationPath $env:OCRAN_ZIP_DESTINATION"
79
+
80
+ # Create a zip archive from a source directory, replacing any archive
81
+ # already at +zip_path+.
59
82
  # Uses the `zip` command on POSIX and PowerShell on Windows.
60
83
  def self.create_zip(zip_path, source_dir)
61
84
  zip_path = File.expand_path(zip_path.to_s)
85
+ # Both tools add to an existing archive rather than replace it (zip
86
+ # keeps entries the new tree no longer has; Compress-Archive refuses
87
+ # without -Force), so start from nothing.
88
+ FileUtils.rm_f(zip_path)
62
89
  if Gem.win_platform?
63
- system("powershell", "-NoProfile", "-Command",
64
- "Compress-Archive -Path '#{source_dir}\\*' -DestinationPath '#{zip_path}'",
90
+ env = { "OCRAN_ZIP_SOURCE" => source_dir.to_s, "OCRAN_ZIP_DESTINATION" => zip_path }
91
+ system(env, "powershell", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_ZIP_COMMAND,
65
92
  exception: true)
66
93
  else
94
+ # -y stores symlinks (the libruby.so aliases) as links; without it
95
+ # zip follows them and stores the library once per alias.
67
96
  Dir.chdir(source_dir) do
68
- system("zip", "-r", zip_path, ".", exception: true)
97
+ system("zip", "-q", "-r", "-y", zip_path, ".", exception: true)
69
98
  end
70
99
  end
71
100
  end
@@ -84,13 +113,41 @@ module Ocran
84
113
  write_launch_script
85
114
  end
86
115
 
87
- # Replace the EXTRACT_ROOT placeholder ("|") in a value string with
88
- # the runtime directory variable.
89
- def replace_root(value, root_var)
90
- value.gsub("#{EXTRACT_ROOT}/", "#{root_var}/")
116
+ # Anchors a relative packed path at the extraction root placeholder;
117
+ # absolute paths are kept as they are.
118
+ def root_path(path)
119
+ return path if path.start_with?("#{EXTRACT_ROOT}/") || File.absolute_path?(path)
120
+ "#{EXTRACT_ROOT}/#{path}"
121
+ end
122
+
123
+ # Quotes +value+ as one POSIX shell word. The extraction root placeholder
124
+ # becomes "$SCRIPT_DIR"; everything else is single-quoted, so the shell
125
+ # expands nothing in it.
126
+ def shell_word(value)
127
+ word = value.split("#{EXTRACT_ROOT}/", -1).map { |part|
128
+ part.empty? ? "" : "'#{part.gsub("'", %q('"'"'))}'"
129
+ }.join('"$SCRIPT_DIR"/')
130
+ word.empty? ? "''" : word
131
+ end
132
+
133
+ # The batch script's reference to its own directory, which ends in a
134
+ # backslash; see WindowsCommandEscaping#batch_value.
135
+ BATCH_SCRIPT_DIR = "%SCRIPT_DIR%"
136
+
137
+ # The directory the launch script changes into before it starts the
138
+ # application, as a packed path (see root_path), or nil to stay put.
139
+ def chdir_target
140
+ if @chdir_exe_dir
141
+ EXTRACT_ROOT.to_s
142
+ elsif @chdir_before && @exec_args
143
+ dir = File.dirname(@exec_args[1])
144
+ dir == "." ? EXTRACT_ROOT.to_s : root_path(dir)
145
+ end
91
146
  end
92
147
 
93
148
  def script_basename
149
+ return @script_name.to_s if @script_name
150
+
94
151
  @exec_args ? Pathname(@exec_args[1]).basename.sub_ext("").to_s : "run"
95
152
  end
96
153
 
@@ -107,24 +164,18 @@ module Ocran
107
164
  ]
108
165
 
109
166
  @env.each do |name, value|
110
- replaced = replace_root(value, "$SCRIPT_DIR")
111
- lines << "export #{name}=\"#{replaced}\""
167
+ lines << "export #{name}=#{shell_word(value)}"
168
+ end
169
+
170
+ if (dir = chdir_target)
171
+ target = dir == EXTRACT_ROOT.to_s ? '"$SCRIPT_DIR"' : shell_word(dir)
172
+ lines << "cd #{target} || exit 1"
112
173
  end
113
174
 
114
175
  if @exec_args
115
176
  image, script, argv = @exec_args
116
- image_r = replace_root(image, "$SCRIPT_DIR")
117
- script_r = replace_root(script, "$SCRIPT_DIR")
118
-
119
- # Prepend SCRIPT_DIR to relative paths
120
- image_r = "$SCRIPT_DIR/#{image_r}" unless image_r.start_with?("/", "$SCRIPT_DIR")
121
- script_r = "$SCRIPT_DIR/#{script_r}" unless script_r.start_with?("/", "$SCRIPT_DIR")
122
-
123
- args = argv.map { |a| "\"#{replace_root(a, "$SCRIPT_DIR")}\"" }.join(" ")
124
- exec_line = "exec \"#{image_r}\" \"#{script_r}\""
125
- exec_line += " #{args}" unless args.empty?
126
- exec_line += ' "$@"'
127
- lines << exec_line
177
+ words = [root_path(image), root_path(script), *argv].map { |a| shell_word(a) }
178
+ lines << "exec #{words.join(" ")} \"$@\""
128
179
  end
129
180
 
130
181
  File.write(script_path, lines.join("\n") + "\n")
@@ -134,29 +185,28 @@ module Ocran
134
185
  def write_batch_script
135
186
  script_path = @path / "#{script_basename}.bat"
136
187
 
188
+ # setlocal keeps the variables and the working directory set here from
189
+ # outliving the script in the cmd.exe session that ran it.
137
190
  lines = [
138
191
  "@echo off",
139
- "set SCRIPT_DIR=%~dp0",
192
+ "setlocal",
193
+ 'set "SCRIPT_DIR=%~dp0"',
140
194
  ]
141
195
 
142
196
  @env.each do |name, value|
143
- replaced = replace_root(value, "%SCRIPT_DIR%").tr("/", "\\")
144
- lines << "set #{name}=#{replaced}"
197
+ lines << "set \"#{name}=#{batch_value(value, BATCH_SCRIPT_DIR).tr("/", "\\")}\""
198
+ end
199
+
200
+ if (dir = chdir_target)
201
+ target = dir == EXTRACT_ROOT.to_s ? BATCH_SCRIPT_DIR : batch_value(dir, BATCH_SCRIPT_DIR).tr("/", "\\")
202
+ lines << "cd /d #{quote_and_escape(target)} || exit /b 1"
145
203
  end
146
204
 
147
205
  if @exec_args
148
206
  image, script, argv = @exec_args
149
- image_r = replace_root(image, "%SCRIPT_DIR%").tr("/", "\\")
150
- script_r = replace_root(script, "%SCRIPT_DIR%").tr("/", "\\")
151
-
152
- image_r = "%SCRIPT_DIR%#{image_r}" unless image_r.include?("%SCRIPT_DIR%") || File.absolute_path?(image_r)
153
- script_r = "%SCRIPT_DIR%#{script_r}" unless script_r.include?("%SCRIPT_DIR%") || File.absolute_path?(script_r)
154
-
155
- args = argv.map { |a| replace_root(a, "%SCRIPT_DIR%") }.join(" ")
156
- exec_line = "\"#{image_r}\" \"#{script_r}\""
157
- exec_line += " #{args}" unless args.empty?
158
- exec_line += " %*"
159
- lines << exec_line
207
+ words = [root_path(image), root_path(script)].map { |p| batch_value(p, BATCH_SCRIPT_DIR).tr("/", "\\") }
208
+ words += argv.map { |a| batch_value(a, BATCH_SCRIPT_DIR) }
209
+ lines << "#{words.map { |w| quote_and_escape(w) }.join(" ")} %*"
160
210
  end
161
211
 
162
212
  File.write(script_path, lines.join("\r\n") + "\r\n")
@@ -16,7 +16,9 @@ module Ocran
16
16
  # - /ruby/3.0.0/
17
17
  # - /ruby/site_ruby/3.0.0/
18
18
  # - /ruby/vendor_ruby/3.0.0/
19
- RUBY_LIBRARY_PATH_REGEX = %r{/(ruby/(?:site_ruby/|vendor_ruby/)?\d+\.\d+\.\d+)/?$}i
19
+ # The version directory is RbConfig's ruby_version, which development
20
+ # builds suffix with their ABI version (e.g. "4.1.0+5").
21
+ RUBY_LIBRARY_PATH_REGEX = %r{/(ruby/(?:site_ruby/|vendor_ruby/)?\d+\.\d+\.\d+(?:\+\d+)?)/?$}i
20
22
 
21
23
  # Core libraries provided by glibc and the dynamic loader. These must
22
24
  # never be bundled: they are tightly coupled to the target system's
@@ -35,6 +37,9 @@ module Ocran
35
37
  # payload, which is statically linked and has no dlopen.
36
38
  NATIVE_BINARY_EXTENSIONS = %w[.so .bundle .dll].freeze
37
39
 
40
+ # Visual C++ 2015-2022 runtime DLLs (Microsoft.VC14x.CRT redistributables)
41
+ VC_RUNTIME_DLL_RE = /\A(?:vcruntime140(?:_\d+)?|msvcp140(?:_\w+)?|concrt140|vccorlib140|vcomp140)\.dll\z/i
42
+
38
43
  # Native binaries a gem ships or has had built for it, i.e. the files
39
44
  # that make it unusable under the cosmopolitan Ruby payload.
40
45
  #
@@ -176,7 +181,48 @@ module Ocran
176
181
  else
177
182
  require_relative "library_detector_posix"
178
183
  end
179
- LibraryDetector.loaded_dlls.map { |path| Pathname.new(path).cleanpath }
184
+ dlls = LibraryDetector.loaded_dlls.map { |path| Pathname.new(path).cleanpath }
185
+ # A Ruby reached through a junction (the "current" link of rb or
186
+ # scoop) loads its DLLs by the junction path, while RbConfig reports
187
+ # the real prefix. Resolve them, or no DLL is recognised as part of
188
+ # the Ruby installation.
189
+ dlls.map! { |dll| dll.realpath rescue dll } if Gem.win_platform?
190
+ dlls
191
+ end
192
+
193
+ # The DLLs that the packed ruby.exe (or rubyw.exe), the Ruby DLL and
194
+ # the native extensions among +features+ import, directly or through
195
+ # each other, that are found in the Ruby bin directory, and the Visual
196
+ # C++ runtime DLLs among them (from the system directory). System DLLs,
197
+ # and DLLs elsewhere that only the dependency run can find, are not
198
+ # included.
199
+ def windows_imported_dlls(features)
200
+ require_relative "pe_imports"
201
+
202
+ bin_dlls = bindir.children.select { |path| path.extname?(".dll") && path.file? }
203
+ .to_h { |path| [path.basename.to_s.downcase, path] }
204
+ system_dir = Pathname((ENV["SystemRoot"] || "C:/Windows").tr("\\", "/")) / "System32"
205
+ seen = Set.new
206
+ seen << libruby_so.to_s.downcase if libruby_so
207
+ found = []
208
+ queue = [bindir / ruby_executable]
209
+ queue << bindir / libruby_so if libruby_so
210
+ queue += features.select { |f| f.extname?(".so") }
211
+ while (image = queue.shift)
212
+ next unless image.file?
213
+
214
+ PEImports.dll_names(image).each do |name|
215
+ next unless seen.add?(name.downcase)
216
+
217
+ dll = bin_dlls[name.downcase]
218
+ dll ||= system_dir / name if name.match?(VC_RUNTIME_DLL_RE) && (system_dir / name).file?
219
+ next unless dll
220
+
221
+ found << dll
222
+ queue << dll
223
+ end
224
+ end
225
+ found
180
226
  end
181
227
 
182
228
  def find_gemspecs(features)
@@ -191,8 +237,7 @@ module Ocran
191
237
  end
192
238
  end
193
239
  if defined?(Gem)
194
- foreign = foreign_bundle_gem_names
195
- specs += Gem.loaded_specs.each_value.reject { |spec| foreign.include?(spec.name) }
240
+ specs += application_loaded_specs
196
241
  # Now, we also detect gems that are not included in Gem.loaded_specs.
197
242
  # Therefore, we look for any loaded file from a gem path.
198
243
  specs += GemSpecQueryable.detect_gems_from(features, verbose: @option.verbose?)
@@ -202,6 +247,46 @@ module Ocran
202
247
  specs
203
248
  end
204
249
 
250
+ # The gems RubyGems had activated when the dependency run ended, less
251
+ # those that are not the application's.
252
+ #
253
+ # Read from the snapshot taken with the loaded features, not from
254
+ # Gem.loaded_specs, which by now also holds what OCRAN activated for the
255
+ # build itself - fiddle for DLL detection, tempfile with tmpdir,
256
+ # fileutils, delegate and etc for the builder - and which every
257
+ # application used to get packed since 1.3.16 moved the build ahead of
258
+ # the gem scan. OCRAN's own gem and its runtime dependencies (fiddle) are
259
+ # dropped as well: the `ocran` command activates them before the
260
+ # dependency run starts, and since OCRA 1.3.2 they were packed into
261
+ # every application built from an installed OCRAN, although nothing in a
262
+ # packaged application loads OCRAN. Only activation is discounted: an
263
+ # application that does load one of them still gets it packed through
264
+ # detect_gems_from.
265
+ def application_loaded_specs
266
+ @application_loaded_specs ||= begin
267
+ excluded = foreign_bundle_gem_names | ocran_gem_names
268
+ @post_env.loaded_specs.reject { |spec| excluded.include?(spec.name) }
269
+ end
270
+ end
271
+
272
+ # The names of the gem this OCRAN runs from and of the gems it activated
273
+ # as its runtime dependencies. Empty when OCRAN does not run from an
274
+ # installed gem, as from a source checkout.
275
+ def ocran_gem_names
276
+ specs = @post_env.loaded_specs.to_h { |spec| [spec.name, spec] }
277
+ own = specs.each_value.find { |spec| Pathname(__dir__).subpath?(spec.gem_dir) }
278
+ names = Set.new
279
+ queue = own ? [own] : []
280
+ while (spec = queue.shift)
281
+ next unless names.add?(spec.name)
282
+
283
+ spec.runtime_dependencies.each do |dep|
284
+ queue << specs[dep.name] if specs.key?(dep.name)
285
+ end
286
+ end
287
+ names
288
+ end
289
+
205
290
  # The gems RubyGems had already activated for a bundle that is not the
206
291
  # application's, by the time OCRAN started.
207
292
  #
@@ -445,8 +530,17 @@ module Ocran
445
530
  end
446
531
  end
447
532
 
533
+ # Windows-only: DLLs bundled into bin, next to ruby.exe, by basename
534
+ dlls_in_bin = Set.new
535
+ add_dll_to_bin = proc do |dll|
536
+ next if dll.subpath?(bindir) || !dlls_in_bin.add?(dll.basename.to_s.downcase)
537
+
538
+ say "Adding detected DLL #{dll} to bin"
539
+ builder.copy_to_bin(dll, dll.basename)
540
+ end
541
+
448
542
  # Windows-only: Add detected DLLs
449
- if Gem.win_platform? && @option.auto_detect_dlls?
543
+ if Gem.win_platform? && @option.auto_detect_dlls? && !@option.static_runtime?
450
544
  # The Windows loader resolves the imports of a native extension from
451
545
  # the extension's own directory, the application directory of the
452
546
  # packed ruby.exe (bin) plus its SxS assembly (ruby_builtin_dlls),
@@ -463,16 +557,17 @@ module Ocran
463
557
  # LD_LIBRARY_PATH. DLLs from the Windows directory always come from
464
558
  # the target system and are never bundled.
465
559
  windows_dir = Pathname(ENV["SystemRoot"] || "C:/Windows")
466
- dlls_in_bin = Set.new
467
- add_dll_to_bin = proc do |dll|
468
- next if dll.subpath?(bindir) || !dlls_in_bin.add?(dll.basename.to_s.downcase)
469
-
470
- say "Adding detected DLL #{dll} to bin"
471
- builder.copy_to_bin(dll, dll.basename)
472
- end
473
560
 
474
561
  detect_dlls.each do |dll|
475
562
  next unless dll.extname?(".dll") && dll.basename != libruby_so
563
+ # The Visual C++ runtime is not part of Windows: an mswin Ruby
564
+ # (and any MSVC-built DLL a gem loads) fails to start with
565
+ # 0xC0000135 where the VC++ Redistributable is not installed.
566
+ # Microsoft permits deploying it app-locally, next to ruby.exe.
567
+ if dll.basename.to_s.match?(VC_RUNTIME_DLL_RE)
568
+ add_dll_to_bin.call(dll)
569
+ next
570
+ end
476
571
  next if dll.subpath?(windows_dir)
477
572
 
478
573
  if dll.subpath?(exec_prefix)
@@ -492,7 +587,7 @@ module Ocran
492
587
  # the same extension packed at a gem path (openssl and psych are
493
588
  # gems since Ruby 3.x) resolves its imports from bin.
494
589
  features.select { |f| f.extname?(".so") && f.subpath?(exec_prefix) }
495
- .map(&:dirname).uniq
590
+ .map(&:dirname).uniq { |dir| dir.path_key }
496
591
  .each do |dir|
497
592
  dir.each_child do |path|
498
593
  next unless path.file? && path.extname?(".dll")
@@ -503,6 +598,25 @@ module Ocran
503
598
  end
504
599
  end
505
600
 
601
+ # Windows-only: DLLs that ruby.exe, the Ruby DLL and the packed native
602
+ # extensions import from the Ruby bin directory, plus the Visual C++
603
+ # runtime. RubyInstaller keeps such DLLs in ruby_builtin_dlls, which is
604
+ # packed whole below; an mswin Ruby keeps them (gmp, libffi, OpenSSL,
605
+ # libyaml, zlib from vcpkg) next to ruby.exe, and its interpreter does
606
+ # not even start without gmp and vcruntime140. They are read from the
607
+ # PE import tables, so they are packed with --no-autodll too, and for
608
+ # extensions the dependency run did not exercise.
609
+ if Gem.win_platform?
610
+ windows_imported_dlls(features).each do |dll|
611
+ if dll.subpath?(bindir)
612
+ verbose "Adding imported DLL #{dll}"
613
+ builder.duplicate_to_exec_prefix(dll)
614
+ else
615
+ add_dll_to_bin.call(dll)
616
+ end
617
+ end
618
+ end
619
+
506
620
  # Linux: bundle detected shared libraries (e.g. libyaml, libssl,
507
621
  # libcrypt) next to libruby so the executable also runs on systems
508
622
  # where they are missing or have different sonames (the stub already
@@ -510,7 +624,7 @@ module Ocran
510
624
  # libraries and the loader are never bundled - they must come from the
511
625
  # target system. Ruby native extensions are packed as features, not
512
626
  # here. Not needed with --cosmo-ruby: the APE payload is static.
513
- if RUBY_PLATFORM.include?("linux") && @option.auto_detect_dlls? && !@option.cosmo_ruby
627
+ if RUBY_PLATFORM.include?("linux") && @option.auto_detect_dlls? && !@option.cosmo_ruby && !@option.static_runtime?
514
628
  feature_set = features.to_set
515
629
  feature_realpaths = features.filter_map { |f| f.realpath rescue nil }.to_set
516
630
  # Ruby native extensions live in these directories and are packed as
@@ -560,7 +674,7 @@ module Ocran
560
674
  archdir = Pathname(RbConfig::CONFIG["archdir"])
561
675
  sxs_manifest_dirs << archdir if archdir.exist? && archdir.subpath?(exec_prefix)
562
676
  if defined?(Gem)
563
- Gem.loaded_specs.each_value do |spec|
677
+ application_loaded_specs.each do |spec|
564
678
  next if spec.extensions.empty?
565
679
  ext_dir = Pathname(spec.extension_dir)
566
680
  sxs_manifest_dirs << ext_dir if ext_dir.exist? && ext_dir.subpath?(exec_prefix)
@@ -812,7 +926,9 @@ module Ocran
812
926
 
813
927
  actual_files
814
928
  end
815
- gem_files.uniq!
929
+ # Keyed on Pathname#path_key here and below: Array#uniq and Set do not
930
+ # see the refined, case-insensitive Pathname#eql? on Windows.
931
+ gem_files.uniq! { |file| file.path_key }
816
932
 
817
933
  # On some distros parts of a gem are reachable through symlinks at other
818
934
  # locations (e.g. Fedora symlinks /usr/share/ruby/psych.rb into the
@@ -820,16 +936,17 @@ module Ocran
820
936
  # the same file under different paths. Compare realpaths when removing
821
937
  # gem files from the feature list; otherwise a stdlib-level duplicate
822
938
  # would be packed as well and shadow the packed gem at runtime.
823
- gem_file_set = (gem_files + gem_files.filter_map { |file| file.realpath rescue nil }).to_set
939
+ gem_file_set = (gem_files + gem_files.filter_map { |file| file.realpath rescue nil })
940
+ .to_set { |file| file.path_key }
824
941
  features = features.reject do |feature|
825
- next true if gem_file_set.include?(feature)
942
+ next true if gem_file_set.include?(feature.path_key)
826
943
 
827
944
  real = begin
828
945
  feature.realpath
829
946
  rescue SystemCallError
830
947
  nil
831
948
  end
832
- real && gem_file_set.include?(real)
949
+ real && gem_file_set.include?(real.path_key)
833
950
  end
834
951
 
835
952
  # If requested, add all ruby standard libraries
@@ -844,7 +961,6 @@ module Ocran
844
961
  end
845
962
  path.find.each do |src|
846
963
  next if src.directory?
847
- a = Pathname(subdir) / src.relative_path_from(path)
848
964
  builder.copy_to_lib(src, Pathname(subdir) / src.relative_path_from(path))
849
965
  end
850
966
  end
@@ -873,7 +989,8 @@ module Ocran
873
989
  end
874
990
 
875
991
  # Windows-only: Workaround for RubyInstaller MSYS folder detection
876
- if Gem.win_platform?
992
+ # (mswin Ruby, built with Visual C++, has no MSYS2 devkit)
993
+ if Gem.win_platform? && !RUBY_PLATFORM.include?("mswin")
877
994
  # RubyInstaller cannot find the msys folder if ../msys64/usr/bin/msys-2.0.dll is not present
878
995
  # (since RubyInstaller-2.4.1 rubyinstaller 2 issue 23)
879
996
  builder.touch('msys64/usr/bin/msys-2.0.dll')
@@ -1043,7 +1160,7 @@ module Ocran
1043
1160
  neutralize_bundler_env(builder)
1044
1161
  # Add the load path that are required with the correct path after
1045
1162
  # src_prefix was adjusted.
1046
- load_path = src_load_path.map { |path| SRCDIR / path.relative_path_from(inst_src_prefix) }.uniq
1163
+ load_path = src_load_path.map { |path| SRCDIR / path.relative_path_from(inst_src_prefix) }.uniq { |path| path.path_key }
1047
1164
 
1048
1165
  # On POSIX systems, also add the packed Ruby standard library directories
1049
1166
  # to RUBYLIB. The Ruby binary has a compiled-in prefix pointing to the build
@@ -1082,7 +1199,7 @@ module Ocran
1082
1199
  prefix_gem_dirs = (Gem.path.map { |dir| Pathname(dir) } + [Pathname(Gem.default_dir)])
1083
1200
  .select { |dir| dir.subpath?(exec_prefix) }
1084
1201
  .map { |dir| dir.relative_path_from(exec_prefix) }
1085
- .uniq
1202
+ .uniq { |dir| dir.path_key }
1086
1203
  gem_paths += prefix_gem_dirs
1087
1204
  # RubyGems probes the default gem dir for writability at startup and
1088
1205
  # prints "Can't determine writability of default gem path" on stderr
@@ -1256,6 +1373,20 @@ module Ocran
1256
1373
  end
1257
1374
  end
1258
1375
 
1376
+ # The PE resource options (version info, string table, manifest), for
1377
+ # every Windows executable the user launches: the self-extracting exe and
1378
+ # the wrapper that installer, directory and zip output start from.
1379
+ def pe_resource_options
1380
+ {
1381
+ version_strings: @option.version_strings,
1382
+ file_version: @option.file_version,
1383
+ product_version: @option.product_version,
1384
+ resource_strings: @option.resource_strings,
1385
+ execution_level: @option.execution_level,
1386
+ application_manifest: @option.application_manifest&.to_s,
1387
+ }
1388
+ end
1389
+
1259
1390
  # Builds the small RUN_IN_EXE_DIR wrapper stub that starts the deployed
1260
1391
  # application directly from the directory the wrapper resides in.
1261
1392
  def build_wrapper_exe(wrapper_path)
@@ -1268,7 +1399,8 @@ module Ocran
1268
1399
  gui_mode: @option.windowed?,
1269
1400
  icon_path: @option.icon_filename,
1270
1401
  run_in_exe_dir: true,
1271
- stub_path: cosmo_stub_path) do |stub|
1402
+ stub_path: cosmo_stub_path,
1403
+ **pe_resource_options) do |stub|
1272
1404
  yield(stub)
1273
1405
  end
1274
1406
  end
@@ -1278,7 +1410,11 @@ module Ocran
1278
1410
 
1279
1411
  path = Pathname(path)
1280
1412
  say "Building directory #{path}"
1281
- builder = DirBuilder.new(path, &to_proc)
1413
+ builder = DirBuilder.new(path,
1414
+ script_name: @option.script.basename.sub_ext("").to_s,
1415
+ chdir_before: @option.chdir_before?,
1416
+ chdir_exe_dir: @option.chdir_exe_dir?,
1417
+ &to_proc)
1282
1418
 
1283
1419
  if @option.wrapper_exe?
1284
1420
  # Same wrapper as in Inno Setup builds: a doubleclickable executable
@@ -1342,30 +1478,41 @@ module Ocran
1342
1478
  end
1343
1479
 
1344
1480
  bundle_id = @option.bundle_identifier || "com.example.#{app_name}"
1345
- icon_entry = @option.icon_filename ? " <key>CFBundleIconFile</key>\n <string>AppIcon</string>\n" : ""
1481
+ File.write(contents_dir / "Info.plist",
1482
+ self.class.info_plist(app_name, bundle_id, icon: !!@option.icon_filename))
1346
1483
 
1347
- File.write(contents_dir / "Info.plist", <<~PLIST)
1484
+ say "Finished building #{bundle_path} (#{builder.data_size} bytes decompressed)"
1485
+ end
1486
+
1487
+ XML_ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&apos;" }.freeze
1488
+
1489
+ # The Info.plist of a macOS app bundle. The values are XML-escaped: an
1490
+ # app named "R&D" must not produce a property list macOS cannot parse.
1491
+ def self.info_plist(app_name, bundle_id, icon: false)
1492
+ name = app_name.to_s.gsub(/[&<>"']/, XML_ESCAPES)
1493
+ id = bundle_id.to_s.gsub(/[&<>"']/, XML_ESCAPES)
1494
+ icon_entry = icon ? " <key>CFBundleIconFile</key>\n <string>AppIcon</string>\n" : ""
1495
+
1496
+ <<~PLIST
1348
1497
  <?xml version="1.0" encoding="UTF-8"?>
1349
1498
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1350
1499
  <plist version="1.0">
1351
1500
  <dict>
1352
1501
  <key>CFBundleName</key>
1353
- <string>#{app_name}</string>
1502
+ <string>#{name}</string>
1354
1503
  <key>CFBundleDisplayName</key>
1355
- <string>#{app_name}</string>
1504
+ <string>#{name}</string>
1356
1505
  <key>CFBundleIdentifier</key>
1357
- <string>#{bundle_id}</string>
1506
+ <string>#{id}</string>
1358
1507
  <key>CFBundleVersion</key>
1359
1508
  <string>1.0</string>
1360
1509
  <key>CFBundlePackageType</key>
1361
1510
  <string>APPL</string>
1362
1511
  <key>CFBundleExecutable</key>
1363
- <string>#{app_name}</string>
1512
+ <string>#{name}</string>
1364
1513
  #{icon_entry}</dict>
1365
1514
  </plist>
1366
1515
  PLIST
1367
-
1368
- say "Finished building #{bundle_path} (#{builder.data_size} bytes decompressed)"
1369
1516
  end
1370
1517
 
1371
1518
  # Builds the executable by copying the cosmopolitan Ruby and injecting
@@ -1380,6 +1527,7 @@ module Ocran
1380
1527
  ZipPayloadBuilder.new(output,
1381
1528
  cosmo_ruby: @option.cosmo_ruby,
1382
1529
  chdir_before: @option.chdir_before?,
1530
+ chdir_exe_dir: @option.chdir_exe_dir?,
1383
1531
  debug_mode: @option.enable_debug_mode?,
1384
1532
  &to_proc) => builder
1385
1533
 
@@ -1402,6 +1550,56 @@ module Ocran
1402
1550
  say "Finished building #{output} (#{output.size} bytes, #{builder.data_size} bytes of application data)"
1403
1551
  end
1404
1552
 
1553
+ # --static-runtime: the application appended as a ZIP archive to a statically
1554
+ # linked Windows Ruby (see StaticRuntimeBuilder).
1555
+ def build_static_runtime_exe
1556
+ require_relative "static_runtime_builder"
1557
+
1558
+ runtime = StaticRuntime.load(@option.static_runtime)
1559
+ runtime.verify!
1560
+ host_api = RbConfig::CONFIG["ruby_version"]
1561
+ # Gems, default gem specs and the host files the runtime does not
1562
+ # replace were resolved for the build host's Ruby; they only fit a
1563
+ # runtime with the same API version.
1564
+ unless host_api == runtime.ruby_api_version
1565
+ raise "The static runtime in #{runtime.dir} is Ruby #{runtime.ruby_version} (API #{runtime.ruby_api_version}), " \
1566
+ "but this is Ruby #{RUBY_VERSION} (API #{host_api}): package with a Ruby #{runtime.ruby_api_version.sub(/\.0\z/, "")}"
1567
+ end
1568
+ if RUBY_VERSION != runtime.ruby_version
1569
+ warning "Dependency detection ran under Ruby #{RUBY_VERSION}; the static runtime is Ruby #{runtime.ruby_version}"
1570
+ end
1571
+ say "Packaging for the static runtime #{runtime.executable} (Ruby #{runtime.ruby_version} #{runtime.arch})"
1572
+ if @option.extra_dlls.any?
1573
+ warning "--dll has no effect with --static-runtime: a DLL cannot be loaded from inside the executable"
1574
+ end
1575
+
1576
+ output = @option.output_executable
1577
+ StaticRuntimeBuilder.new(output,
1578
+ runtime: runtime,
1579
+ host_ruby_api_version: host_api,
1580
+ host_arch: RbConfig::CONFIG["arch"],
1581
+ chdir_before: @option.chdir_before?,
1582
+ chdir_exe_dir: @option.chdir_exe_dir?,
1583
+ windowed: @option.windowed?,
1584
+ debug_mode: @option.enable_debug_mode?,
1585
+ dlls: @option.runtime_dlls,
1586
+ resources: { icon_path: @option.icon_filename&.to_s, **pe_resource_options },
1587
+ &to_proc) => builder
1588
+
1589
+ builder.skipped.each do |reason, names|
1590
+ verbose "Not packed (#{reason.to_s.tr("_", " ")}): #{names.size} file(s)"
1591
+ names.each { |name| verbose "\t#{name}" }
1592
+ end
1593
+ unless builder.skipped[:not_in_runtime_stdlib].empty?
1594
+ say "#{builder.skipped[:not_in_runtime_stdlib].size} file(s) of this Ruby's standard library are not " \
1595
+ "in the runtime's and were left out (--verbose lists them)"
1596
+ end
1597
+ say "Finished building #{output} (#{output.size} bytes, #{builder.data_size} bytes of application data)"
1598
+ unless builder.shipped_dlls.empty?
1599
+ say "Ship these DLLs next to #{output.basename}: #{builder.shipped_dlls.map { |d| d.basename.to_s }.join(", ")}"
1600
+ end
1601
+ end
1602
+
1405
1603
  def build_stab_exe
1406
1604
  require_relative "stub_builder"
1407
1605
 
@@ -1418,6 +1616,7 @@ module Ocran
1418
1616
  gui_mode: @option.windowed?,
1419
1617
  icon_path: @option.icon_filename,
1420
1618
  stub_path: cosmo_stub_path,
1619
+ **pe_resource_options,
1421
1620
  &to_proc) => builder
1422
1621
  say "Finished building #{@option.output_executable} (#{@option.output_executable.size} bytes)"
1423
1622
  say "After decompression, the data will expand to #{builder.data_size} bytes."