ocran 1.4.6 → 1.5.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +82 -0
  3. data/README.md +519 -15
  4. data/ext/extconf.rb +5 -1
  5. data/lib/ocran/aot_toolchain.rb +154 -0
  6. data/lib/ocran/build_helper.rb +7 -0
  7. data/lib/ocran/command_output.rb +8 -1
  8. data/lib/ocran/compatibility_scan.rb +414 -0
  9. data/lib/ocran/cosmo_toolchain.rb +29 -7
  10. data/lib/ocran/dir_builder.rb +88 -38
  11. data/lib/ocran/direction.rb +292 -44
  12. data/lib/ocran/ed_resource.rb +113 -0
  13. data/lib/ocran/ed_resource_builder.rb +199 -0
  14. data/lib/ocran/env_hook.rb +98 -0
  15. data/lib/ocran/file_path_set.rb +9 -5
  16. data/lib/ocran/gem_spec_queryable.rb +13 -5
  17. data/lib/ocran/host_config_helper.rb +11 -1
  18. data/lib/ocran/inno_setup_script_builder.rb +24 -14
  19. data/lib/ocran/launcher_batch_builder.rb +8 -11
  20. data/lib/ocran/npm_package.rb +106 -0
  21. data/lib/ocran/option.rb +465 -35
  22. data/lib/ocran/pe_imports.rb +96 -0
  23. data/lib/ocran/pe_resources.rb +226 -0
  24. data/lib/ocran/refine_pathname.rb +29 -32
  25. data/lib/ocran/roundhouse_builder.rb +316 -0
  26. data/lib/ocran/runner.rb +52 -11
  27. data/lib/ocran/runtime_sdk.rb +259 -0
  28. data/lib/ocran/spinel_builder.rb +183 -0
  29. data/lib/ocran/spinel_compatibility.rb +149 -0
  30. data/lib/ocran/static_runtime/boot.rb +443 -0
  31. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  32. data/lib/ocran/static_runtime_builder.rb +814 -0
  33. data/lib/ocran/stub_builder.rb +70 -25
  34. data/lib/ocran/version.rb +1 -1
  35. data/lib/ocran/wasm_builder.rb +570 -0
  36. data/lib/ocran/wasm_compatibility.rb +198 -0
  37. data/lib/ocran/windows_command_escaping.rb +21 -0
  38. data/lib/ocran/zip_payload_builder.rb +18 -9
  39. data/lib/ocran/zip_writer.rb +48 -9
  40. data/src/Makefile +18 -2
  41. data/src/Makefile.mswin +65 -0
  42. data/src/inst_dir.c +34 -6
  43. data/src/path_utils.c +144 -0
  44. data/src/script_info.c +5 -0
  45. data/src/stub.c +77 -19
  46. data/src/stub.rc +1 -1
  47. data/src/system_utils.c +276 -155
  48. data/src/system_utils.h +46 -3
  49. data/src/system_utils_posix.c +243 -148
  50. data/src/unpack.c +9 -0
  51. metadata +25 -4
@@ -6,6 +6,7 @@ require_relative "refine_pathname"
6
6
  require_relative "host_config_helper"
7
7
  require_relative "command_output"
8
8
  require_relative "build_constants"
9
+ require_relative "env_hook"
9
10
 
10
11
  module Ocran
11
12
  class Direction
@@ -16,7 +17,9 @@ module Ocran
16
17
  # - /ruby/3.0.0/
17
18
  # - /ruby/site_ruby/3.0.0/
18
19
  # - /ruby/vendor_ruby/3.0.0/
19
- RUBY_LIBRARY_PATH_REGEX = %r{/(ruby/(?:site_ruby/|vendor_ruby/)?\d+\.\d+\.\d+)/?$}i
20
+ # The version directory is RbConfig's ruby_version, which development
21
+ # builds suffix with their ABI version (e.g. "4.1.0+5").
22
+ RUBY_LIBRARY_PATH_REGEX = %r{/(ruby/(?:site_ruby/|vendor_ruby/)?\d+\.\d+\.\d+(?:\+\d+)?)/?$}i
20
23
 
21
24
  # Core libraries provided by glibc and the dynamic loader. These must
22
25
  # never be bundled: they are tightly coupled to the target system's
@@ -35,6 +38,9 @@ module Ocran
35
38
  # payload, which is statically linked and has no dlopen.
36
39
  NATIVE_BINARY_EXTENSIONS = %w[.so .bundle .dll].freeze
37
40
 
41
+ # Visual C++ 2015-2022 runtime DLLs (Microsoft.VC14x.CRT redistributables)
42
+ VC_RUNTIME_DLL_RE = /\A(?:vcruntime140(?:_\d+)?|msvcp140(?:_\w+)?|concrt140|vccorlib140|vcomp140)\.dll\z/i
43
+
38
44
  # Native binaries a gem ships or has had built for it, i.e. the files
