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.
- checksums.yaml +4 -4
- data/.github/workflows/package.yml +310 -0
- data/.github/workflows/release-gem.yml +2 -49
- data/.github/workflows/tag.yml +11 -28
- data/.github/workflows/test.yml +16 -37
- data/.gitignore +2 -0
- data/ChangeLog.md +28 -0
- data/Gemfile.lock +26 -0
- data/README.md +232 -27
- data/Rakefile +21 -0
- data/VERSION +1 -1
- data/bin/reflex +2 -32
- data/examples/hello/icon.png +0 -0
- data/examples/hello/main.rb +18 -0
- data/examples/hello/reflex.yml +3 -0
- data/lib/reflex/packager/app_store_connect.rb +162 -0
- data/lib/reflex/packager/cli.rb +42 -9
- data/lib/reflex/packager/config.rb +233 -36
- data/lib/reflex/packager/data_compiler.rb +59 -0
- data/lib/reflex/packager/data_file.rb +183 -0
- data/lib/reflex/packager/data_loader.rb +127 -0
- data/lib/reflex/packager/gems.rb +282 -0
- data/lib/reflex/packager/library.rb +120 -0
- data/lib/reflex/packager/macos.rb +592 -94
- data/lib/reflex/packager/platform.rb +289 -10
- data/lib/reflex/packager/profile.rb +38 -26
- data/lib/reflex/packager/profiles/reflex.rb +43 -0
- data/lib/reflex/packager/rake.rb +115 -0
- data/lib/reflex/packager/stores/app_store.rb +60 -0
- data/lib/reflex/packager/stores/itchio.rb +53 -0
- data/lib/reflex/packager/templates/macos/AppStore.entitlements.erb +8 -0
- data/lib/reflex/packager/templates/macos/AppStoreExportOptions.plist.erb +14 -0
- data/lib/reflex/packager/templates/macos/ExportOptions.plist.erb +20 -0
- data/lib/reflex/packager/templates/macos/InfoPlist.strings.erb +5 -0
- data/lib/reflex/packager/templates/macos/boot.rb.erb +29 -0
- data/lib/reflex/packager/templates/macos/compile.rb.erb +7 -0
- data/lib/reflex/packager/templates/macos/main.mm.erb +10 -7
- data/lib/reflex/packager/templates/macos/project.yml.erb +60 -3
- data/lib/reflex/packager/templates/windows/app.manifest.erb +25 -0
- data/lib/reflex/packager/templates/windows/app.rc.erb +38 -0
- data/lib/reflex/packager/templates/windows/boot.rb.erb +32 -0
- data/lib/reflex/packager/templates/windows/main.cpp.erb +47 -0
- data/lib/reflex/packager/templates/windows/runtime.manifest.erb +7 -0
- data/lib/reflex/packager/windows.rb +370 -0
- data/lib/reflex/packager.rb +18 -1
- data/reflex-packager.gemspec +8 -7
- data/test/helper.rb +50 -5
- data/test/test_packager_app_store_connect.rb +108 -0
- data/test/test_packager_cli.rb +38 -3
- data/test/test_packager_config.rb +166 -27
- data/test/test_packager_data_file.rb +96 -0
- data/test/test_packager_data_loader.rb +114 -0
- data/test/test_packager_library.rb +81 -0
- data/test/test_packager_macos.rb +714 -86
- data/test/test_packager_platform.rb +16 -0
- data/test/test_packager_rake.rb +165 -0
- data/test/test_packager_stores_app_store.rb +66 -0
- data/test/test_packager_stores_itchio.rb +38 -0
- data/test/test_packager_windows.rb +685 -0
- metadata +72 -15
- data/.github/workflows/utils.rb +0 -127
- data/lib/reflex/packager/templates/macos/Podfile.erb +0 -30
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
require 'rbconfig'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
require 'reflex/packager/platform'
|
|
4
|
+
require 'reflex/packager/gems'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
module Reflex
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
module Packager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Packages a Reflex application as a Windows executable with the Ruby
|
|
14
|
+
# runtime it runs on next to it.
|
|
15
|
+
#
|
|
16
|
+
# The executable links the Ruby DLL of RubyInstaller, and the native
|
|
17
|
+
# libraries (xot, rucy, rays, ...) statically from the archives and the
|
|
18
|
+
# ext objects a gem build leaves in the installed gems. Each extension is
|
|
19
|
+
# registered with ruby_init_ext, so requiring it initializes the copy
|
|
20
|
+
# linked in, as --with-static-linked-ext does.
|
|
21
|
+
#
|
|
22
|
+
class Windows < Platform
|
|
23
|
+
|
|
24
|
+
include Gems
|
|
25
|
+
|
|
26
|
+
TOOLCHAIN_HINT = 'install RubyInstaller with the MSYS2 DevKit (ridk install)'
|
|
27
|
+
|
|
28
|
+
# The dlls the executable loads go in this directory, which is a
|
|
29
|
+
# private assembly of the same name that the manifest embedded in the
|
|
30
|
+
# executable depends on. Having the ruby dll in lib/ (or bin/) also
|
|
31
|
+
# makes the parent directory the prefix, where Ruby looks for lib/ruby.
|
|
32
|
+
#
|
|
33
|
+
RUNTIME_DIR = 'lib'
|
|
34
|
+
RUNTIME_VERSION = '1.0.0.0'
|
|
35
|
+
|
|
36
|
+
# Sizes of the icon of the executable, one image each in the ICO.
|
|
37
|
+
#
|
|
38
|
+
ICON_SIZES = [16, 32, 48, 256]
|
|
39
|
+
|
|
40
|
+
def package(generate_only: false)
|
|
41
|
+
raise Error, 'notarizing is only for macos' if notarize?
|
|
42
|
+
raise Error, 'the Mac App Store is only for macos' if appstore?
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def generate()
|
|
47
|
+
copy_libraries
|
|
48
|
+
copy_app_files 'lib/app'
|
|
49
|
+
write 'src/main.cpp', render('main.cpp.erb')
|
|
50
|
+
write 'src/app.manifest', render('app.manifest.erb')
|
|
51
|
+
write 'src/app.rc', render('app.rc.erb')
|
|
52
|
+
write 'lib/boot.rb', render('boot.rb.erb')
|
|
53
|
+
generate_icon if config.icon
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def generate_icon()
|
|
57
|
+
File.binwrite File.join(build_dir, 'src', 'app.ico'), Windows.ico(icon_pngs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# [[size, png], ...] of the icon drawn at each size with Rays.
|
|
61
|
+
#
|
|
62
|
+
def icon_pngs()
|
|
63
|
+
require 'rays'
|
|
64
|
+
icon = Rays::Image.load File.join(config.dir, config.icon), smooth: true
|
|
65
|
+
ICON_SIZES.map do |size|
|
|
66
|
+
path = File.join build_dir, 'src', "icon_#{size}.png"
|
|
67
|
+
Rays::Image.new(size, size).paint {|p| p.image icon, 0, 0, size, size}.save path
|
|
68
|
+
[size, File.binread(path)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An ICO of the PNGs: ICONDIR, an ICONDIRENTRY per image, then the
|
|
73
|
+
# images. Windows takes PNG images in an ICO since Vista, and a size of
|
|
74
|
+
# 256 is written as 0.
|
|
75
|
+
#
|
|
76
|
+
def self.ico(pngs)
|
|
77
|
+
offset = 6 + 16 * pngs.size
|
|
78
|
+
entries = pngs.map do |size, png|
|
|
79
|
+
entry = [size % 256, size % 256, 0, 0, 1, 32, png.bytesize, offset].pack 'C4v2V2'
|
|
80
|
+
offset += png.bytesize
|
|
81
|
+
entry
|
|
82
|
+
end
|
|
83
|
+
[[0, 1, pngs.size].pack('v3'), *entries, *pngs.map {_1.last.b}].join
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build()
|
|
87
|
+
enable_toolchain
|
|
88
|
+
check_tools tools
|
|
89
|
+
# the strings of the app name are in utf-8
|
|
90
|
+
run 'windres', '--codepage=65001', 'app.rc', '-o', 'app.res.o',
|
|
91
|
+
chdir: File.join(build_dir, 'src')
|
|
92
|
+
run(*link_command, chdir: build_dir)
|
|
93
|
+
copy_dist
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The command to zip the packaged app into archive_path, with its
|
|
97
|
+
# folder, by the tar of Windows, which writes a zip by the extension.
|
|
98
|
+
# The tar of MSYS2 or git, which may come first on PATH, cannot.
|
|
99
|
+
#
|
|
100
|
+
def archive_command()
|
|
101
|
+
tar = File.join ENV['SystemRoot'] || 'C:/Windows', 'System32', 'tar.exe'
|
|
102
|
+
[tar, '-a', '-c', '-f', archive_path, '-C', dist_dir, File.basename(dist_path)]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The architecture is the one of the ruby the executable is made with.
|
|
106
|
+
#
|
|
107
|
+
def archive_platform(rbconfig = RbConfig::CONFIG)
|
|
108
|
+
"windows-#{rbconfig['host_cpu'].match?(/arm|aarch64/) ? 'arm64' : 'x64'}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def tools()
|
|
112
|
+
{compiler.first => TOOLCHAIN_HINT, 'windres' => TOOLCHAIN_HINT, 'objdump' => TOOLCHAIN_HINT}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Directories under lib/ boot.rb puts on the load path, in the order of
|
|
116
|
+
# lib_names: the libraries, and the gems under gems/, apart from what
|
|
117
|
+
# else lib/ has, as ruby/ and app/.
|
|
118
|
+
#
|
|
119
|
+
def load_dirs()
|
|
120
|
+
gems = gem_names
|
|
121
|
+
lib_names.map {gems.include?(_1) ? File.join('gems', _1) : _1}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Libraries built from native code: the ones whose gem has the static
|
|
125
|
+
# archive the extension was linked from.
|
|
126
|
+
#
|
|
127
|
+
def native_libraries()
|
|
128
|
+
libraries.select {File.file? static_archive(_1)}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def ext_objects()
|
|
132
|
+
libraries.select(&:extension).flat_map do |lib|
|
|
133
|
+
objs = Dir.glob(File.join lib.root, 'ext', lib.name, '*.o').sort
|
|
134
|
+
raise Error, "no objects of '#{lib.extension}' in '#{lib.root}' (was the gem built?)" if
|
|
135
|
+
objs.empty?
|
|
136
|
+
objs
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Static archives of the native libraries, the ones depending on the
|
|
141
|
+
# others first.
|
|
142
|
+
#
|
|
143
|
+
def static_archives()
|
|
144
|
+
native_libraries.reverse.map {static_archive _1}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# System libraries the extensions link, as the Makefiles their gem builds
|
|
148
|
+
# leave have them.
|
|
149
|
+
#
|
|
150
|
+
def system_libs()
|
|
151
|
+
native_libraries.flat_map do |lib|
|
|
152
|
+
makefile = File.join lib.root, 'ext', lib.name, 'Makefile'
|
|
153
|
+
File.file?(makefile) ? Windows.makefile_libs(File.read makefile) : []
|
|
154
|
+
end.uniq
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The names in the -l options of the LIBS of a Makefile mkmf writes.
|
|
158
|
+
#
|
|
159
|
+
def self.makefile_libs(makefile)
|
|
160
|
+
line = makefile.lines.find {_1.start_with? 'LIBS ='} or return []
|
|
161
|
+
line.split.filter_map {_1[/\A-l(.+)\z/, 1]}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Paths of the DLLs of the toolchain the executable loads, directly or
|
|
165
|
+
# through another one: the import tables are followed from the
|
|
166
|
+
# executable as far as the DLLs beside the compiler go. The ones of
|
|
167
|
+
# Windows and the ruby dll end it, and any other is not to be found.
|
|
168
|
+
#
|
|
169
|
+
def toolchain_dlls(rbconfig = RbConfig::CONFIG)
|
|
170
|
+
@toolchain_dlls ||= begin
|
|
171
|
+
dir = toolchain_dir(rbconfig) or
|
|
172
|
+
raise Error, "compiler not found: #{TOOLCHAIN_HINT}"
|
|
173
|
+
found = {}
|
|
174
|
+
exe = File.join build_dir, "#{target}.exe"
|
|
175
|
+
queue = dll_imports(exe).map {[_1, exe]}
|
|
176
|
+
until queue.empty?
|
|
177
|
+
dll, by = queue.shift
|
|
178
|
+
next if found.key? dll.downcase
|
|
179
|
+
path = File.join dir, dll
|
|
180
|
+
if File.file? path
|
|
181
|
+
found[dll.downcase] = path
|
|
182
|
+
queue.concat dll_imports(path).map {[_1, path]}
|
|
183
|
+
else
|
|
184
|
+
raise Error, "'#{dll}' needed by #{File.basename by} not found" unless
|
|
185
|
+
system_dll? dll, rbconfig
|
|
186
|
+
found[dll.downcase] = nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
found.values.compact
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# DLLs the executable loads from lib/, listed in its manifest.
|
|
194
|
+
#
|
|
195
|
+
def runtime_dlls(rbconfig = RbConfig::CONFIG)
|
|
196
|
+
[rbconfig['LIBRUBY_SO'], *toolchain_dlls(rbconfig).map {File.basename _1}]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The versions of the product and of the build, as the four numbers of
|
|
200
|
+
# the version resource and the assembly version.
|
|
201
|
+
#
|
|
202
|
+
def product_version()
|
|
203
|
+
Windows.four_numbers config.display_version
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def file_version()
|
|
207
|
+
Windows.four_numbers config.build_version
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The first four numbers of +version+, filled up to four, which have to
|
|
211
|
+
# be in 0..65535.
|
|
212
|
+
#
|
|
213
|
+
def self.four_numbers(version)
|
|
214
|
+
numbers = version.split('.').first(4).map(&:to_i)
|
|
215
|
+
raise Error, "a number in the version '#{version}' is over 65535" if
|
|
216
|
+
numbers.any? {_1 > 65535}
|
|
217
|
+
(numbers + [0] * 4).first(4).join '.'
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The language id of a block of the version resource, for +lang+ of the
|
|
221
|
+
# localizations, as Windows has it.
|
|
222
|
+
#
|
|
223
|
+
def langid(lang)
|
|
224
|
+
return 0x0409 if lang == 'en' # U.S. English, the one of the values out of them
|
|
225
|
+
id = Windows.lcid(lang) & 0xffff
|
|
226
|
+
raise Error, "unknown language on windows: '#{lang}'" if
|
|
227
|
+
id == 0 || id == LOCALE_CUSTOM_UNSPECIFIED
|
|
228
|
+
id
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
LOCALE_CUSTOM_UNSPECIFIED = 0x1000
|
|
232
|
+
|
|
233
|
+
# The locale id Windows has for the locale +name+, or 0.
|
|
234
|
+
#
|
|
235
|
+
def self.lcid(name)
|
|
236
|
+
require 'fiddle'
|
|
237
|
+
@locale_name_to_lcid ||= Fiddle::Function.new(
|
|
238
|
+
Fiddle.dlopen('kernel32')['LocaleNameToLCID'],
|
|
239
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
240
|
+
@locale_name_to_lcid.call "#{name}\0".encode('UTF-16LE'), 0
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# +str+ as a string literal of a resource script, which doubles a quote
|
|
244
|
+
# rather than escaping it.
|
|
245
|
+
#
|
|
246
|
+
def rc_string(str)
|
|
247
|
+
%("#{str.gsub('\\', '\\\\\\\\').gsub('"', '""')}")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def compiler(rbconfig = RbConfig::CONFIG)
|
|
251
|
+
# may carry flags, e.g. 'g++ -std=gnu++11'
|
|
252
|
+
rbconfig['CXX'].shellsplit
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Compiles src/main.cpp and links it into the executable, run in the
|
|
256
|
+
# build directory.
|
|
257
|
+
#
|
|
258
|
+
def link_command(rbconfig = RbConfig::CONFIG)
|
|
259
|
+
[
|
|
260
|
+
*compiler(rbconfig), 'src/main.cpp', 'src/app.res.o', '-o', "#{target}.exe",
|
|
261
|
+
"-I#{rbconfig['rubyhdrdir']}", "-I#{rbconfig['rubyarchhdrdir']}",
|
|
262
|
+
*ext_objects,
|
|
263
|
+
'-Wl,--whole-archive', *static_archives, '-Wl,--no-whole-archive',
|
|
264
|
+
"-L#{rbconfig['libdir']}", *rbconfig['LIBRUBYARG_SHARED'].shellsplit,
|
|
265
|
+
*system_libs.map {"-l#{_1}"},
|
|
266
|
+
*('-mwindows' unless config.windows.console?)
|
|
267
|
+
]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Copies what the executable needs from the Ruby it was built with:
|
|
271
|
+
# the dlls into lib/, the standard library into lib/ruby.
|
|
272
|
+
#
|
|
273
|
+
def copy_runtime(dest, rbconfig = RbConfig::CONFIG)
|
|
274
|
+
runtime = File.join dest, RUNTIME_DIR
|
|
275
|
+
bindir = rbconfig['bindir']
|
|
276
|
+
FileUtils.mkdir_p runtime
|
|
277
|
+
FileUtils.cp File.join(bindir, rbconfig['LIBRUBY_SO']), runtime
|
|
278
|
+
|
|
279
|
+
# the ruby dll finds these through the manifest in the directory, so
|
|
280
|
+
# the directory has to come along as it is
|
|
281
|
+
builtin = File.join bindir, 'ruby_builtin_dlls'
|
|
282
|
+
FileUtils.cp_r builtin, runtime if File.directory? builtin
|
|
283
|
+
|
|
284
|
+
toolchain_dlls(rbconfig).each {FileUtils.cp _1, runtime}
|
|
285
|
+
File.write File.join(runtime, "#{RUNTIME_DIR}.manifest"),
|
|
286
|
+
render('runtime.manifest.erb', dlls: runtime_dlls(rbconfig))
|
|
287
|
+
|
|
288
|
+
stdlib = File.join dest, 'lib', 'ruby'
|
|
289
|
+
FileUtils.mkdir_p stdlib
|
|
290
|
+
FileUtils.cp_r File.join(rbconfig['rubylibprefix'], rbconfig['ruby_version']), stdlib
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def platform_name()
|
|
296
|
+
'windows'
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The ruby dll the executable links is the one the gems are built for.
|
|
300
|
+
#
|
|
301
|
+
def native_gems?()
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def static_archive(lib)
|
|
306
|
+
File.join lib.root, 'lib', "lib#{lib.name}.a"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The lib directory of the library +name+ in the package.
|
|
310
|
+
#
|
|
311
|
+
def library_lib_dir(name)
|
|
312
|
+
File.join build_dir, 'lib', name, 'lib'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def copy_libraries()
|
|
316
|
+
dir = File.join build_dir, 'lib'
|
|
317
|
+
FileUtils.rm_rf dir
|
|
318
|
+
libraries.each {copy_library _1.root, File.join(dir, _1.name)}
|
|
319
|
+
copy_gems {File.join dir, 'gems', _1, 'lib'}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def copy_dist()
|
|
323
|
+
dist = dist_path
|
|
324
|
+
FileUtils.rm_rf dist
|
|
325
|
+
FileUtils.mkdir_p dist
|
|
326
|
+
%W[#{target}.exe lib].each do |path|
|
|
327
|
+
FileUtils.cp_r File.join(build_dir, path), dist
|
|
328
|
+
end
|
|
329
|
+
copy_runtime dist
|
|
330
|
+
puts "Created #{dist}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Puts the MSYS2 toolchain of RubyInstaller on the PATH as gem install
|
|
334
|
+
# does, so that packaging works from any shell without 'ridk enable'.
|
|
335
|
+
#
|
|
336
|
+
def enable_toolchain()
|
|
337
|
+
require 'ruby_installer/runtime'
|
|
338
|
+
RubyInstaller::Runtime.enable_msys_apps
|
|
339
|
+
rescue LoadError
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# The directory of the compiler, where MSYS2 keeps the DLLs of the
|
|
343
|
+
# toolchain and its packages, rather than PATH, where another toolchain,
|
|
344
|
+
# as the one of git, may come first with its own.
|
|
345
|
+
#
|
|
346
|
+
def toolchain_dir(rbconfig = RbConfig::CONFIG)
|
|
347
|
+
cxx = rbconfig['CXX']&.shellsplit&.first
|
|
348
|
+
compiler = cxx && (File.absolute_path?(cxx) ? cxx : find_executable(cxx))
|
|
349
|
+
compiler && File.dirname(compiler)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def system_dll?(dll, rbconfig)
|
|
353
|
+
dll.casecmp?(rbconfig['LIBRUBY_SO']) ||
|
|
354
|
+
dll.match?(/\A(api|ext)-ms-/i) ||# api sets, which windows resolves
|
|
355
|
+
File.file?(File.join ENV['SystemRoot'].to_s, 'System32', dll)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def dll_imports(path)
|
|
359
|
+
out, status = Open3.capture2 'objdump', '-p', path
|
|
360
|
+
raise Error, "objdump failed: #{path}" unless status.success?
|
|
361
|
+
out.scan(/DLL Name: (\S+)/).flatten
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
end# Windows
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
end# Packager
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
end# Reflex
|
data/lib/reflex/packager.rb
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
require 'reflex/packager/extension'
|
|
2
2
|
|
|
3
|
+
require 'reflex/packager/data_file'
|
|
4
|
+
require 'reflex/packager/data_compiler'
|
|
5
|
+
require 'reflex/packager/data_loader'
|
|
6
|
+
|
|
3
7
|
require 'reflex/packager/profile'
|
|
4
8
|
require 'reflex/packager/config'
|
|
5
9
|
require 'reflex/packager/platform'
|
|
10
|
+
require 'reflex/packager/gems'
|
|
11
|
+
require 'reflex/packager/app_store_connect'
|
|
6
12
|
require 'reflex/packager/macos'
|
|
13
|
+
require 'reflex/packager/windows'
|
|
14
|
+
require 'reflex/packager/stores/app_store'
|
|
15
|
+
require 'reflex/packager/stores/itchio'
|
|
7
16
|
require 'reflex/packager/cli'
|
|
8
17
|
|
|
9
18
|
|
|
10
19
|
module Reflex::Packager
|
|
11
|
-
|
|
20
|
+
|
|
21
|
+
PLATFORMS = {macos: MacOS, windows: Windows}
|
|
22
|
+
|
|
23
|
+
# The platform the packager runs on, the only one it can build for.
|
|
24
|
+
#
|
|
25
|
+
def self.default_platform()
|
|
26
|
+
Gem.win_platform? ? :windows : :macos
|
|
27
|
+
end
|
|
28
|
+
|
|
12
29
|
end
|
data/reflex-packager.gemspec
CHANGED
|
@@ -11,13 +11,13 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
|
|
12
12
|
ext = Reflex::Packager::Extension
|
|
13
13
|
name = ext.name true
|
|
14
|
-
rdocs = glob.call
|
|
14
|
+
rdocs = glob.call(*%w[README])
|
|
15
15
|
|
|
16
16
|
s.name = name
|
|
17
17
|
s.version = ext.version
|
|
18
18
|
s.license = 'MIT'
|
|
19
|
-
s.summary = 'Package Reflex applications as native
|
|
20
|
-
s.description = 'CLI tool to package Reflex applications as native macOS
|
|
19
|
+
s.summary = 'Package Reflex applications as native apps.'
|
|
20
|
+
s.description = 'CLI tool to package Reflex applications as native macOS and Windows applications.'
|
|
21
21
|
s.authors = %w[xordog]
|
|
22
22
|
s.email = 'xordog@gmail.com'
|
|
23
23
|
s.homepage = "https://github.com/xord/reflex-packager"
|
|
@@ -25,10 +25,11 @@ Gem::Specification.new do |s|
|
|
|
25
25
|
s.platform = Gem::Platform::RUBY
|
|
26
26
|
s.required_ruby_version = '>= 3.0.0'
|
|
27
27
|
|
|
28
|
-
s.add_dependency '
|
|
29
|
-
s.add_dependency '
|
|
30
|
-
s.add_dependency '
|
|
31
|
-
s.add_dependency '
|
|
28
|
+
s.add_dependency 'fiddle'
|
|
29
|
+
s.add_dependency 'xot', '~> 0.5.0'
|
|
30
|
+
s.add_dependency 'rucy', '~> 0.5.0'
|
|
31
|
+
s.add_dependency 'rays', '~> 0.5.0'
|
|
32
|
+
s.add_dependency 'reflexion', '~> 0.7.0'
|
|
32
33
|
|
|
33
34
|
s.files = `git ls-files`.split $/
|
|
34
35
|
s.executables = s.files.grep(%r{^bin/}) {|f| File.basename f}
|
data/test/helper.rb
CHANGED
|
@@ -7,17 +7,53 @@ require 'reflex/extension'
|
|
|
7
7
|
require 'reflex/packager'
|
|
8
8
|
|
|
9
9
|
require 'test/unit'
|
|
10
|
+
require 'tempfile'
|
|
10
11
|
require 'tmpdir'
|
|
11
12
|
|
|
12
13
|
include Xot::Test
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
# Makes +dir+ a checkout of the cruby repository with what packaging for macOS
|
|
17
|
+
# reads of it, so that the tests do not fetch the real one.
|
|
18
|
+
#
|
|
19
|
+
def fake_cruby(dir)
|
|
20
|
+
%w[CRuby/include/ruby.h CRuby/lib/ruby/4.0.0/set.rb src/CRuby.m].each do |path|
|
|
21
|
+
path = File.join dir, path
|
|
22
|
+
FileUtils.mkdir_p File.dirname(path)
|
|
23
|
+
FileUtils.touch path
|
|
24
|
+
end
|
|
25
|
+
dir
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Runs the block with the method +name+ of +obj+ returning +value+.
|
|
29
|
+
#
|
|
30
|
+
def stub(obj, name, value, &block)
|
|
31
|
+
# removed before defined again, which warns on overwriting
|
|
32
|
+
klass = obj.singleton_class
|
|
33
|
+
hidden = klass.private_method_defined? name, false
|
|
34
|
+
saved = klass.instance_method name if hidden || klass.method_defined?(name, false)
|
|
35
|
+
klass.remove_method name if saved
|
|
36
|
+
klass.define_method(name) {|*| value}
|
|
37
|
+
block.call
|
|
38
|
+
ensure
|
|
39
|
+
klass.remove_method name
|
|
40
|
+
klass.define_method name, saved if saved
|
|
41
|
+
klass.send :private, name if hidden
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs the block with the environment variables of +env+, nil to unset.
|
|
45
|
+
#
|
|
46
|
+
def with_env(env, &block)
|
|
47
|
+
saved = env.map {|key, _| [key, ENV[key]]}
|
|
48
|
+
env.each {|key, value| value ? ENV[key] = value : ENV.delete(key)}
|
|
49
|
+
block.call
|
|
50
|
+
ensure
|
|
51
|
+
saved.each {|key, value| value ? ENV[key] = value : ENV.delete(key)}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
|
|
15
55
|
TEST_PROFILE = Reflex::Packager::Profile.new(
|
|
16
|
-
|
|
17
|
-
git: 'https://github.com/xord/reflex',
|
|
18
|
-
version: Reflex::Extension.version,
|
|
19
|
-
libraries: %w[Xot Rucy Rays Reflex],
|
|
20
|
-
extensions: %w[rays_ext reflex_ext],
|
|
56
|
+
extension: Reflex::Extension,
|
|
21
57
|
config_files: %w[reflex.yml reflex.yaml],
|
|
22
58
|
templates: {'main.rb': <<~MAIN, 'reflex.yml': <<~CONFIG})
|
|
23
59
|
require 'reflex'
|
|
@@ -28,3 +64,12 @@ TEST_PROFILE = Reflex::Packager::Profile.new(
|
|
|
28
64
|
MAIN
|
|
29
65
|
name: myapp
|
|
30
66
|
CONFIG
|
|
67
|
+
|
|
68
|
+
# The environment variables to sign with, to unset by with_env for the tests
|
|
69
|
+
# not to take the ones of the developer.
|
|
70
|
+
#
|
|
71
|
+
ENV_TO_SIGN = [
|
|
72
|
+
*Reflex::Packager::MacOSConfig::CODESIGN_ENV_KEYS.values,
|
|
73
|
+
*Reflex::Packager::MacOS::CODESIGN_P12_ENV_KEYS.values,
|
|
74
|
+
*Reflex::Packager::AppStoreConnect::AUTH_ENV_KEYS.values
|
|
75
|
+
].to_h {[_1, nil]}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
require_relative 'helper'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestPackagerAppStoreConnect < Test::Unit::TestCase
|
|
5
|
+
|
|
6
|
+
RP = Reflex::Packager
|
|
7
|
+
ASC = RP::AppStoreConnect
|
|
8
|
+
|
|
9
|
+
# App Store Connect with a key of its own, answering the requests to the
|
|
10
|
+
# paths under the notary service by +answers+.
|
|
11
|
+
def asc(answers = {}, &block)
|
|
12
|
+
Dir.mktmpdir do |dir|
|
|
13
|
+
path = File.join dir, 'AuthKey.p8'
|
|
14
|
+
File.write path, OpenSSL::PKey::EC.generate('prime256v1').to_pem
|
|
15
|
+
asc = ASC.new issuer_id: 'ISSUER', key_id: 'KEY', key_path: path
|
|
16
|
+
asc.define_singleton_method(:get) do |path|
|
|
17
|
+
answers.fetch(path.delete_prefix '/notary/v2/').then {_1.is_a?(Proc) ? _1.call : _1}
|
|
18
|
+
end
|
|
19
|
+
asc.define_singleton_method(:sleep) {|*|}
|
|
20
|
+
asc.define_singleton_method(:puts) {|*|}
|
|
21
|
+
block.call asc, path
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def status(str)
|
|
26
|
+
{'data' => {'attributes' => {'status' => str}}}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_credentials()
|
|
30
|
+
keys = ASC::AUTH_ENV_KEYS
|
|
31
|
+
with_env ENV_TO_SIGN do
|
|
32
|
+
assert_nil ASC.credentials
|
|
33
|
+
assert_raise(RP::Error) {ASC.new}
|
|
34
|
+
end
|
|
35
|
+
creds = {issuer_id: 'ISSUER', key_id: 'KEY', key_path: '/a.p8'}
|
|
36
|
+
with_env ENV_TO_SIGN.merge(creds.to_h {[keys[_1], _2]}) do
|
|
37
|
+
assert_equal creds, ASC.credentials
|
|
38
|
+
end
|
|
39
|
+
with_env ENV_TO_SIGN.merge(keys[:key_id] => '', keys[:key_path] => '/a.p8') do
|
|
40
|
+
error = assert_raise(RP::Error) {ASC.credentials}
|
|
41
|
+
assert_include error.message, keys[:key_id]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# by the content of the .p8 file, as a secret of CI
|
|
45
|
+
creds = {issuer_id: 'ISSUER', key_id: 'KEY', key_p8: 'P8'}
|
|
46
|
+
with_env ENV_TO_SIGN.merge(creds.to_h {[keys[_1], _2]}) do
|
|
47
|
+
assert_equal creds, ASC.credentials
|
|
48
|
+
end
|
|
49
|
+
with_env ENV_TO_SIGN.merge(keys[:issuer_id] => 'ISSUER', keys[:key_id] => 'KEY') do
|
|
50
|
+
error = assert_raise(RP::Error) {ASC.credentials}
|
|
51
|
+
assert_include error.message, "#{keys[:key_p8]} or #{keys[:key_path]}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_token()
|
|
56
|
+
asc do |asc, path|
|
|
57
|
+
header, payload, signature = asc.__send__(:token).split('.')
|
|
58
|
+
decode = -> str {(str + '=' * (-str.size % 4)).tr('-_', '+/').unpack1 'm0'}
|
|
59
|
+
assert_equal 'KEY', JSON.parse(decode[header])['kid']
|
|
60
|
+
assert_equal 'ISSUER', JSON.parse(decode[payload])['iss']
|
|
61
|
+
|
|
62
|
+
# the raw r and s, which OpenSSL takes in DER
|
|
63
|
+
r, s = decode[signature].unpack('a32a32').map {OpenSSL::BN.new _1, 2}
|
|
64
|
+
der = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
|
|
65
|
+
assert OpenSSL::PKey.read(File.read path).verify('SHA256', der, "#{header}.#{payload}")
|
|
66
|
+
|
|
67
|
+
# by the content of the .p8 file
|
|
68
|
+
p8 = File.read path
|
|
69
|
+
header, payload, signature = ASC.new(issuer_id: 'ISSUER', key_id: 'KEY', key_p8: p8)
|
|
70
|
+
.__send__(:token).split('.')
|
|
71
|
+
r, s = decode[signature].unpack('a32a32').map {OpenSSL::BN.new _1, 2}
|
|
72
|
+
der = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
|
|
73
|
+
assert OpenSSL::PKey.read(p8).verify('SHA256', der, "#{header}.#{payload}")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_wait_until_notarized()
|
|
78
|
+
statuses = [status('In Progress'), status('Accepted')]
|
|
79
|
+
asc 'submissions/abc' => -> {statuses.shift} do |asc, _|
|
|
80
|
+
assert_nothing_raised {asc.wait_until_notarized 'ABC'} # in lower case
|
|
81
|
+
assert_empty statuses
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
asc 'submissions/abc' => status('In Progress') do |asc, _|
|
|
85
|
+
error = assert_raise(RP::Error) {asc.wait_until_notarized 'abc', timeout: -1}
|
|
86
|
+
assert_include error.message, 'timed out'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
asc 'submissions/abc' => {} do |asc, _|
|
|
90
|
+
error = assert_raise(RP::Error) {asc.wait_until_notarized 'abc'}
|
|
91
|
+
assert_include error.message, 'unknown status of the notarization: nil'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
log = {'issues' => [{'path' => 'a.app/b', 'message' => 'not signed'}]}
|
|
95
|
+
answers = {
|
|
96
|
+
'submissions/abc' => status('Invalid'),
|
|
97
|
+
'submissions/abc/logs' => {'data' => {'attributes' => {'developerLogUrl' => 'https://log'}}}
|
|
98
|
+
}
|
|
99
|
+
asc answers do |asc, _|
|
|
100
|
+
stub Net::HTTP, :get, log.to_json do
|
|
101
|
+
error = assert_raise(RP::Error) {asc.wait_until_notarized 'abc'}
|
|
102
|
+
assert_include error.message, 'Invalid'
|
|
103
|
+
assert_include error.message, 'a.app/b: not signed'
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
end# TestPackagerAppStoreConnect
|
data/test/test_packager_cli.rb
CHANGED
|
@@ -63,10 +63,27 @@ class TestPackagerCLI < Test::Unit::TestCase
|
|
|
63
63
|
def test_package()
|
|
64
64
|
tmpdir do
|
|
65
65
|
capture {cli.create ['myapp']}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
Dir.mktmpdir do |cruby|
|
|
67
|
+
saved, ENV['CRUBY_PATH'] = ENV['CRUBY_PATH'], fake_cruby(cruby)
|
|
68
|
+
cli.package ['--generate-only', '--platform', 'macos', 'myapp']
|
|
69
|
+
ensure
|
|
70
|
+
ENV['CRUBY_PATH'] = saved
|
|
69
71
|
end
|
|
72
|
+
%w[project.yml src/main.mm app/main.rb].each do |f|
|
|
73
|
+
assert File.exist?("myapp/.build/macos/project/#{f}"), "missing #{f}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
tmpdir do
|
|
78
|
+
capture {cli.create ['myapp']}
|
|
79
|
+
cli.package ['--generate-only', '--platform', 'windows', 'myapp']
|
|
80
|
+
%w[src/main.cpp src/app.manifest lib/boot.rb lib/app/main.rb lib/reflex/lib/reflex.rb].each do |f|
|
|
81
|
+
assert File.exist?("myapp/.build/windows/#{f}"), "missing #{f}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
cli.package ['--generate-only', '--platform', 'windows', '--pack', 'myapp']
|
|
85
|
+
assert_include Dir.children('myapp/.build/windows/lib/app'), 'data.bin'
|
|
86
|
+
assert_not_include Dir.children('myapp/.build/windows/lib/app'), 'main.rb'
|
|
70
87
|
end
|
|
71
88
|
|
|
72
89
|
tmpdir do
|
|
@@ -74,7 +91,25 @@ class TestPackagerCLI < Test::Unit::TestCase
|
|
|
74
91
|
assert_raise(RP::Error) do
|
|
75
92
|
cli.package ['--generate-only', '--platform', 'unknown', 'myapp']
|
|
76
93
|
end
|
|
94
|
+
error = assert_raise(RP::Error) do
|
|
95
|
+
cli.package ['--notarize-submit', '--notarize-export', 'myapp']
|
|
96
|
+
end
|
|
97
|
+
assert_include error.message, 'only one of'
|
|
98
|
+
|
|
99
|
+
error = assert_raise(RP::Error) do
|
|
100
|
+
cli.package ['--notarize-wait', 'ID', '--appstore', 'myapp']
|
|
101
|
+
end
|
|
102
|
+
assert_include error.message, '--notarize-wait'
|
|
103
|
+
error = assert_raise(RP::Error) do
|
|
104
|
+
cli.package ['--platform', 'windows', '--appstore', 'myapp']
|
|
105
|
+
end
|
|
106
|
+
assert_include error.message, 'only for macos'
|
|
77
107
|
end
|
|
78
108
|
end
|
|
79
109
|
|
|
110
|
+
def test_default_platform()
|
|
111
|
+
stub(Gem, :win_platform?, true) {assert_equal :windows, RP.default_platform}
|
|
112
|
+
stub(Gem, :win_platform?, false) {assert_equal :macos, RP.default_platform}
|
|
113
|
+
end
|
|
114
|
+
|
|
80
115
|
end# TestPackagerCLI
|