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
@@ -1,4 +1,10 @@
1
+ require 'json'
2
+ require 'rbconfig'
3
+ require 'securerandom'
4
+ require 'tmpdir'
1
5
  require 'reflex/packager/platform'
6
+ require 'reflex/packager/gems'
7
+ require 'reflex/packager/app_store_connect'
2
8
 
3
9
 
4
10
  module Reflex
@@ -9,87 +15,222 @@ module Reflex
9
15
 
10
16
  # Packages a Reflex application as a macOS application bundle.
11
17
  #
18
+ # The native libraries (xot, rucy, rays, ...) are compiled from the
19
+ # sources in their installed gems, as their Rakefiles build them, into an
20
+ # executable that embeds CRuby, which comes as a prebuilt xcframework
21
+ # from a checkout of the cruby repository.
22
+ #
12
23
  class MacOS < Platform
13
24
 
14
- GIT_CRUBY = 'https://github.com/xord/cruby'
25
+ include Gems
26
+
27
+ CRUBY_GIT = 'https://github.com/xord/cruby'
28
+
29
+ # The CRuby used unless the config or CRUBY_PATH names another one.
30
+ #
31
+ CRUBY_VERSION = '4.0.701'
32
+
33
+ # The identity of the certificate of Developer ID in the keychain, which
34
+ # the full name of the certificate starts with.
35
+ #
36
+ DEVELOPER_ID = 'Developer ID Application'
37
+
38
+ # The environment variables of the certificate of Developer ID to sign
39
+ # with, put into a keychain only while signing, as on CI: the .p12 file
40
+ # in base64, and the password of it.
41
+ #
42
+ CODESIGN_P12_ENV_KEYS = {
43
+ p12: 'APPLE_CODESIGN_P12',
44
+ password: 'APPLE_CODESIGN_P12_PASSWORD'
45
+ }
15
46
 
16
47
  TOOLS = {
48
+ git: 'install Xcode command line tools: xcode-select --install',
17
49
  xcodegen: 'install with: brew install xcodegen',
18
- pod: 'install with: brew install cocoapods (or gem install cocoapods)',
19
50
  xcodebuild: 'install Xcode and run: sudo xcode-select --switch /Applications/Xcode.app'
20
51
  }
21
52
 
53
+ # A packed app compiles its scripts when built, and a notarized one is
54
+ # sent to Apple when exported, as the one for the Mac App Store is
55
+ # exported from its archive, so they have to be built; the app only
56
+ # exported after its notarization is not built, but taken from the
57
+ # archive sent.
58
+ #
59
+ def package(generate_only: false)
60
+ case
61
+ # what can not be done together
62
+ when generate_only && (pack? || notarize? || appstore?)
63
+ 'a packed, notarized or Mac App Store app has to be built on macos'
64
+ when appstore? && notarize == [:export]
65
+ 'only exporting the notarized app, which is the generic one, makes no app' +
66
+ ' for the Mac App Store'
67
+ # what macos does not take
68
+ when config.build_version.split('.').size > 3
69
+ 'macos takes three numbers at most for the build version, as' +
70
+ " CFBundleVersion: '#{config.build_version}'"
71
+ # what the config lacks
72
+ when !config.macos.codesign_team_id &&
73
+ (notarize.include?(:submit) || appstore? || codesign_p12)
74
+ 'notarizing, the Mac App Store and the certificate by' +
75
+ " #{CODESIGN_P12_ENV_KEYS[:p12]} need the team to sign with," +
76
+ " by macos.codesign.team_id or #{MacOSConfig::CODESIGN_ENV_KEYS[:team_id]}"
77
+ when appstore? && !config.macos.category
78
+ 'the Mac App Store needs the category of the app, by macos.category'
79
+ # how the app is signed
80
+ when (notarize.include?(:submit) || @generic) && !signing_to_distribute?
81
+ 'notarizing and --generic are only for the app signed with Developer ID,' +
82
+ ' by the certificate Apple manages, with no identity to sign with,' +
83
+ " or by #{DEVELOPER_ID}"
84
+ when generic? && notarize != [:export] && !generate_only &&
85
+ cloud_signing? && AppStoreConnect.credentials
86
+ # FB16835802 of Apple, which may be fixed some day
87
+ 'the API key is refused to sign with the certificate Apple manages,' +
88
+ " give the certificate by #{CODESIGN_P12_ENV_KEYS[:p12]}"
89
+ end&.tap {raise Error, _1}
90
+ notarize == [:export] ? export_notarized_app : super
91
+ end
92
+
22
93
  def generate()