39
45
  # that make it unusable under the cosmopolitan Ruby payload.
40
46
  #
@@ -176,7 +182,48 @@ module Ocran
176
182
  else
177
183
  require_relative "library_detector_posix"
178
184
  end
179
- LibraryDetector.loaded_dlls.map { |path| Pathname.new(path).cleanpath }
185
+ dlls = LibraryDetector.loaded_dlls.map { |path| Pathname.new(path).cleanpath }
186
+ # A Ruby reached through a junction (the "current" link of rb or
187
+ # scoop) loads its DLLs by the junction path, while RbConfig reports
188
+ # the real prefix. Resolve them, or no DLL is recognised as part of
189
+ # the Ruby installation.
190
+ dlls.map! { |dll| dll.realpath rescue dll } if Gem.win_platform?
191
+ dlls
192
+ end
193
+
194
+ # The DLLs that the packed ruby.exe (or rubyw.exe), the Ruby DLL and
195
+ # the native extensions among +features+ import, directly or through
196
+ # each other, that are found in the Ruby bin directory, and the Visual
197
+ # C++ runtime DLLs among them (from the system directory). System DLLs,
198
+ # and DLLs elsewhere that only the dependency run can find, are not
199
+ # included.
200
+ def windows_imported_dlls(features)
201
+ require_relative "pe_imports"
202
+
203
+ bin_dlls = bindir.children.select { |path| path.extname?(".dll") && path.file? }
204
+ .to_h { |path| [path.basename.to_s.downcase, path] }
205
+ system_dir = Pathname((ENV["SystemRoot"] || "C:/Windows").tr("\\", "/")) / "System32"
206
+ seen = Set.new
207
+ seen << libruby_so.to_s.downcase if libruby_so
208
+ found = []
209
+ queue = [bindir / ruby_executable]
210
+ queue << bindir / libruby_so if libruby_so
211
+ queue += features.select { |f| f.extname?(".so") }
212
+ while (image = queue.shift)
213
+ next unless image.file?
214
+
215
+ PEImports.dll_names(image).each do |name|
216
+ next unless seen.add?(name.downcase)
217
+
218
+ dll = bin_dlls[name.downcase]
219
+ dll ||= system_dir / name if name.match?(VC_RUNTIME_DLL_RE) && (system_dir / name).file?
220
+ next unless dll
221
+
222
+ found << dll
223
+ queue << dll
224
+ end
225
+ end
226
+ found
180
227
  end
181
228
 
182
229
  def find_gemspecs(features)
@@ -350,19 +397,30 @@ module Ocran
350
397
  # Convert all relative paths to absolute paths before building.
351
398
  # NOTE: In the future, different strategies may be needed before and after script execution.
352
399
  features.filter_map do |feature|
353
- if feature.absolute?
354
- feature
355
- elsif (load_path = @post_env.find_load_path(feature))
356
- feature.expand_path(@post_env.expand_path(load_path))
357
- else
358
- # This message occurs when paths for core library files (e.g., enumerator.so,
359
- # rational.so, complex.so, fiber.so, thread.rb, ruby2_keywords.rb) are not
360
- # found. These are integral to Ruby's standard libraries or extensions and
361
- # may not be located via normal load path searches, especially in RubyInstaller
362
- # environments.
363
- verbose "Load path not found for #{feature}, skip this feature"
364
- nil
400
+ path =
401
+ if feature.absolute?
402
+ feature
403
+ elsif (load_path = @post_env.find_load_path(feature))
404
+ feature.expand_path(@post_env.expand_path(load_path))
405
+ else
406
+ # This message occurs when paths for core library files (e.g., enumerator.so,
407
+ # rational.so, complex.so, fiber.so, thread.rb, ruby2_keywords.rb) are not
408
+ # found. These are integral to Ruby's standard libraries or extensions and
409
+ # may not be located via normal load path searches, especially in RubyInstaller
410
+ # environments.
411
+ verbose "Load path not found for #{feature}, skip this feature"
412
+ next
413
+ end
414
+ # An entry can be in $LOADED_FEATURES without a file behind it: a
415
+ # racc parser generated with its runtime embedded marks
416
+ # <dir>/racc/parser.rb and racc/info.rb as loaded so that racc is not
417
+ # loaded twice (rdoc's RD parsers in Ruby 3.4 do). Copying it failed
418
+ # the whole build with ENOENT; there is nothing to pack.
419
+ unless path.file?
420
+ verbose "#{path} is marked as loaded but is not a file, skip this feature"
421
+ next
365
422
  end
423
+ path
366
424
  end
367
425
  end
368
426
 
@@ -425,6 +483,19 @@ module Ocran
425
483
  end
426
484
  end
427
485
 
