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
data/lib/reflex/packager/cli.rb
CHANGED
|
@@ -14,6 +14,16 @@ module Reflex
|
|
|
14
14
|
#
|
|
15
15
|
class CLI
|
|
16
16
|
|
|
17
|
+
# The options to notarize the app by, and the steps of the notarization
|
|
18
|
+
# they take.
|
|
19
|
+
#
|
|
20
|
+
NOTARIZE_OPTIONS = {
|
|
21
|
+
notarize: %i[submit export],
|
|
22
|
+
'notarize-submit': %i[submit],
|
|
23
|
+
'notarize-wait': %i[wait],
|
|
24
|
+
'notarize-export': %i[export]
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
def initialize(profile)
|
|
18
28
|
@profile = profile
|
|
19
29
|
end
|
|
@@ -61,20 +71,43 @@ module Reflex
|
|
|
61
71
|
end
|
|
62
72
|
|
|
63
73
|
def package(argv)
|
|
64
|
-
profile
|
|
65
|
-
argv, params
|
|
66
|
-
on '--platform PLATFORM',
|
|
67
|
-
|
|
68
|
-
on '--
|
|
69
|
-
on '--
|
|
74
|
+
profile, platform = @profile, Packager.default_platform
|
|
75
|
+
argv, params = parse argv, "Usage: #{profile.command} package [options] [DIR]" do
|
|
76
|
+
on '--platform PLATFORM',
|
|
77
|
+
"target platform: #{PLATFORMS.keys.join ' or '} (default: #{platform})"
|
|
78
|
+
on '--config PATH', "config file path (default: DIR/#{profile.config_files.first})"
|
|
79
|
+
on '--generate-only', 'generate project files but do not build'
|
|
80
|
+
on '--pack', 'put the files of the app together in a data file, for a release'
|
|
81
|
+
on '--generic', 'package the app signed with Developer ID, also with --appstore'
|
|
82
|
+
on '--appstore', 'package the app for the Mac App Store, only it unless --generic'
|
|
83
|
+
on '--notarize', 'have Apple check the app with Developer ID, on macos'
|
|
84
|
+
on '--notarize-submit', 'only submit the app to be notarized, not waiting'
|
|
85
|
+
on '--notarize-wait ID', 'only wait for the notarization ID, by the API key'
|
|
86
|
+
on '--notarize-export', 'only wait for the notarization submitted and export the app'
|
|
87
|
+
on '--verbose', 'verbose output'
|
|
70
88
|
end
|
|
71
89
|
|
|
90
|
+
notarize = NOTARIZE_OPTIONS.filter_map {|key, value| value if params[key]}
|
|
91
|
+
raise Error, "only one of #{NOTARIZE_OPTIONS.keys.map {"--#{_1}"}.join ', '}" if
|
|
92
|
+
notarize.size > 1
|
|
93
|
+
|
|
94
|
+
id = params[:'notarize-wait']
|
|
95
|
+
raise Error, '--notarize-wait packages nothing, not with --appstore' if
|
|
96
|
+
id && params[:appstore]
|
|
97
|
+
return AppStoreConnect.new.wait_until_notarized id if id
|
|
98
|
+
|
|
72
99
|
dir = argv.shift || '.'
|
|
73
|
-
platform = (params[:platform] ||
|
|
100
|
+
platform = (params[:platform] || platform).to_sym
|
|
74
101
|
klass = PLATFORMS[platform] || raise(Error, "unknown platform: '#{platform}'")
|
|
75
102
|
config = Config.load profile, dir, params[:config]
|
|
76
|
-
klass.new(
|
|
77
|
-
|
|
103
|
+
klass.new(
|
|
104
|
+
config,
|
|
105
|
+
verbose: params[:verbose],
|
|
106
|
+
pack: params[:pack],
|
|
107
|
+
notarize: notarize.first,
|
|
108
|
+
generic: params[:generic],
|
|
109
|
+
appstore: params[:appstore]
|
|
110
|
+
).package generate_only: params[:'generate-only']
|
|
78
111
|
end
|
|
79
112
|
|
|
80
113
|
private
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'pathname'
|
|
1
2
|
require 'yaml'
|
|
2
3
|
|
|
3
4
|
|
|
@@ -20,15 +21,15 @@ module Reflex
|
|
|
20
21
|
{
|
|
21
22
|
name: name,
|
|
22
23
|
bundle_id: hash[:bundle_id] || default_bundle_id(profile, name),
|
|
23
|
-
version: '0.1.0',
|
|
24
|
+
version: (hash[:version].nil? && version_file(dir)) || '0.1.0',
|
|
25
|
+
copyright: nil,
|
|
24
26
|
main: profile.main,
|
|
25
27
|
icon: nil,
|
|
26
28
|
files: nil,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
macos: MacOSConfig.defaults
|
|
29
|
+
excludes: nil,
|
|
30
|
+
macos: MacOSConfig.defaults,
|
|
31
|
+
windows: WindowsConfig.defaults,
|
|
32
|
+
upload: UploadConfig.defaults
|
|
32
33
|
}
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -51,40 +52,68 @@ module Reflex
|
|
|
51
52
|
def initialize(profile, dir, hash = nil, stderr: $stderr)
|
|
52
53
|
raise Error, "no such directory: '#{dir}'" unless File.directory? dir
|
|
53
54
|
|
|
54
|
-
@profile
|
|
55
|
-
@dir
|
|
56
|
-
hash
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
@profile = profile
|
|
56
|
+
@dir = File.expand_path dir
|
|
57
|
+
hash = symbolize_keys hash || {}
|
|
58
|
+
localizations = hash.delete :localizations
|
|
59
|
+
hash = validate_input hash, Config.defaults(profile, @dir, hash), stderr: stderr
|
|
60
|
+
|
|
61
|
+
# YAML reads 1.10 as a number, the same as 1.1
|
|
62
|
+
raise Error, "version #{hash[:version]} is read as a number, quote it" if
|
|
63
|
+
hash[:version].is_a? Float
|
|
64
|
+
|
|
65
|
+
@name = hash[:name] .to_s
|
|
66
|
+
@bundle_id = hash[:bundle_id] .to_s
|
|
67
|
+
@version = hash[:version] .to_s
|
|
68
|
+
@copyright = hash[:copyright]&.to_s
|
|
69
|
+
@main = hash[:main] .to_s
|
|
70
|
+
@icon = hash[:icon] &.to_s
|
|
64
71
|
@files = hash[:files]&.then {Array(_1).map(&:to_s)}
|
|
65
|
-
@
|
|
66
|
-
@macos = MacOSConfig.new
|
|
72
|
+
@excludes = glob_patterns hash[:excludes], :excludes
|
|
73
|
+
@macos = MacOSConfig.new hash[:macos]
|
|
74
|
+
@windows = WindowsConfig.new hash[:windows]
|
|
75
|
+
@upload = UploadConfig.new hash[:upload]
|
|
76
|
+
|
|
77
|
+
@localizations = localize localizations, stderr: stderr
|
|
67
78
|
validate
|
|
68
79
|
end
|
|
69
80
|
|
|
70
|
-
attr_reader :profile, :dir, :name, :bundle_id, :version, :
|
|
71
|
-
:files, :macos, :
|
|
81
|
+
attr_reader :profile, :dir, :name, :bundle_id, :version, :copyright,
|
|
82
|
+
:main, :icon, :files, :excludes, :macos, :windows, :upload
|
|
83
|
+
|
|
84
|
+
# The values that differ by language, in English, the language of the
|
|
85
|
+
# ones out of localizations, and in each language of the localizations
|
|
86
|
+
# with the English ones for what they leave out.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash<String, Hash>] {language => {name:, copyright:}}
|
|
89
|
+
#
|
|
90
|
+
attr_reader :localizations
|
|
91
|
+
|
|
92
|
+
# The version to show: the numbers of the version, up to three.
|
|
93
|
+
#
|
|
94
|
+
def display_version()
|
|
95
|
+
@version.split('.').first(3).join('.')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The version to tell builds apart with: the version, a fourth number
|
|
99
|
+
# of which tells a build apart from another of the same version.
|
|
100
|
+
#
|
|
101
|
+
def build_version()
|
|
102
|
+
@version
|
|
103
|
+
end
|
|
72
104
|
|
|
73
105
|
# Returns paths to be bundled into the application, relative to the
|
|
74
|
-
# project directory
|
|
106
|
+
# project directory: the files only, with the ones in the directories
|
|
107
|
+
# matched, for excludes to take out a file in them.
|
|
75
108
|
#
|
|
76
109
|
# @return [Array<String>] relative paths
|
|
77
110
|
#
|
|
78
111
|
def app_files()
|
|
79
|
-
|
|
80
|
-
[@main, *@files]
|
|
81
|
-
.flat_map {Dir.glob _1, base: @dir}
|
|
82
|
-
.reject {_1.start_with?('.') || excludes.include?(_1)}
|
|
83
|
-
.uniq
|
|
84
|
-
.sort
|
|
112
|
+
excluded = glob_files [*EXCLUDES, *@profile.config_files, *@excludes]
|
|
113
|
+
(glob_files([@main, *@files]) - excluded).reject {_1.start_with? '.'}.sort
|
|
85
114
|
end
|
|
86
115
|
|
|
87
|
-
EXCLUDES = %w[
|
|
116
|
+
EXCLUDES = %w[dist]
|
|
88
117
|
|
|
89
118
|
private
|
|
90
119
|
|
|
@@ -101,6 +130,19 @@ module Reflex
|
|
|
101
130
|
"#{profile.bundle_id_prefix}.#{id}"
|
|
102
131
|
end
|
|
103
132
|
|
|
133
|
+
# The version in the VERSION file of the app, which the config may leave
|
|
134
|
+
# it to, as the gems have theirs in one.
|
|
135
|
+
#
|
|
136
|
+
def self.version_file(dir)
|
|
137
|
+
path = File.join dir, 'VERSION'
|
|
138
|
+
return nil unless File.file? path
|
|
139
|
+
# without the BOM an editor on Windows may write
|
|
140
|
+
version = File.read(path, encoding: 'bom|utf-8').strip
|
|
141
|
+
raise Error, "invalid version in the VERSION file: '#{version}'" if
|
|
142
|
+
version !~ VERSION_FORMAT
|
|
143
|
+
version
|
|
144
|
+
end
|
|
145
|
+
|
|
104
146
|
def symbolize_keys(hash)
|
|
105
147
|
hash.map {|k, v|
|
|
106
148
|
[
|
|
@@ -110,6 +152,26 @@ module Reflex
|
|
|
110
152
|
}.to_h
|
|
111
153
|
end
|
|
112
154
|
|
|
155
|
+
# A language tag, as ja or zh-Hans.
|
|
156
|
+
#
|
|
157
|
+
LANGUAGE_FORMAT = /\A[a-z]{2,3}(-[A-Za-z0-9]{2,8})*\z/
|
|
158
|
+
|
|
159
|
+
def localize(localizations, stderr: nil)
|
|
160
|
+
english = {name: @name, copyright: @copyright}
|
|
161
|
+
raise Error, "not a Hash: '/localizations'" unless
|
|
162
|
+
localizations.nil? || localizations.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
(localizations || {}).each.with_object({'en' => english}) do |(lang, hash), result|
|
|
165
|
+
lang = lang.to_s
|
|
166
|
+
raise Error, "invalid language: '#{lang}'" if lang !~ LANGUAGE_FORMAT
|
|
167
|
+
raise Error, "'en' is the language of the values out of localizations" if
|
|
168
|
+
lang == 'en'
|
|
169
|
+
result[lang] = validate_input(
|
|
170
|
+
hash, english, parent: "/localizations/#{lang}", stderr: stderr
|
|
171
|
+
).transform_values {_1&.to_s}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
113
175
|
def validate_input(hash, defaults, parent: '', stderr: nil)
|
|
114
176
|
return defaults.dup if hash.nil?
|
|
115
177
|
raise Error, "not a Hash: '#{parent}'" unless hash.is_a? Hash
|
|
@@ -127,16 +189,50 @@ module Reflex
|
|
|
127
189
|
end
|
|
128
190
|
end
|
|
129
191
|
|
|
192
|
+
def glob_patterns(patterns, key)
|
|
193
|
+
return nil if patterns.nil?
|
|
194
|
+
Array(patterns).map do |pattern|
|
|
195
|
+
raise Error, "not a glob pattern in '/#{key}': #{pattern.inspect}" if
|
|
196
|
+
pattern.is_a?(Hash) || pattern.is_a?(Array) || pattern.to_s.strip.empty?
|
|
197
|
+
pattern.to_s
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The files matched by +patterns+ in the project directory, and the
|
|
202
|
+
# ones in the directories matched but the dotfiles, as lib/a.rb for
|
|
203
|
+
# ./lib/a.rb too, for the excludes to be compared with.
|
|
204
|
+
#
|
|
205
|
+
def glob_files(patterns)
|
|
206
|
+
patterns
|
|
207
|
+
.flat_map {Dir.glob _1, base: @dir}
|
|
208
|
+
.map {Pathname(_1).cleanpath.to_s}
|
|
209
|
+
.flat_map {|path|
|
|
210
|
+
# by base:, not to take the name of the directory as a pattern
|
|
211
|
+
[path, *Dir.glob('**/*', base: File.join(@dir, path)).map {File.join path, _1}]
|
|
212
|
+
}
|
|
213
|
+
.reject {File.directory? File.join(@dir, _1)}
|
|
214
|
+
.uniq
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Numbers only, which macOS and Windows compare versions by.
|
|
218
|
+
#
|
|
219
|
+
VERSION_FORMAT = /\A\d+(\.\d+)*\z/
|
|
220
|
+
|
|
130
221
|
def validate()
|
|
222
|
+
# the name of the .app on macos, which can not have a path separator
|
|
223
|
+
raise Error, "invalid name: '#{@name}'" if @name.strip.empty? || @name =~ %r{[/:]}
|
|
224
|
+
|
|
131
225
|
raise Error, "invalid bundle_id: '#{@bundle_id}'" if
|
|
132
226
|
@bundle_id !~ /\A[A-Za-z0-9\-]+(\.[A-Za-z0-9\-]+)+\z/
|
|
133
227
|
|
|
134
|
-
raise Error, "invalid version: '#{@version}'" if
|
|
135
|
-
@version !~ /\A\d+(\.\d+)*\z/
|
|
228
|
+
raise Error, "invalid version: '#{@version}'" if @version !~ VERSION_FORMAT
|
|
136
229
|
|
|
137
230
|
raise Error, "main script not found: '#{@main}'" if
|
|
138
231
|
!File.file?(File.join @dir, @main)
|
|
139
232
|
|
|
233
|
+
raise Error, "main script excluded: '#{@main}'" if
|
|
234
|
+
@excludes && (glob_files([@main]) - glob_files(@excludes)).empty?
|
|
235
|
+
|
|
140
236
|
raise Error, "icon not found: '#{@icon}'" if
|
|
141
237
|
@icon && !File.file?(File.join @dir, @icon)
|
|
142
238
|
end
|
|
@@ -148,10 +244,20 @@ module Reflex
|
|
|
148
244
|
#
|
|
149
245
|
class MacOSConfig
|
|
150
246
|
|
|
247
|
+
# The keys of codesign in the config, and the environment variables
|
|
248
|
+
# that override them.
|
|
249
|
+
#
|
|
250
|
+
CODESIGN_ENV_KEYS = {
|
|
251
|
+
identity: 'APPLE_CODESIGN_IDENTITY',
|
|
252
|
+
team_id: 'APPLE_TEAM_ID'
|
|
253
|
+
}
|
|
254
|
+
|
|
151
255
|
def self.defaults()
|
|
152
256
|
{
|
|
153
257
|
deployment_target: '11.0',
|
|
154
|
-
archs:
|
|
258
|
+
archs: %w[arm64 x86_64],
|
|
259
|
+
cruby: nil,
|
|
260
|
+
category: nil,
|
|
155
261
|
codesign: {
|
|
156
262
|
identity: '-',
|
|
157
263
|
team_id: nil
|
|
@@ -160,22 +266,113 @@ module Reflex
|
|
|
160
266
|
end
|
|
161
267
|
|
|
162
268
|
def initialize(hash)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
@
|
|
166
|
-
@
|
|
269
|
+
codesign = hash[:codesign].merge CODESIGN_ENV_KEYS.transform_values {env _1}.compact
|
|
270
|
+
|
|
271
|
+
@deployment_target = hash[:deployment_target] .to_s
|
|
272
|
+
@archs = Array(hash[:archs]).map &:to_s
|
|
273
|
+
@cruby = hash[:cruby] &.to_s
|
|
274
|
+
@category = hash[:category] &.to_s
|
|
275
|
+
@codesign_identity = codesign[:identity] .to_s
|
|
276
|
+
@codesign_team_id = codesign[:team_id] &.to_s
|
|
167
277
|
validate
|
|
168
278
|
end
|
|
169
279
|
|
|
170
|
-
|
|
280
|
+
# The version of CRuby, or the path of a checkout of the cruby
|
|
281
|
+
# repository, to embed.
|
|
282
|
+
#
|
|
283
|
+
attr_reader :cruby
|
|
284
|
+
|
|
285
|
+
attr_reader :deployment_target, :archs
|
|
286
|
+
|
|
287
|
+
# The category of the app in its Info.plist, as public.app-category.games,
|
|
288
|
+
# which Finder groups apps by and the App Store requires.
|
|
289
|
+
#
|
|
290
|
+
attr_reader :category
|
|
291
|
+
|
|
292
|
+
# The identity and the team to sign the app with, which
|
|
293
|
+
# APPLE_CODESIGN_IDENTITY and APPLE_TEAM_ID override, for the name of
|
|
294
|
+
# the developer in the identity not to be in the config.
|
|
295
|
+
#
|
|
296
|
+
attr_reader :codesign_identity, :codesign_team_id
|
|
171
297
|
|
|
172
298
|
def validate()
|
|
173
299
|
raise Error, "invalid archs: '#{@archs}'" if @archs.empty?
|
|
300
|
+
raise Error, "invalid category: '#{@category}'" if
|
|
301
|
+
@category && @category !~ /\Apublic\.app-category\.[a-z\-]+\z/
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
# The environment variable +name+, or nil if empty, as a secret of
|
|
307
|
+
# GitHub Actions not set is.
|
|
308
|
+
#
|
|
309
|
+
def env(name)
|
|
310
|
+
ENV[name].then {_1 unless _1.to_s.empty?}
|
|
174
311
|
end
|
|
175
312
|
|
|
176
313
|
end# MacOSConfig
|
|
177
314
|
|
|
178
315
|
|
|
316
|
+
# Windows specific configuration.
|
|
317
|
+
#
|
|
318
|
+
class WindowsConfig
|
|
319
|
+
|
|
320
|
+
def self.defaults()
|
|
321
|
+
{
|
|
322
|
+
console: false
|
|
323
|
+
}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def initialize(hash)
|
|
327
|
+
@console = hash[:console]
|
|
328
|
+
validate
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Whether the executable keeps a console window, which shows what an app
|
|
332
|
+
# prints and the errors it dies of while it is being worked on.
|
|
333
|
+
#
|
|
334
|
+
def console?()
|
|
335
|
+
@console
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def validate()
|
|
339
|
+
raise Error, "invalid console: '#{@console}'" unless
|
|
340
|
+
[true, false].include? @console
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
end# WindowsConfig
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Configuration of the stores to upload the app to.
|
|
347
|
+
#
|
|
348
|
+
class UploadConfig
|
|
349
|
+
|
|
350
|
+
def self.defaults()
|
|
351
|
+
{
|
|
352
|
+
itchio: {
|
|
353
|
+
project: nil
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def initialize(hash)
|
|
359
|
+
@itchio_project = hash[:itchio][:project]&.to_s
|
|
360
|
+
validate
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The project of itch.io to upload the app to, as user/game of
|
|
364
|
+
# https://user.itch.io/game, which butler takes.
|
|
365
|
+
#
|
|
366
|
+
attr_reader :itchio_project
|
|
367
|
+
|
|
368
|
+
def validate()
|
|
369
|
+
raise Error, "invalid upload.itchio.project: '#{@itchio_project}'" if
|
|
370
|
+
@itchio_project && @itchio_project !~ %r{\A[a-z0-9_\-]+/[a-z0-9_\-]+\z}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
end# UploadConfig
|
|
374
|
+
|
|
375
|
+
|
|
179
376
|
# Raised on invalid configuration or packaging failure.
|
|
180
377
|
#
|
|
181
378
|
class Error < StandardError; end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative 'data_file'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Reflex
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
module Packager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Compiles the Ruby scripts of an app into instruction sequences for its
|
|
11
|
+
# data file, which DataLoader reads them from, named as the scripts but
|
|
12
|
+
# with .rbc.
|
|
13
|
+
#
|
|
14
|
+
# They are compiled by the Ruby running this, which has to be the one a
|
|
15
|
+
# package runs them on, with their paths relative to the app directory,
|
|
16
|
+
# as the one the package runs in has them.
|
|
17
|
+
#
|
|
18
|
+
# A package carries none of this, which requires nothing but DataFile,
|
|
19
|
+
# for an app to compile its scripts with the Ruby in it.
|
|
20
|
+
#
|
|
21
|
+
module DataCompiler
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
|
|
25
|
+
# The names of the Ruby scripts of the app in +dir+.
|
|
26
|
+
#
|
|
27
|
+
def scripts(dir)
|
|
28
|
+
Dir.glob('**/*.rb', base: dir).select {File.file? File.join(dir, _1)}.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Compiles the Ruby scripts of the app in +dir+ into the files of its
|
|
32
|
+
# data file, with the version of the Ruby.
|
|
33
|
+
#
|
|
34
|
+
def compile(dir)
|
|
35
|
+
data = scripts(dir).to_h do |name|
|
|
36
|
+
source = File.read File.join(dir, name), encoding: Encoding::UTF_8
|
|
37
|
+
iseq = RubyVM::InstructionSequence.compile source, name, name
|
|
38
|
+
[name.sub(/\.rb\z/, '.rbc'), iseq.to_binary]
|
|
39
|
+
end
|
|
40
|
+
data['.ruby-version'] = RUBY_VERSION
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Writes the data file at +path+ with the Ruby scripts of the app in
|
|
45
|
+
# +dir+ compiled, all of them before writing it.
|
|
46
|
+
#
|
|
47
|
+
def write(dir, path)
|
|
48
|
+
DataFile.write path, compile(dir)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
end# self
|
|
52
|
+
|
|
53
|
+
end# DataCompiler
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
end# Packager
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
end# Reflex
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module Reflex
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Packager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# A file the files of an app are put together in, as app/data.bin, which
|
|
8
|
+
# a package reads them from as they are, not extracting them.
|
|
9
|
+
#
|
|
10
|
+
# It has a header, the bytes of the files one after another, and the
|
|
11
|
+
# index of them, each byte of which but the header's is substituted with
|
|
12
|
+
# another by a table from the seed in the header, which keeps them from
|
|
13
|
+
# being read as they are, though it is no encryption.
|
|
14
|
+
#
|
|
15
|
+
# A package carries this file to read its data file, so it requires
|
|
16
|
+
# nothing.
|
|
17
|
+
#
|
|
18
|
+
class DataFile
|
|
19
|
+
|
|
20
|
+
MAGIC = 'RXDF'
|
|
21
|
+
|
|
22
|
+
VERSION = 1
|
|
23
|
+
|
|
24
|
+
# The magic, the version, the seed, and the offset and the size of the
|
|
25
|
+
# index.
|
|
26
|
+
#
|
|
27
|
+
HEADER = 'a4 L< L< Q< Q<'
|
|
28
|
+
|
|
29
|
+
HEADER_SIZE = 28
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
|
|
33
|
+
# Writes the files to +path+.
|
|
34
|
+
#
|
|
35
|
+
# @param [String] path data file path
|
|
36
|
+
# @param [Hash<String, String>] files {name => bytes}, a name as 'dir/file.png'
|
|
37
|
+
# @param [Integer] seed of the substitution of the bytes, a random one by default
|
|
38
|
+
#
|
|
39
|
+
def write(path, files, seed: rand(1..0xffffffff))
|
|
40
|
+
tr_substitutes seed # raises on an invalid one before writing
|
|
41
|
+
|
|
42
|
+
index, offset = [], HEADER_SIZE
|
|
43
|
+
files.each do |name, bytes|
|
|
44
|
+
raise ArgumentError, "invalid name: #{name.inspect}" if
|
|
45
|
+
name =~ /[\t\n]/ || name.start_with?('/') || name.split('/').include?('..')
|
|
46
|
+
index << "#{name}\t#{offset}\t#{bytes.bytesize}\n"
|
|
47
|
+
offset += bytes.bytesize
|
|
48
|
+
end
|
|
49
|
+
index = index.join.b
|
|
50
|
+
|
|
51
|
+
File.open path, 'wb' do |f|
|
|
52
|
+
f.write [MAGIC, VERSION, seed, offset, index.bytesize].pack HEADER
|
|
53
|
+
files.each_value {f.write encode(_1, seed)}
|
|
54
|
+
f.write encode(index, seed)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def open(path, &block)
|
|
59
|
+
file = new path
|
|
60
|
+
return file unless block
|
|
61
|
+
begin
|
|
62
|
+
block.call file
|
|
63
|
+
ensure
|
|
64
|
+
file.close
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# +bytes+ with each of them substituted with another by the table from
|
|
69
|
+
# +seed+.
|
|
70
|
+
#
|
|
71
|
+
def encode(bytes, seed)
|
|
72
|
+
bytes.b.tap {_1.tr! TR_BYTES, tr_substitutes(seed)}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# +bytes+ encoded, with the substitution undone.
|
|
76
|
+
#
|
|
77
|
+
def decode(bytes, seed)
|
|
78
|
+
bytes.b.tap {_1.tr! tr_substitutes(seed), TR_BYTES}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def tr_substitutes(seed)
|
|
84
|
+
(@tr_substitutes ||= {})[seed] ||= tr_bytes substitutes(seed)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The bytes in an order shuffled by xorshift from +seed+, which is the
|
|
88
|
+
# same on any Ruby, as Random may not be.
|
|
89
|
+
#
|
|
90
|
+
def substitutes(seed)
|
|
91
|
+
raise ArgumentError, "invalid seed: #{seed}" unless (1..0xffffffff).include? seed
|
|
92
|
+
x = seed
|
|
93
|
+
255.downto(1).with_object((0..255).to_a) {|i, bytes|
|
|
94
|
+
x ^= (x << 13) & 0xffffffff
|
|
95
|
+
x ^= x >> 17
|
|
96
|
+
x ^= (x << 5) & 0xffffffff
|
|
97
|
+
j = x % (i + 1)
|
|
98
|
+
bytes[i], bytes[j] = bytes[j], bytes[i]
|
|
99
|
+
}.pack('C*')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# String#tr takes '\', '-' and '^' as escape, range and negation.
|
|
103
|
+
#
|
|
104
|
+
def tr_bytes(bytes)
|
|
105
|
+
bytes.gsub(/[\\\-^]/n) {"\\#{_1}"}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
end# self
|
|
109
|
+
|
|
110
|
+
TR_BYTES = tr_bytes (0..255).map(&:chr).join.b
|
|
111
|
+
|
|
112
|
+
def initialize(path)
|
|
113
|
+
@path = path
|
|
114
|
+
@file = File.open path, 'rb'
|
|
115
|
+
@mutex = Mutex.new
|
|
116
|
+
@seed, @index = read_index
|
|
117
|
+
rescue
|
|
118
|
+
@file&.close
|
|
119
|
+
raise
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
attr_reader :path
|
|
123
|
+
|
|
124
|
+
def names()
|
|
125
|
+
@index.keys
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def include?(name)
|
|
129
|
+
@index.key? name
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The size of the file in bytes, or nil.
|
|
133
|
+
#
|
|
134
|
+
def size(name)
|
|
135
|
+
@index[name]&.last
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The bytes of the file, or nil.
|
|
139
|
+
#
|
|
140
|
+
def read(name)
|
|
141
|
+
offset, size = @index[name]
|
|
142
|
+
return nil unless offset
|
|
143
|
+
DataFile.decode read_at(offset, size), @seed
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def close()
|
|
147
|
+
@file.close
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def read_at(offset, size)
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
@file.seek offset
|
|
155
|
+
@file.read(size) || '' # nil at the end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def read_index()
|
|
160
|
+
header = read_at 0, HEADER_SIZE
|
|
161
|
+
raise "not a data file: #{@path}" unless header.bytesize == HEADER_SIZE
|
|
162
|
+
|
|
163
|
+
magic, version, seed, offset, size = header.unpack HEADER
|
|
164
|
+
raise "not a data file: #{@path}" unless magic == MAGIC
|
|
165
|
+
raise "unknown data file version: #{version}" unless version == VERSION
|
|
166
|
+
|
|
167
|
+
index = DataFile.decode(read_at(offset, size), seed)
|
|
168
|
+
.force_encoding(Encoding::UTF_8)
|
|
169
|
+
.lines(chomp: true)
|
|
170
|
+
.to_h {
|
|
171
|
+
name, offset, size = _1.split "\t"
|
|
172
|
+
[name, [offset.to_i, size.to_i]]
|
|
173
|
+
}
|
|
174
|
+
return seed, index
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
end# DataFile
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
end# Packager
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
end# Reflex
|