94
+ # the bundles first, which what reads the data file is copied into
95
+ copy_bundles
23
96
  copy_app_files
24
97
  generate_icon if config.icon
25
- write 'project.yml', render('project.yml.erb')
26
- write 'Podfile', render('Podfile.erb')
27
- write 'src/main.mm', render('main.mm.erb')
98
+ write 'project.yml', render('project.yml.erb')
99
+ write 'src/main.mm', render('main.mm.erb')
100
+ write 'boot.rb', render('boot.rb.erb')
101
+ write export_options_path, render('ExportOptions.plist.erb') if signing_to_distribute?
102
+ if appstore?
103
+ write appstore_entitlements_path, render('AppStore.entitlements.erb')
104
+ write appstore_export_options_path, render('AppStoreExportOptions.plist.erb')
105
+ end
106
+ write_localizations
28
107
  end
29
108
 
30
109
  def build()
31
110
  check_tools TOOLS
32
- check_dev_pods
33
- run 'xcodegen', 'generate', chdir: build_dir
34
- run 'pod', 'install', *('--verbose' if verbose?), chdir: build_dir,
35
- env: {'os' => 'macos'}
36
- run 'xcodebuild',
37
- '-workspace', "#{target}.xcworkspace",
38
- '-scheme', target,
39
- '-configuration', 'Release',
40
- '-destination', 'platform=macOS',
41
- '-derivedDataPath', 'DerivedData',
42
- 'build',
43
- chdir: build_dir
44
- copy_app
111
+ run 'xcodegen', 'generate', *('--quiet' unless verbose?), chdir: build_dir
112
+ compile_scripts if pack?
113
+ build_generic if generic?
114
+ export_appstore_pkg if appstore?
45
115
  end
46
116
 
47
- # Returns the Xcode target name: the app name without characters
48
- # unsafe for target/scheme/file names.
117
+ # The Xcode project, with what it is made of and what it builds, apart
118
+ # from the archives of the app in platform_dir.
49
119
  #
50
- def target()
51
- config.name.gsub(/[^A-Za-z0-9_\-]+/, '').then {_1.empty? ? 'App' : _1}
120
+ def build_dir()
121
+ File.join platform_dir, 'project'
52
122
  end
53
123
 
54
- def build_dir()
55
- File.join config.dir, 'build', platform_name
124
+ # The generic one is signed with Developer ID, to be handed out, and
125
+ # the one signed otherwise is adhoc, only to be run where it is built.
126
+ #
127
+ def variant()
128
+ signing_to_distribute? || notarize? ? super : 'adhoc'
129
+ end
130
+
131
+ # The name of the .app and its executable, which Finder, the Dock and
132
+ # /Applications show: the name of the app as it is, with spaces, as Mac
133
+ # apps are named, unlike the target.
134
+ #
135
+ def app_name()
136
+ config.name
56
137
  end
57
138
 
58
- def dist_dir()
59
- File.join config.dir, 'dist'
139
+ def dist_path()
140
+ File.join dist_dir, "#{app_name}.app"
60
141
  end
61
142
 
62
- # Native extensions registered with CRuby (Init_<name> symbols).
143
+ # The installer package of the app for the Mac App Store, to upload to
144
+ # App Store Connect.
63
145
  #
64
- def extensions()
65
- profile.extensions
146
+ def appstore_pkg_path()
147
+ File.join dist_dir('appstore'), "#{target}-#{config.version}.pkg"
148
+ end
149
+
150
+ def store_package_paths()
151
+ {appstore: appstore_pkg_path}
66
152
  end
67
153
 
68
- # Ruby library bundles added to the load path.
154
+ # The command to zip the packaged app into archive_path, by ditto,
155
+ # which keeps the symlinks and the signature of the app.
69
156
  #
70
- def libraries()
71
- profile.libraries
157
+ def archive_command()
158
+ ['ditto', '-c', '-k', '--keepParent', dist_path, archive_path]
159
+ end
160
+
161
+ def archive_platform()
162
+ archs = config.macos.archs
163
+ "macos-#{archs.size == 1 ? archs.first : 'universal'}"
72
164
  end