486
+ # String#unicode_normalize (and unicode_normalized?, unicode_normalize!)
487
+ # is core, but core requires its implementation from the standard
488
+ # library (unicode_normalize/normalize.rb, which loads tables.rb) on
489
+ # first use. An app that does not call it during the dependency run
490
+ # fails with a LoadError once packed, so always pack these files.
491
+ # Not with --cosmo-ruby: the payload has its own standard library.
492
+ unless @option.cosmo_ruby
493
+ known = features.to_set { |f| f.path_key }
494
+ (Pathname(RbConfig::CONFIG["rubylibdir"]) / "unicode_normalize").glob("*.rb").sort.each do |path|
495
+ features << path unless known.include?(path.path_key)
496
+ end
497
+ end
498
+
428
499
  say "Building #{@option.output_executable}"
429
500
  require_relative "build_helper"
430
501
  builder.extend(BuildHelper)
@@ -484,8 +555,17 @@ module Ocran
484
555
  end
485
556
  end
486
557
 
558
+ # Windows-only: DLLs bundled into bin, next to ruby.exe, by basename
559
+ dlls_in_bin = Set.new
560
+ add_dll_to_bin = proc do |dll|
561
+ next if dll.subpath?(bindir) || !dlls_in_bin.add?(dll.basename.to_s.downcase)
562
+
563
+ say "Adding detected DLL #{dll} to bin"
564
+ builder.copy_to_bin(dll, dll.basename)
565
+ end
566
+
487
567
  # Windows-only: Add detected DLLs
488
- if Gem.win_platform? && @option.auto_detect_dlls?
568
+ if Gem.win_platform? && @option.auto_detect_dlls? && !@option.static_runtime?
489
569
  # The Windows loader resolves the imports of a native extension from
490
570
  # the extension's own directory, the application directory of the
491
571
  # packed ruby.exe (bin) plus its SxS assembly (ruby_builtin_dlls),
@@ -502,16 +582,17 @@ module Ocran
502
582
  # LD_LIBRARY_PATH. DLLs from the Windows directory always come from
503
583
  # the target system and are never bundled.
504
584
  windows_dir = Pathname(ENV["SystemRoot"] || "C:/Windows")
505
- dlls_in_bin = Set.new
506
- add_dll_to_bin = proc do |dll|
507
- next if dll.subpath?(bindir) || !dlls_in_bin.add?(dll.basename.to_s.downcase)
508
-
509
- say "Adding detected DLL #{dll} to bin"
510
- builder.copy_to_bin(dll, dll.basename)
511
- end
512
585
 
513
586
  detect_dlls.each do |dll|
514
587
  next unless dll.extname?(".dll") && dll.basename != libruby_so
588
+ # The Visual C++ runtime is not part of Windows: an mswin Ruby
589
+ # (and any MSVC-built DLL a gem loads) fails to start with
590
+ # 0xC0000135 where the VC++ Redistributable is not installed.
591
+ # Microsoft permits deploying it app-locally, next to ruby.exe.
592
+ if dll.basename.to_s.match?(VC_RUNTIME_DLL_RE)
593
+ add_dll_to_bin.call(dll)
594
+ next
595
+ end
515
596
  next if dll.subpath?(windows_dir)
516
597
 
517
598
  if dll.subpath?(exec_prefix)
@@ -531,7 +612,7 @@ module Ocran
531
612
  # the same extension packed at a gem path (openssl and psych are
532
613
  # gems since Ruby 3.x) resolves its imports from bin.
533
614
  features.select { |f| f.extname?(".so") && f.subpath?(exec_prefix) }
534
- .map(&:dirname).uniq
615
+ .map(&:dirname).uniq { |dir| dir.path_key }
535
616
  .each do |dir|
536
617
  dir.each_child do |path|
537
618
  next unless path.file? && path.extname?(".dll")
@@ -542,6 +623,25 @@ module Ocran
542
623
  end
543
624
  end
544
625
 
626
+ # Windows-only: DLLs that ruby.exe, the Ruby DLL and the packed native
627
+ # extensions import from the Ruby bin directory, plus the Visual C++
628
+ # runtime. RubyInstaller keeps such DLLs in ruby_builtin_dlls, which is
629
+ # packed whole below; an mswin Ruby keeps them (gmp, libffi, OpenSSL,
630
+ # libyaml, zlib from vcpkg) next to ruby.exe, and its interpreter does
631
+ # not even start without gmp and vcruntime140. They are read from the
632
+ # PE import tables, so they are packed with --no-autodll too, and for
633
+ # extensions the dependency run did not exercise.
634
+ if Gem.win_platform?
635
+ windows_imported_dlls(features).each do |dll|
636
+ if dll.subpath?(bindir)
637
+ verbose "Adding imported DLL #{dll}"
638
+ builder.duplicate_to_exec_prefix(dll)
639
+ else
640
+ add_dll_to_bin.call(dll)
641
+ end
642
+ end
643
+ end
644
+
545
645
  # Linux: bundle detected shared libraries (e.g. libyaml, libssl,
546
646
  # libcrypt) next to libruby so the executable also runs on systems
547
647
  # where they are missing or have different sonames (the stub already
@@ -549,7 +649,7 @@ module Ocran
549
649
  # libraries and the loader are never bundled - they must come from the
550
650
  # target system. Ruby native extensions are packed as features, not
551
651
  # here. Not needed with --cosmo-ruby: the APE payload is static.
552
- if RUBY_PLATFORM.include?("linux") && @option.auto_detect_dlls? && !@option.cosmo_ruby
652
+ if RUBY_PLATFORM.include?("linux") && @option.auto_detect_dlls? && !@option.cosmo_ruby && !@option.static_runtime?
553
653
  feature_set = features.to_set
554
654
  feature_realpaths = features.filter_map { |f| f.realpath rescue nil }.to_set
555
655
  # Ruby native extensions live in these directories and are packed as
@@ -851,7 +951,9 @@ module Ocran
851
951
 
852
952
  actual_files
853
953
  end
854
- gem_files.uniq!
954
+ # Keyed on Pathname#path_key here and below: Array#uniq and Set do not
955
+ # see the refined, case-insensitive Pathname#eql? on Windows.
956
+ gem_files.uniq! { |file| file.path_key }
855
957
 
856
958
  # On some distros parts of a gem are reachable through symlinks at other
857
959
  # locations (e.g. Fedora symlinks /usr/share/ruby/psych.rb into the
@@ -859,16 +961,17 @@ module Ocran
859
961
  # the same file under different paths. Compare realpaths when removing
860
962
  # gem files from the feature list; otherwise a stdlib-level duplicate
861
963
  # would be packed as well and shadow the packed gem at runtime.
862
- gem_file_set = (gem_files + gem_files.filter_map { |file| file.realpath rescue nil }).to_set
964
+ gem_file_set = (gem_files + gem_files.filter_map { |file| file.realpath rescue nil })
965
+ .to_set { |file| file.path_key }
863
966
  features = features.reject do |feature|
864
- next true if gem_file_set.include?(feature)
967
+ next true if gem_file_set.include?(feature.path_key)
865
968
 
866
969
  real = begin
867
970
  feature.realpath
868
971
  rescue SystemCallError
869
972
  nil
870
973
  end
871
- real && gem_file_set.include?(real)
974
+ real && gem_file_set.include?(real.path_key)
872
975
  end
873
976
 
874
977
  # If requested, add all ruby standard libraries
@@ -877,15 +980,23 @@ module Ocran
877
980
  elsif @option.add_all_core?
878
981
  say "Will include all ruby core libraries"
879
982
  all_core_dir.each do |path|
983
+ # A configured directory that was never created (Debian's
984
+ # sitelibdir, /usr/local/lib/site_ruby/<ver>) holds nothing.
985
+ next unless path.directory?
986
+
880
987
  # Match the load path against standard library, site_ruby, and vendor_ruby paths
881
988
  unless (subdir = path.to_posix.match(RUBY_LIBRARY_PATH_REGEX)&.[](1))
882
989
  raise "Unexpected library path format (does not match core dirs): #{path}"
883
990
  end
884
- path.find.each do |src|
885
- next if src.directory?
886
- a = Pathname(subdir) / src.relative_path_from(path)
991
+ files, skipped = self.class.core_library_files(path)
992
+ files.each do |src|
887
993
  builder.copy_to_lib(src, Pathname(subdir) / src.relative_path_from(path))
888
994
  end
995
+ next if skipped.empty?
996
+
997
+ warning "Not packed: #{skipped.size} entr#{skipped.size == 1 ? "y" : "ies"} of #{path} that " \
998
+ "#{skipped.size == 1 ? "is" : "are"} not a readable file (#{skipped.first(3).join(", ")}" \
999
+ "#{skipped.size > 3 ? ", ..." : ""})"
889
1000
  end
890
1001
  end
891
1002
 
@@ -912,7 +1023,8 @@ module Ocran
912
1023
  end
913
1024
 
914
1025
  # Windows-only: Workaround for RubyInstaller MSYS folder detection
915
- if Gem.win_platform?
1026
+ # (mswin Ruby, built with Visual C++, has no MSYS2 devkit)
1027
+ if Gem.win_platform? && !RUBY_PLATFORM.include?("mswin")
916
1028
  # RubyInstaller cannot find the msys folder if ../msys64/usr/bin/msys-2.0.dll is not present
917
1029
  # (since RubyInstaller-2.4.1 rubyinstaller 2 issue 23)
918
1030
  builder.touch('msys64/usr/bin/msys-2.0.dll')
@@ -1082,7 +1194,7 @@ module Ocran
1082
1194
  neutralize_bundler_env(builder)
1083
1195
  # Add the load path that are required with the correct path after
1084
1196
  # src_prefix was adjusted.