73
165
 
74
- # Returns {'CRuby' => {...}, '<pod>' => {...}} resolved from the config,
75
- # the <POD>_PODS_PATH env var, or the defaults.
166
+ # Libraries built from native code: the ones whose gem has an
167
+ # extension to build, even if only for its tests as xot and rucy.
76
168
  #
77
- def pod_refs()
78
- p = profile
79
- {
80
- 'CRuby' => pod_ref(:cruby, {git: GIT_CRUBY}),
81
- p.pod => pod_ref(p.pod_key, {git: p.git, tag: "v#{p.version}"})
82
- }
169
+ def native_libraries()
170
+ libraries.select {File.file? File.join(_1.root, 'ext', _1.name, 'extconf.rb')}
83
171
  end
84
172
 
85
- # Returns local directories of pods referenced by path, which need
86
- # special handling because CocoaPods does not place development
87
- # pods under PODS_ROOT and does not run their prepare_command.
173
+ # Returns {library => {srcs:, vendor_srcs:, incdirs:, defs:}} of the native
174
+ # libraries, with absolute paths, as their Rakefiles have them.
88
175
  #
89
- def dev_pod_paths()
90
- pod_refs
91
- .filter_map {|name, ref| [name, ref[:path]] if ref[:path]}
92
- .to_h
176
+ def build_infos()
177
+ @build_infos ||= native_libraries.to_h do |lib|
178
+ info = rake_build_info lib.root
179
+ ext = File.join lib.root, 'ext', lib.name
180
+ info[:srcs] += Dir.glob(File.join ext, '*.{c,cpp,m,mm}').sort if lib.extension
181
+ [lib, info]
182
+ end
183
+ end
184
+
185
+ # Frameworks the extensions link, as the Makefiles their gem builds
186
+ # leave have them.
187
+ #
188
+ def frameworks()
189
+ native_libraries.flat_map do |lib|
190
+ makefile = File.join lib.root, 'ext', lib.name, 'Makefile'
191
+ unless File.file? makefile
192
+ raise Error, "no Makefile of '#{lib}' in '#{lib.root}' (was the gem built?)" if
193
+ lib.extension
194
+ next [] # xot and rucy build their extensions only for their tests
195
+ end
196
+ MacOS.makefile_frameworks File.read(makefile)
197
+ end.uniq
198
+ end
199
+
200
+ # The names in the -framework options of the ldflags of a Makefile mkmf
201
+ # writes.
202
+ #
203
+ def self.makefile_frameworks(makefile)
204
+ line = makefile.lines.find {_1.start_with? 'ldflags'} or return []
205
+ line.scan(/-framework\s+(\S+)/).flatten
206
+ end
207
+
208
+ def header_search_paths()
209
+ build_infos.values.flat_map {_1[:incdirs]}.uniq.reject {vendor? _1}
210
+ end
211
+
212
+ def system_header_search_paths()
213
+ [
214
+ *build_infos.values.flat_map {_1[:incdirs]}.uniq.select {vendor? _1},
215
+ File.join(cruby_dir, 'CRuby', 'include')
216
+ ]
217
+ end
218
+
219
+ # The checkout of the cruby repository with the CRuby it built or
220
+ # downloaded: CRUBY_PATH, which overrides the config while working on
221
+ # CRuby, or the path in the config, or else the version in the config
222
+ # or the default one, fetched into the build directory.
223
+ #
224
+ def cruby_dir()
225
+ @cruby_dir ||= begin
226
+ env, cruby = ENV['CRUBY_PATH'], config.macos.cruby
227
+ case
228
+ when env then check_cruby File.expand_path(env)
229
+ when !cruby then fetch_cruby CRUBY_VERSION
230
+ when version?(cruby) then fetch_cruby cruby
231
+ else check_cruby File.expand_path(cruby, config.dir)
232
+ end
233
+ end
93
234
  end
94
235
 
95
236
  # Returns sips command lines to resize the icon into an iconset.
@@ -104,34 +245,412 @@ module Reflex
104
245
 
105
246
  private
106
247
 