1085
- load_path = src_load_path.map { |path| SRCDIR / path.relative_path_from(inst_src_prefix) }.uniq
1197
+ load_path = src_load_path.map { |path| SRCDIR / path.relative_path_from(inst_src_prefix) }.uniq { |path| path.path_key }
1086
1198
 
1087
1199
  # On POSIX systems, also add the packed Ruby standard library directories
1088
1200
  # to RUBYLIB. The Ruby binary has a compiled-in prefix pointing to the build
@@ -1121,7 +1233,7 @@ module Ocran
1121
1233
  prefix_gem_dirs = (Gem.path.map { |dir| Pathname(dir) } + [Pathname(Gem.default_dir)])
1122
1234
  .select { |dir| dir.subpath?(exec_prefix) }
1123
1235
  .map { |dir| dir.relative_path_from(exec_prefix) }
1124
- .uniq
1236
+ .uniq { |dir| dir.path_key }
1125
1237
  gem_paths += prefix_gem_dirs
1126
1238
  # RubyGems probes the default gem dir for writability at startup and
1127
1239
  # prints "Can't determine writability of default gem path" on stderr
@@ -1137,6 +1249,8 @@ module Ocran
1137
1249
  end
1138
1250
  builder.set_env_path("GEM_PATH", *gem_paths)
1139
1251
 
1252
+ pack_env_hook(builder)
1253
+
1140
1254
  # Add the opcode to launch the script
1141
1255
  installed_ruby_exe = BINDIR / ruby_executable
1142
1256
  target_script = builder.resolve_source_path(@option.script, inst_src_prefix)
@@ -1146,6 +1260,58 @@ module Ocran
1146
1260
  builder.exec(installed_ruby_exe, target_script, *@option.argv)
1147
1261
  end
1148
1262
 
1263
+ # Packs the RubyGems startup hook that sets the exported variables for a
1264
+ # bare bin/ruby of the package (see EnvHook), e.g. a Windows service
1265
+ # registered as `bin\ruby.exe src\service.rb`. It goes into the packed
1266
+ # sitelibdir, the first default load path entry, in every output that
1267
+ # carries the host Ruby: the stub and the launch scripts set the
1268
+ # variables themselves, so there it only loads the original file.
1269
+ # Not with --cosmo-ruby and --static-runtime, which bring their own
1270
+ # Ruby and set up their environment themselves.
1271
+ def pack_env_hook(builder)
1272
+ return if @option.cosmo_ruby || @option.static_runtime?
1273
+
1274
+ site = Pathname(sitelibdir)
1275
+ unless site.subpath?(exec_prefix)
1276
+ verbose "Not packing #{EnvHook::FEATURE}: sitelibdir #{site} is outside #{exec_prefix}"
1277
+ return
1278
+ end
1279
+ if (site / EnvHook::FEATURE).exist?
1280
+ warning "Not packing ocran's #{EnvHook::FEATURE}: #{site} has one already, so a bare " \
1281
+ "#{BINDIR / ruby_executable} of the package will not get the launch script's environment"
1282
+ return
1283
+ end
1284
+
1285
+ target = site.relative_path_from(exec_prefix) / EnvHook::FEATURE
1286
+ say "Adding #{target} (sets the launch environment for a bare #{BINDIR / ruby_executable})"
1287
+ require "tempfile"
1288
+ hook_file = Tempfile.new(["ocran-operating_system", ".rb"])
1289
+ hook_file.write(EnvHook.source(builder.exported_env, target.dirname.each_filename.count))
1290
+ hook_file.close
1291
+ verbose File.read(hook_file.path)
1292
+ # Kept until the build finishes, like the RUBYOPT launcher.
1293
+ @env_hook_file = hook_file
1294
+ builder.cp(hook_file.path, target)
1295
+ end
1296
+ private :pack_env_hook
1297
+
1298
+ # The files below +dir+ that --add-all-core packs, and the entries it
1299
+ # cannot: a Ruby installation can list what is not there - Debian's
1300
+ # rdoc links its fonts into a fonts-lato package that may not be
1301
+ # installed, and a stdlib entry that vanished or cannot be read used to
1302
+ # abort the whole build (ENOENT) over a file no application needs.
1303
+ # Returns [files, skipped], both lists of Pathname.
1304
+ def self.core_library_files(dir)
1305
+ files = []
1306
+ skipped = []
1307
+ dir.find do |src|
1308
+ next if src.directory?
1309
+
1310
+ (src.file? && src.readable? ? files : skipped) << src
1311
+ end
1312
+ [files, skipped]
1313
+ end
1314
+
1149
1315
  # Maps an absolute build-machine path to its location (relative to the
1150
1316
  # extraction root) inside the packed application, mirroring where
1151
1317
  # #construct places files. Returns nil when the path is not packed.
@@ -1295,6 +1461,20 @@ module Ocran
1295
1461
  end
1296
1462
  end
1297
1463
 
1464
+ # The PE resource options (version info, string table, manifest), for
1465
+ # every Windows executable the user launches: the self-extracting exe and
1466
+ # the wrapper that installer, directory and zip output start from.
1467
+ def pe_resource_options
1468
+ {
1469
+ version_strings: @option.version_strings,
1470
+ file_version: @option.file_version,
1471
+ product_version: @option.product_version,
1472
+ resource_strings: @option.resource_strings,
1473
+ execution_level: @option.execution_level,
1474
+ application_manifest: @option.application_manifest&.to_s,
1475
+ }
1476
+ end
1477
+
1298
1478
  # Builds the small RUN_IN_EXE_DIR wrapper stub that starts the deployed
1299
1479
  # application directly from the directory the wrapper resides in.
1300
1480
  def build_wrapper_exe(wrapper_path)
@@ -1307,7 +1487,8 @@ module Ocran
1307
1487
  gui_mode: @option.windowed?,
1308
1488
  icon_path: @option.icon_filename,
1309
1489
  run_in_exe_dir: true,
1310
- stub_path: cosmo_stub_path) do |stub|
1490
+ stub_path: cosmo_stub_path,
1491
+ **pe_resource_options) do |stub|
1311
1492
  yield(stub)
1312
1493
  end
1313
1494
  end
@@ -1317,7 +1498,11 @@ module Ocran
1317
1498
 
1318
1499
  path = Pathname(path)
1319
1500
  say "Building directory #{path}"
1320
- builder = DirBuilder.new(path, &to_proc)
1501
+ builder = DirBuilder.new(path,
1502
+ script_name: @option.script.basename.sub_ext("").to_s,
1503
+ chdir_before: @option.chdir_before?,
1504
+ chdir_exe_dir: @option.chdir_exe_dir?,
1505
+ &to_proc)
1321
1506
 
1322
1507
  if @option.wrapper_exe?
1323
1508
  # Same wrapper as in Inno Setup builds: a doubleclickable executable
@@ -1381,30 +1566,41 @@ module Ocran
1381
1566
  end
1382
1567
 
1383
1568
  bundle_id = @option.bundle_identifier || "com.example.#{app_name}"
1384
- icon_entry = @option.icon_filename ? " <key>CFBundleIconFile</key>\n <string>AppIcon</string>\n" : ""
1569
+ File.write(contents_dir / "Info.plist",
1570
+ self.class.info_plist(app_name, bundle_id, icon: !!@option.icon_filename))
1571
+
1572
+ say "Finished building #{bundle_path} (#{builder.data_size} bytes decompressed)"
1573
+ end
1385
1574
 