248
+ # Prints what the Rakefile in the current directory builds with.
249
+ #
250
+ RAKE_BUILD_INFO = <<~RUBY
251
+ require 'json'
252
+ require 'rake'
253
+ Rake.application.init 'rake', []
254
+ load 'Rakefile'
255
+ puts JSON.generate(
256
+ srcs: srcs_map.keys,
257
+ vendor_srcs: vendor_srcs_map.keys,
258
+ incdirs: inc_dirs,
259
+ defs: make_cppflags_defs(defs))
260
+ RUBY
261
+
107
262
  def platform_name()
108
263
  'macos'
109
264
  end
110
265
 
111
- def pod_ref(name, default)
112
- ref = config.pods[name]
113
- return ref unless ref.nil? || ref.empty?
266
+ # The app compiles its scripts with the CRuby in it, when built.
267
+ #
268
+ def compile_on_generate?()
269
+ false
270
+ end
114
271
 
115
- root = ENV["#{profile.pod_key.to_s.upcase}_PODS_PATH"]
116
- root ? {path: File.expand_path(name.to_s, root)} : default
272
+ def xcodebuild(action = 'build', *args)
273
+ run 'xcodebuild', *('-quiet' unless verbose?),
274
+ '-project', "#{target}.xcodeproj",
275
+ '-scheme', target,
276
+ '-configuration', 'Release',
277
+ '-destination', 'generic/platform=macOS',
278
+ '-derivedDataPath', 'DerivedData',
279
+ action, *args,
280
+ chdir: build_dir
117
281
  end
118
282
 
119
- def pod_line(name, ref)
120
- args = ref.map {|key, value| "#{key}: '#{value}'"}.join ', '
121
- "pod '#{name}', #{args}"
283
+ # The identity to sign with, which is of Developer ID by the certificate
284
+ # APPLE_CODESIGN_P12 gives, with no other identity.
285
+ #
286
+ def codesign_identity()
287
+ identity = config.macos.codesign_identity
288
+ identity == '-' && codesign_p12 ? DEVELOPER_ID : identity
289
+ end
290
+
291
+ # The certificate APPLE_CODESIGN_P12 gives, as {data:, password:} with
292
+ # the data of the .p12 file decoded, or nil.
293
+ #
294
+ def codesign_p12()
295
+ p12, password = ENV.values_at(*CODESIGN_P12_ENV_KEYS.values)
296
+ return nil if p12.to_s.empty?
297
+ {data: p12.unpack1('m'), password: password.to_s}
298
+ end
299
+
300
+ # Whether the app is signed by the certificate of Developer ID in the
301
+ # keychain, as on CI, where Apple refuses the API key to sign with the
302
+ # one it manages.
303
+ #
304
+ def keychain_signing?()
305
+ config.macos.codesign_team_id && codesign_identity.start_with?(DEVELOPER_ID)
306
+ end
307
+
308
+ # Whether the app is signed by the certificate of the team that Apple
309
+ # manages for it, which needs no certificate in the keychain: the team
310
+ # without the identity to sign with. The certificate signs the app
311
+ # only when exported from its archive.
312
+ #
313
+ def cloud_signing?()
314
+ config.macos.codesign_team_id && codesign_identity == '-'
315
+ end
316
+
317
+ # Whether the app is signed to be distributed, with Developer ID, which
318
+ # signs the app exported from its archive.
319
+ #
320
+ def signing_to_distribute?()
321
+ keychain_signing? || cloud_signing?
322
+ end
323
+
324
+ # Builds the generic app into the dist directory: as it is built, or
325
+ # exported signed with Developer ID, or notarized too.
326
+ #
327
+ def build_generic()
328
+ case
329
+ when !signing_to_distribute?
330
+ xcodebuild
331
+ copy_app
332
+ when !notarize?
333
+ export_app
334
+ copy_app exported_app_path
335
+ else
336
+ export_app
337
+ puts "Submitted for the notarization: #{submission_id}"
338
+ export_notarized_app if notarize.include? :export
339
+ end
340
+ end
341
+
342
+ # Archives the app and exports it signed with Developer ID, by the
343
+ # account of Xcode, or by the API key of App Store Connect that
344
+ # APPLE_API_ISSUER_ID, APPLE_API_KEY_ID and APPLE_API_KEY_P8 or
345
+ # APPLE_API_KEY_PATH tell.
346
+ #
347
+ def export_app()
348
+ xcodebuild 'archive', '-archivePath', xcarchive_path
349
+ FileUtils.rm_rf File.dirname(exported_app_path)
350
+ with_codesign_keychain do
351
+ with_auth_options do |auth|
352
+ run 'xcodebuild', *('-quiet' unless verbose?),
353
+ '-exportArchive',
354
+ '-archivePath', xcarchive_path,
355
+ '-exportOptionsPlist', export_options_path,
356
+ '-exportPath', File.dirname(exported_app_path),
357
+ '-allowProvisioningUpdates',
358
+ *auth,
359
+ chdir: build_dir
360
+ end
361
+ end
362
+ end
363
+
364
+ # The ID of the notarization the archive was submitted for last, in
365
+ # lower case as the notary service takes it.
366
+ #
367
+ def submission_id()
368
+ dir = File.join xcarchive_path, 'Submissions'
369
+ id = Dir.exist?(dir) && Dir.children(dir).max_by {File.mtime File.join(dir, _1)}
370
+ raise Error, "no submission for the notarization in '#{xcarchive_path}'" unless id
371
+ id.downcase
372
+ end
373
+
374
+ # Exports the app stapled with the ticket of the notarization into the
375
+ # dist directory, waiting for Apple to check the app sent and to issue
376
+ # the ticket, which takes some minutes.
377
+ #
378
+ def export_notarized_app()
379
+ raise Error, "no archive sent to be notarized: '#{xcarchive_path}'" unless
380
+ File.directory? xcarchive_path
381
+
382
+ FileUtils.rm_rf File.dirname(notarized_app_path)
383
+ with_auth_options do |auth|
384
+ cmd = [
385
+ 'xcodebuild', '-exportNotarizedApp',
386
+ '-archivePath', xcarchive_path,
387
+ '-exportPath', File.dirname(notarized_app_path),
388
+ *auth
389
+ ]
390
+ deadline = Time.now + AppStoreConnect::NOTARIZATION_TIMEOUT
391
+ loop do
392
+ out, ok = capture(*cmd)
393
+ break if ok
394
+ raise Error, "failed to export the notarized app:\n#{out}" unless
395
+ out.match?(/processing and not ready|Ticket not found/)
396
+ raise Error, 'timed out waiting for the notarization' if Time.now > deadline
397
+ puts 'waiting for the notarization...'
398
+ sleep AppStoreConnect::NOTARIZATION_INTERVAL
399
+ end
400
+ end
401
+ copy_app notarized_app_path
122
402
  end