1386
- File.write(contents_dir / "Info.plist", <<~PLIST)
1575
+ XML_ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&apos;" }.freeze
1576
+
1577
+ # The Info.plist of a macOS app bundle. The values are XML-escaped: an
1578
+ # app named "R&D" must not produce a property list macOS cannot parse.
1579
+ def self.info_plist(app_name, bundle_id, icon: false)
1580
+ name = app_name.to_s.gsub(/[&<>"']/, XML_ESCAPES)
1581
+ id = bundle_id.to_s.gsub(/[&<>"']/, XML_ESCAPES)
1582
+ icon_entry = icon ? " <key>CFBundleIconFile</key>\n <string>AppIcon</string>\n" : ""
1583
+
1584
+ <<~PLIST
1387
1585
  <?xml version="1.0" encoding="UTF-8"?>
1388
1586
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1389
1587
  <plist version="1.0">
1390
1588
  <dict>
1391
1589
  <key>CFBundleName</key>
1392
- <string>#{app_name}</string>
1590
+ <string>#{name}</string>
1393
1591
  <key>CFBundleDisplayName</key>
1394
- <string>#{app_name}</string>
1592
+ <string>#{name}</string>
1395
1593
  <key>CFBundleIdentifier</key>
1396
- <string>#{bundle_id}</string>
1594
+ <string>#{id}</string>
1397
1595
  <key>CFBundleVersion</key>
1398
1596
  <string>1.0</string>
1399
1597
  <key>CFBundlePackageType</key>
1400
1598
  <string>APPL</string>
1401
1599
  <key>CFBundleExecutable</key>
1402
- <string>#{app_name}</string>
1600
+ <string>#{name}</string>
1403
1601
  #{icon_entry}</dict>
1404
1602
  </plist>
1405
1603
  PLIST
1406
-
1407
- say "Finished building #{bundle_path} (#{builder.data_size} bytes decompressed)"
1408
1604
  end
1409
1605
 
1410
1606
  # Builds the executable by copying the cosmopolitan Ruby and injecting
@@ -1419,6 +1615,7 @@ module Ocran
1419
1615
  ZipPayloadBuilder.new(output,
1420
1616
  cosmo_ruby: @option.cosmo_ruby,
1421
1617
  chdir_before: @option.chdir_before?,
1618
+ chdir_exe_dir: @option.chdir_exe_dir?,
1422
1619
  debug_mode: @option.enable_debug_mode?,
1423
1620
  &to_proc) => builder
1424
1621
 
@@ -1441,6 +1638,56 @@ module Ocran
1441
1638
  say "Finished building #{output} (#{output.size} bytes, #{builder.data_size} bytes of application data)"
1442
1639
  end
1443
1640
 
1641
+ # --static-runtime: the application appended as a ZIP archive to a statically
1642
+ # linked Windows Ruby (see StaticRuntimeBuilder).
1643
+ def build_static_runtime_exe
1644
+ require_relative "static_runtime_builder"
1645
+
1646
+ runtime = StaticRuntime.load(@option.static_runtime)
1647
+ runtime.verify!
1648
+ host_api = RbConfig::CONFIG["ruby_version"]
1649
+ # Gems, default gem specs and the host files the runtime does not
1650
+ # replace were resolved for the build host's Ruby; they only fit a
1651
+ # runtime with the same API version.
1652
+ unless host_api == runtime.ruby_api_version
1653
+ raise "The static runtime in #{runtime.dir} is Ruby #{runtime.ruby_version} (API #{runtime.ruby_api_version}), " \
1654
+ "but this is Ruby #{RUBY_VERSION} (API #{host_api}): package with a Ruby #{runtime.ruby_api_version.sub(/\.0\z/, "")}"
1655
+ end
1656
+ if RUBY_VERSION != runtime.ruby_version
1657
+ warning "Dependency detection ran under Ruby #{RUBY_VERSION}; the static runtime is Ruby #{runtime.ruby_version}"
1658
+ end
1659
+ say "Packaging for the static runtime #{runtime.executable} (Ruby #{runtime.ruby_version} #{runtime.arch})"
1660
+ if @option.extra_dlls.any?
1661
+ warning "--dll has no effect with --static-runtime: a DLL cannot be loaded from inside the executable"
1662
+ end
1663
+
1664
+ output = @option.output_executable
1665
+ StaticRuntimeBuilder.new(output,
1666
+ runtime: runtime,
1667
+ host_ruby_api_version: host_api,
1668
+ host_arch: RbConfig::CONFIG["arch"],
1669
+ chdir_before: @option.chdir_before?,
1670
+ chdir_exe_dir: @option.chdir_exe_dir?,
1671
+ windowed: @option.windowed?,
1672
+ debug_mode: @option.enable_debug_mode?,
1673
+ dlls: @option.runtime_dlls,
1674
+ resources: { icon_path: @option.icon_filename&.to_s, **pe_resource_options },
1675
+ &to_proc) => builder
1676
+
1677
+ builder.skipped.each do |reason, names|
1678
+ verbose "Not packed (#{reason.to_s.tr("_", " ")}): #{names.size} file(s)"
1679
+ names.each { |name| verbose "\t#{name}" }
1680
+ end
1681
+ unless builder.skipped[:not_in_runtime_stdlib].empty?
1682
+ say "#{builder.skipped[:not_in_runtime_stdlib].size} file(s) of this Ruby's standard library are not " \
1683
+ "in the runtime's and were left out (--verbose lists them)"
1684
+ end
1685
+ say "Finished building #{output} (#{output.size} bytes, #{builder.data_size} bytes of application data)"
1686
+ unless builder.shipped_dlls.empty?
1687
+ say "Ship these DLLs next to #{output.basename}: #{builder.shipped_dlls.map { |d| d.basename.to_s }.join(", ")}"
1688
+ end
1689
+ end
1690
+
1444
1691
  def build_stab_exe
1445
1692
  require_relative "stub_builder"
1446
1693
 
@@ -1457,6 +1704,7 @@ module Ocran
1457
1704
  gui_mode: @option.windowed?,
1458
1705
  icon_path: @option.icon_filename,
1459
1706
  stub_path: cosmo_stub_path,
1707
+ **pe_resource_options,
1460
1708
  &to_proc) => builder
1461
1709
  say "Finished building #{@option.output_executable} (#{@option.output_executable.size} bytes)"
1462
1710
  say "After decompression, the data will expand to #{builder.data_size} bytes."
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ require "fiddle/import"
3
+ require "fiddle/types"
4
+ # The language/codepage constants and the pure-Ruby blob builder live in a
5
+ # separate, dlload-free file so they can be unit-tested on any platform.
6
+ require_relative "ed_resource_builder"
7
+
8
+ module Ocran
9
+ # Edits PE resources in an executable, in the style of rcedit:
10
+ # * RT_VERSION - version info (VS_VERSIONINFO / VS_FIXEDFILEINFO / StringFileInfo)
11
+ # * RT_MANIFEST - application manifest / requested execution level
12
+ # * RT_STRING - string table entries by numeric ID
13
+ #
14
+ # This is a sibling of EdIcon and uses the same Windows resource-update API
15
+ # (BeginUpdateResourceW / UpdateResourceW / EndUpdateResourceW) via Fiddle.
16
+ module EdResource
17
+ extend Fiddle::Importer
18
+ dlload "kernel32.dll"
19
+
20
+ include Fiddle::Win32Types
21
+ typealias "LPVOID", "void*"
22
+ typealias "LPCWSTR", "char*"
23
+
24
+ MAKEINTRESOURCE = -> (i) { Fiddle::Pointer.new(i) }
25
+ RT_VERSION = MAKEINTRESOURCE.(16)
26
+ RT_STRING = MAKEINTRESOURCE.(6)
27
+ # CREATEPROCESS_MANIFEST_RESOURCE_ID == 1; the manifest is stored under
28
+ # resource type RT_MANIFEST (24), name id 1.
29
+ RT_MANIFEST = MAKEINTRESOURCE.(24)
30
+ MANIFEST_RESOURCE_ID = 1
31
+
32
+ # Language/codepage constants (MAKELANGID, LANGID, LANGID_US_ENGLISH,
33
+ # CODEPAGE_UNICODE, STRING_TABLE_KEY), EXECUTION_LEVELS and
34
+ # MANIFEST_TEMPLATE are defined in ed_resource_builder.rb, required above.
35
+
36
+ extern "DWORD GetLastError()"
37
+ extern "HANDLE BeginUpdateResourceW(LPCWSTR, BOOL)"
38
+ extern "BOOL EndUpdateResourceW(HANDLE, BOOL)"
39
+ extern "BOOL UpdateResourceW(HANDLE, LPCWSTR, LPCWSTR, WORD, LPVOID, DWORD)"
40
+
41
+ class << self
42
+ # Perform every requested resource edit under a single
43
+ # BeginUpdateResource/EndUpdateResource cycle (one PE rewrite).
44
+ def update(executable_filename, version_strings: nil, file_version: nil,
45
+ product_version: nil, resource_strings: nil, manifest_xml: nil,
46
+ execution_level: nil)
47
+ wants_version = !blank?(version_strings) || file_version || product_version
48
+ wants_manifest = manifest_xml || execution_level
49
+ wants_strings = !blank?(resource_strings)
50
+ return unless wants_version || wants_manifest || wants_strings
51
+
52
+ update_resource(executable_filename) do |handle|
53
+ write_version_info(handle, version_strings || {}, file_version, product_version) if wants_version
54
+ write_manifest(handle, manifest_xml, execution_level) if wants_manifest
55
+ write_resource_strings(handle, resource_strings) if wants_strings
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def blank?(value)
62
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
63
+ end
64
+
65
+ def write_version_info(handle, strings, file_version, product_version)
66
+ merged = {}
67
+ strings.each { |k, v| merged[k.to_s] = v }
68
+ merged["FileVersion"] = file_version if file_version
69
+ merged["ProductVersion"] = product_version if product_version
70
+
71
+ blob = Build.version_info(
72
+ strings: merged,
73
+ file_version: file_version || merged["FileVersion"] || "0.0.0.0",
74
+ product_version: product_version || merged["ProductVersion"] || "0.0.0.0"
75
+ )
76
+ put(handle, RT_VERSION, 1, blob)
77
+ end
78
+
79
+ def write_manifest(handle, manifest_xml, execution_level)
80
+ xml = Build.manifest(manifest_xml, execution_level)
81
+ put(handle, RT_MANIFEST, MANIFEST_RESOURCE_ID, xml.b)
82
+ end
83
+
84
+ def write_resource_strings(handle, id_to_value)
85
+ id_to_value.group_by { |id, _| id.to_i / 16 }.each do |bundle_index, pairs|
86
+ bundle = Build.string_bundle(pairs.to_h)
87
+ put(handle, RT_STRING, bundle_index + 1, bundle)
88
+ end
89
+ end
90
+
91
+ def put(handle, type, name, data)
92
+ name_arg = name.is_a?(Integer) ? MAKEINTRESOURCE.(name) : name
93
+ if UpdateResourceW(handle, type, name_arg, LANGID, data, data.bytesize) == 0
94
+ raise "Failed to UpdateResourceW(#{GetLastError()})"
95
+ end
96
+ end
97
+
98
+ def update_resource(executable_filename)
99
+ path = File.expand_path(executable_filename).encode("UTF-16LE").b + "\x00\x00".b
100
+ handle = BeginUpdateResourceW(path, 0)
101
+ if handle == Fiddle::NULL
102
+ raise "Failed to BeginUpdateResourceW(#{GetLastError()})"
103
+ end
104
+
105
+ yield(handle)
106
+
107
+ if EndUpdateResourceW(handle, 0) == 0
108
+ raise "Failed to EndUpdateResourceW(#{GetLastError()})"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end