123
403
 
124
- def copy_app_files()
125
- dir = File.join build_dir, 'app'
404
+ # Archives the app in the sandbox, which the Mac App Store needs, and
405
+ # exports it into the installer package to upload, signed by the
406
+ # certificates of the team that Apple manages, as the profile too.
407
+ #
408
+ # The archive is of its own, apart from the generic one out of the
409
+ # sandbox, which shares its data with the ones of the other stores.
410
+ #
411
+ def export_appstore_pkg()
412
+ xcodebuild 'archive', '-archivePath', appstore_xcarchive_path,
413
+ "CODE_SIGN_ENTITLEMENTS=#{appstore_entitlements_path}"
414
+ FileUtils.rm_rf File.dirname(appstore_exported_pkg_path)
415
+ with_auth_options do |auth|
416
+ run 'xcodebuild', *('-quiet' unless verbose?),
417
+ '-exportArchive',
418
+ '-archivePath', appstore_xcarchive_path,
419
+ '-exportOptionsPlist', appstore_export_options_path,
420
+ '-exportPath', File.dirname(appstore_exported_pkg_path),
421
+ '-allowProvisioningUpdates',
422
+ *auth,
423
+ chdir: build_dir
424
+ end
425
+ copy_pkg appstore_exported_pkg_path, appstore_pkg_path
426
+ end
427
+
428
+ # Calls the block with the certificate APPLE_CODESIGN_P12 gives in a
429
+ # keychain of its own, which is deleted then, not to leave the private
430
+ # key on the machine, as a runner of CI of one's own.
431
+ #
432
+ def with_codesign_keychain(&block)
433
+ p12 = codesign_p12
434
+ return block.call unless p12
435
+
436
+ Dir.mktmpdir do |dir|
437
+ keychain_path = File.join dir, 'codesign.keychain-db'
438
+ p12_path = File.join dir, 'codesign.p12'
439
+ secret = SecureRandom.hex 16
440
+ others = security('list-keychains', '-d', 'user').lines.map {_1.strip.delete '"'}
441
+ File.binwrite p12_path, p12[:data]
442
+ security 'create-keychain', '-p', secret, keychain_path
443
+ begin
444
+ security 'set-keychain-settings', '-lut', '21600', keychain_path
445
+ security 'unlock-keychain', '-p', secret, keychain_path
446
+ security 'import', p12_path, '-k', keychain_path, '-P', p12[:password],
447
+ '-T', '/usr/bin/codesign', '-T', '/usr/bin/security'
448
+ security 'set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k',
449
+ secret, keychain_path
450
+ security 'list-keychains', '-d', 'user', '-s', keychain_path, *others
451
+ block.call
452
+ ensure
453
+ # delete-keychain takes it out of the keychains to search too
454
+ security 'delete-keychain', keychain_path
455
+ end
456
+ end
457
+ end
458
+
459
+ # Runs the security command, not printing it with the passwords.
460
+ #
461
+ def security(*args)
462
+ out, ok = capture 'security', *args
463
+ raise Error, "failed to run security #{args.first}:\n#{out}" unless ok
464
+ out
465
+ end
466
+
467
+ # Calls the block with the options of xcodebuild to authenticate by the
468
+ # API key, the .p8 file of which is written for the while when given
469
+ # by the content.
470
+ #
471
+ def with_auth_options(&block)
472
+ creds = AppStoreConnect.credentials
473
+ return block.call [] unless creds
474
+
475
+ AppStoreConnect.new(creds).with_key_path do |path|
476
+ block.call [
477
+ '-authenticationKeyIssuerID', creds[:issuer_id],
478
+ '-authenticationKeyID', creds[:key_id],
479
+ '-authenticationKeyPath', path
480
+ ]
481
+ end
482
+ end
483
+
484
+ def built_app_path()
485
+ File.join build_dir, 'DerivedData', 'Build', 'Products', 'Release', "#{app_name}.app"
486
+ end
487
+
488
+ # The directory of the app archived and exported for no store in
489
+ # particular, apart from the ones for a store.
490
+ #
491
+ def generic_dir()
492
+ File.join platform_dir, 'generic'
493
+ end
494
+
495
+ def xcarchive_path()
496
+ File.join generic_dir, "#{target}.xcarchive"
497
+ end
498
+
499
+ def export_options_path()
500
+ File.join generic_dir, 'ExportOptions.plist'
501
+ end
502
+
503
+ def exported_app_path()
504
+ File.join generic_dir, 'export', "#{app_name}.app"
505
+ end
506
+
507
+ def notarized_app_path()
508
+ File.join generic_dir, 'notarized', "#{app_name}.app"
509
+ end
510
+
511
+ # The directory of the app archived and exported for the Mac App Store.
512
+ #
513
+ def appstore_dir()
514
+ File.join platform_dir, 'appstore'
515
+ end
516
+
517
+ def appstore_xcarchive_path()
518
+ File.join appstore_dir, "#{target}.xcarchive"
519
+ end
520
+
521
+ def appstore_entitlements_path()
522
+ File.join appstore_dir, 'AppStore.entitlements'
523
+ end
524
+
525
+ def appstore_export_options_path()
526
+ File.join appstore_dir, 'ExportOptions.plist'
527
+ end
528
+
529
+ def appstore_exported_pkg_path()
530
+ File.join appstore_dir, 'export', "#{app_name}.pkg"
531
+ end
532
+
533
+ # Has the app write its data file with its scripts compiled by the
534
+ # CRuby in it, which runs them: the app is built and run once with a
535
+ # boot script that does only that, in place of the one it has.
536
+ #
537
+ def compile_scripts()
538
+ boot = File.join build_dir, 'boot.rb'
539
+ orig = File.read boot
540
+ File.write boot, render('compile.rb.erb',
541
+ compiler: File.expand_path('data_compiler.rb', __dir__),
542
+ app_dir: app_dir,
543
+ path: File.join(build_dir, 'app', DataLoader::DATA_FILE))
544
+ xcodebuild
545
+ # with nothing of the Ruby running this, as bundle exec sets, which
546
+ # would have the CRuby in the app load the gems of it
547
+ env = ENV.keys.grep(/\A(BUNDLER?_|GEM_)/)
548
+ .then {[*_1, 'RUBYOPT', 'RUBYLIB']}
549
+ .to_h {[_1, nil]}
550
+ run File.join(built_app_path, 'Contents', 'MacOS', app_name), chdir: build_dir, env: env
551
+ ensure
552
+ File.write boot, orig if orig
553
+ end
554
+
555
+ # CRuby has the bundled gems in its standard library.
556
+ #
557
+ def standard_gems?()
558
+ false
559
+ end
560
+
561
+ # Whether CRuby has the gem in its standard library, where it has the
562
+ # bundled gems laid out flat, as fiddle, which was a default gem: the
563
+ # file the gem is required by, its name with '-' as '/', is there.
564
+ #
565
+ def embedded_gem?(name)
566
+ File.file? File.join(cruby_stdlib_dir, "#{name.tr '-', '/'}.rb")
567
+ end
568
+
569
+ # The directory of the standard library of CRuby, lib/ruby/<version>.
570
+ #
571
+ def cruby_stdlib_dir()
572
+ @cruby_stdlib_dir ||= Dir.glob(File.join cruby_dir, 'CRuby', 'lib', 'ruby', '*')
573
+ .find {File.basename(_1).match?(/\A\d+\.\d+\.\d+\z/)} ||
574
+ raise(Error, "no standard library in the CRuby of '#{cruby_dir}'")
575
+ end
576
+
577
+ def rake_build_info(root)
578
+ out, err, status = Open3.capture3 RbConfig.ruby, '-e', RAKE_BUILD_INFO, chdir: root
579
+ raise Error, "failed to read the Rakefile in '#{root}': #{err.strip}" unless
580
+ status.success?
581
+ # the last line, as the Rakefile may print something when loaded
582
+ JSON.parse(out.lines.last.to_s, symbolize_names: true).to_h do |key, value|
583
+ [key, key == :defs ? value : value.map {File.expand_path _1, root}]
584
+ end
585
+ end
586
+
587
+ def vendor?(dir)
588
+ dir.include? '/vendor/'
589
+ end
590
+
591
+ def version?(str)
592
+ str.match?(/\A\d+(\.\d+)*\z/)
593
+ end
594
+
595
+ def check_cruby(path)
596
+ unless File.directory? File.join(path, 'CRuby', 'include')
597
+ raise Error,
598
+ "'#{path}' has no CRuby binary, " +
599
+ "run: cd #{path} && rake download_or_build os=macos"
600
+ end
601
+ path
602
+ end
603
+
604
+ def fetch_cruby(version)
605
+ dir = File.join platform_dir, 'cruby', version
606
+ return dir if File.directory? File.join(dir, 'CRuby', 'include')
607
+
608
+ FileUtils.rm_rf dir
609
+ FileUtils.mkdir_p File.dirname(dir)
610
+ run 'git', 'clone', '-q', '-c', 'advice.detachedHead=false',
611
+ '--depth', '1', '--branch', "v#{version}", CRUBY_GIT, dir,
612
+ chdir: File.dirname(dir)
613
+ # cruby has a Gemfile of its own, the one of the app must not be used
614
+ bundler = ENV.keys.grep(/\ABUNDLER?_/).to_h {[_1, nil]}
615
+ # the ones before 4.0.701 have no task to download only
616
+ download_only = Gem::Version.new(version) >= Gem::Version.new('4.0.701')
617
+ run 'rake', download_only ? 'download' : 'download_or_build',
618
+ chdir: dir, env: {**bundler, 'RUBYOPT' => nil, 'os' => 'macos'}
619
+ check_cruby dir
620
+ end
621
+
622
+ # Writes the bundles CRuby adds to the load path: <name>.bundle with
623
+ # lib/ in its resources, as the resource bundles of a pod are.
624
+ #
625
+ def copy_bundles()
626
+ dir = File.join build_dir, 'Bundles'
126
627
  FileUtils.rm_rf dir
127
- FileUtils.mkdir_p dir
128
- config.app_files.each do |file|
129
- dest = File.join dir, file
130
- FileUtils.mkdir_p File.dirname(dest)
131
- FileUtils.cp_r File.join(config.dir, file), dest
628
+ libraries.each {copy_library _1.root, bundle_resources(dir, _1.name)}
629
+ copy_gems {File.join bundle_resources(dir, _1), 'lib'}
630
+ res = bundle_resources dir, 'CRuby'
631
+ FileUtils.mkdir_p res
632
+ FileUtils.cp_r File.join(cruby_dir, 'CRuby', 'lib'), res
633
+ end
634
+
635
+ # InfoPlist.strings of each language in its .lproj, which xcodegen
636
+ # finds in src/, unless the app is in English only.
637
+ #
638
+ def write_localizations()
639
+ FileUtils.rm_rf Dir.glob(File.join build_dir, 'src', '*.lproj')
640
+ return if config.localizations.size <= 1
641
+ config.localizations.each do |lang, values|
642
+ write "src/#{lang}.lproj/InfoPlist.strings", render('InfoPlist.strings.erb', **values)
132
643
  end
133
- File.write File.join(dir, profile.boot_main), profile.boot if
134
- profile.boot_main && profile.boot
644
+ end
645
+
646
+ # The lib directory of the library +name+ in the package.
647
+ #
648
+ def library_lib_dir(name)
649
+ File.join bundle_resources(File.join(build_dir, 'Bundles'), name), 'lib'
650
+ end
651
+
652
+ def bundle_resources(dir, name)
653
+ File.join dir, "#{name}.bundle", 'Contents', 'Resources'
135
654
  end
136
655
 
137
656
  def generate_icon()
@@ -139,48 +658,27 @@ module Reflex
139
658
  FileUtils.rm_rf iconset
140
659
  FileUtils.mkdir_p iconset
141
660
  icon_commands(File.join(config.dir, config.icon), iconset)
142
- .each {|cmd| run(*cmd, chdir: build_dir)}
661
+ .each {|cmd| run(*cmd, chdir: build_dir, quiet: true)}
143
662
  run 'iconutil', '-c', 'icns', 'AppIcon.iconset', '-o', 'AppIcon.icns',
144
663
  chdir: build_dir
145
664
  end
146
665
 
147
- # Development pods skip prepare_command on pod install, so ensure
148
- # that the manual setups have been done.
149
- #
150
- def check_dev_pods()
151
- dev_pod_paths.each do |name, path|
152
- raise Error, "pod directory not found: '#{path}'" unless File.directory? path
153
- if name == 'CRuby'
154
- unless File.directory? File.join(path, 'CRuby', 'include')
155
- raise Error,
156
- "'#{path}' has no CRuby binary, " +
157
- "run: cd #{path} && rake download_or_build os=macos"
158
- end
159
- else # the umbrella pod (Reflex / RubySketch / ...)
160
- unless File.directory? File.join(path, 'xot')
161
- raise Error,
162
- "'#{path}' is not set up for CocoaPods, " +
163
- "run: cd #{path} && rake -f pod.rake setup"
164
- end
165
- end
166
- end
167
- end
168
-
169
- def copy_app()
170
- app = File.join build_dir, 'DerivedData', 'Build', 'Products', 'Release', "#{target}.app"
666
+ def copy_app(app = built_app_path)
171
667
  raise Error, "application not found: '#{app}'" unless File.directory? app
172
668
 
173
- dist = File.join dist_dir, "#{target}.app"
669
+ dist = dist_path
174
670
  FileUtils.rm_rf dist
175
671
  FileUtils.mkdir_p dist_dir
176
672
  FileUtils.cp_r app, dist
177
673
  puts "Created #{dist}"
178
674
  end
179
675
 
180
- def write(path, content)
181
- path = File.join build_dir, path
182
- FileUtils.mkdir_p File.dirname(path)
183
- File.write path, content
676
+ def copy_pkg(pkg, dist)
677
+ raise Error, "installer package not found: '#{pkg}'" unless File.file? pkg
678
+
679
+ FileUtils.mkdir_p File.dirname(dist)
680
+ FileUtils.cp pkg, dist
681
+ puts "Created #{dist}"
184
682
  end
185
683
 
186
684
  end# MacOS