zui 0.0.6 → 0.0.7

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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Zui
6
+ module Dist
7
+ CONFIG_FILE = "config.rb"
8
+ UNSET = Object.new.freeze
9
+ PLATFORM_ICON_EXTENSIONS = {
10
+ linux: %w[.png .svg],
11
+ macos: %w[.icns],
12
+ windows: %w[.ico]
13
+ }.freeze
14
+
15
+ class Config
16
+ attr_reader :name, :identifier, :version, :publisher, :description, :license,
17
+ :homepage, :icons, :categories
18
+
19
+ def initialize(name:, identifier:, version:, publisher:, description:, license:,
20
+ homepage:, icons:, categories:)
21
+ @name = name
22
+ @identifier = identifier
23
+ @version = version
24
+ @publisher = publisher
25
+ @description = description
26
+ @license = license
27
+ @homepage = homepage
28
+ @icons = icons.transform_keys(&:to_sym).transform_values(&:to_s).freeze
29
+ @categories = categories.map(&:to_s).freeze
30
+ freeze
31
+ end
32
+
33
+ def validate!(project:, platform:)
34
+ project = File.realpath(project)
35
+ validate_text!("name", name, maximum: 100)
36
+ validate_text!("publisher", publisher, maximum: 200)
37
+ validate_text!("description", description, maximum: 500)
38
+ validate_text!("license", license, maximum: 100)
39
+ if homepage
40
+ validate_text!("homepage", homepage, maximum: 500)
41
+ unless homepage.match?(%r{\Ahttps?://[^\s]+\z})
42
+ raise ArgumentError, "#{CONFIG_FILE} homepage must be an HTTP or HTTPS URL"
43
+ end
44
+ end
45
+ unless identifier.to_s.match?(/\A[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)+\z/)
46
+ raise ArgumentError, "#{CONFIG_FILE} identifier must be a lowercase reverse-DNS name"
47
+ end
48
+ unless version.to_s.match?(/\A\d+\.\d+\.\d+\z/)
49
+ raise ArgumentError, "#{CONFIG_FILE} version must have three numeric parts, such as 1.2.0"
50
+ end
51
+ if categories.empty? || categories.any? { |value| !value.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/) }
52
+ raise ArgumentError, "#{CONFIG_FILE} categories must contain portable desktop category names"
53
+ end
54
+
55
+ icon_path(project, platform)
56
+ self
57
+ end
58
+
59
+ def package_name
60
+ value = name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
61
+ raise ArgumentError, "#{CONFIG_FILE} name must contain letters or numbers" if value.empty?
62
+
63
+ value.length < 2 ? "zui-#{value}" : value
64
+ end
65
+
66
+ def icon_path(project, platform)
67
+ os = platform.os
68
+ relative = icons[os]
69
+ raise ArgumentError, "#{CONFIG_FILE} must declare icon #{os}: \"path\"" if relative.nil? || relative.empty?
70
+ if Pathname.new(relative).absolute? || relative.include?("\0")
71
+ raise ArgumentError, "#{CONFIG_FILE} #{os} icon must be a project-relative path"
72
+ end
73
+
74
+ path = File.expand_path(relative, project)
75
+ unless File.file?(path)
76
+ raise ArgumentError, "#{CONFIG_FILE} #{os} icon was not found: #{relative}"
77
+ end
78
+ real_path = File.realpath(path)
79
+ unless real_path.start_with?("#{File.realpath(project)}#{File::SEPARATOR}")
80
+ raise ArgumentError, "#{CONFIG_FILE} #{os} icon must stay inside the project"
81
+ end
82
+ extension = File.extname(real_path).downcase
83
+ allowed = PLATFORM_ICON_EXTENSIONS.fetch(os)
84
+ unless allowed.include?(extension)
85
+ raise ArgumentError, "#{CONFIG_FILE} #{os} icon must use #{allowed.join(' or ')}"
86
+ end
87
+ raise ArgumentError, "#{CONFIG_FILE} icon is too large" if File.size(real_path) > 20 * 1024 * 1024
88
+
89
+ validate_icon_signature!(real_path, extension)
90
+ real_path
91
+ end
92
+
93
+ private
94
+
95
+ def validate_text!(field, value, maximum:)
96
+ unless value.is_a?(String) && !value.strip.empty? && value.bytesize <= maximum &&
97
+ !value.match?(/[\r\n\0]/)
98
+ raise ArgumentError, "#{CONFIG_FILE} #{field} must be a single non-empty line"
99
+ end
100
+ end
101
+
102
+ def validate_icon_signature!(path, extension)
103
+ header = File.binread(path, 512)
104
+ valid = case extension
105
+ when ".png" then header.start_with?("\x89PNG\r\n\x1a\n".b)
106
+ when ".svg" then header.match?(/<svg\b/i)
107
+ when ".icns" then header.start_with?("icns")
108
+ when ".ico" then header.start_with?("\x00\x00\x01\x00".b)
109
+ end
110
+ raise ArgumentError, "#{CONFIG_FILE} icon contents do not match #{extension}" unless valid
111
+ end
112
+ end
113
+
114
+ class Builder
115
+ def initialize
116
+ @values = { homepage: nil, icons: {}, categories: ["Utility"] }
117
+ end
118
+
119
+ %i[name identifier version publisher description license homepage].each do |field|
120
+ define_method(field) do |value = UNSET|
121
+ raise ArgumentError, "#{field} requires a value" if value.equal?(UNSET)
122
+
123
+ @values[field] = value.to_s
124
+ end
125
+ end
126
+
127
+ def icon(**paths)
128
+ unknown = paths.keys.map(&:to_sym) - PLATFORM_ICON_EXTENSIONS.keys
129
+ raise ArgumentError, "unknown icon platform: #{unknown.first}" unless unknown.empty?
130
+
131
+ @values[:icons].merge!(paths.transform_keys(&:to_sym))
132
+ end
133
+
134
+ def categories(*values)
135
+ @values[:categories] = values.flatten.map(&:to_s)
136
+ end
137
+
138
+ def build
139
+ required = %i[name identifier version publisher description license]
140
+ missing = required.reject { |field| @values.key?(field) }
141
+ unless missing.empty?
142
+ raise ArgumentError, "#{CONFIG_FILE} is missing: #{missing.join(', ')}"
143
+ end
144
+
145
+ Config.new(**@values)
146
+ end
147
+ end
148
+
149
+ module_function
150
+
151
+ def configure(&block)
152
+ raise ArgumentError, "Dist.configure requires a block" unless block
153
+
154
+ builder = Builder.new
155
+ block.arity == 1 ? block.call(builder) : builder.instance_eval(&block)
156
+ builder.build
157
+ end
158
+
159
+ def load(project:, platform: Platform.current)
160
+ project = File.expand_path(project)
161
+ path = File.join(project, CONFIG_FILE)
162
+ raise ArgumentError, "#{CONFIG_FILE} not found in project root" unless File.file?(path)
163
+ raise ArgumentError, "#{CONFIG_FILE} is too large" if File.size(path) > 131_072
164
+
165
+ value = Module.new.module_eval(File.read(path), path, 1)
166
+ unless value.is_a?(Config)
167
+ raise ArgumentError, "#{CONFIG_FILE} must return Zui::Dist.configure do ... end"
168
+ end
169
+ value.validate!(project:, platform: platform.assert_supported!)
170
+ rescue SyntaxError => error
171
+ raise ArgumentError, "invalid #{CONFIG_FILE}: #{error.message.lines.first.to_s.strip}"
172
+ rescue ScriptError => error
173
+ raise ArgumentError, "#{CONFIG_FILE} failed: #{error.class}: #{error.message}"
174
+ rescue ArgumentError
175
+ raise
176
+ rescue StandardError => error
177
+ raise ArgumentError, "#{CONFIG_FILE} failed: #{error.class}: #{error.message}"
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "rubygems/package"
6
+ require "shellwords"
7
+ require "tmpdir"
8
+ require "zlib"
9
+
10
+ module Zui
11
+ class DistPackager
12
+ attr_reader :platform, :config, :tree_shake_report
13
+
14
+ def initialize(client: nil, platform: Platform.current, framework_root: FRAMEWORK_ROOT,
15
+ ruby: RbConfig.ruby, tree_shake: true, environment: ENV)
16
+ @platform = platform.assert_supported!
17
+ @client = client || Client.new(platform: @platform)
18
+ @framework_root = framework_root
19
+ @ruby = File.expand_path(ruby)
20
+ @tree_shake = tree_shake == true
21
+ @environment = environment.to_h
22
+ @config = nil
23
+ @tree_shake_report = nil
24
+ end
25
+
26
+ def package(source, output: nil)
27
+ project = File.expand_path(source)
28
+ raise ArgumentError, "project directory not found: #{project}" unless File.directory?(project)
29
+
30
+ @project = project
31
+ @config = Dist.load(project:, platform:)
32
+ output = File.expand_path(output || File.join(project, "dist"))
33
+ if File.exist?(output) && !File.directory?(output)
34
+ raise ArgumentError, "distribution output is not a directory: #{output}"
35
+ end
36
+ preflight!
37
+ targets = artifact_names.map { |name| File.join(output, name) }
38
+ existing = targets.find { |path| File.exist?(path) }
39
+ raise ArgumentError, "distribution artifact already exists: #{existing}" if existing
40
+
41
+ FileUtils.mkdir_p(output)
42
+ artifacts = []
43
+ Dir.mktmpdir(".zui-dist-", output) do |temporary|
44
+ bundle = build_bundle(project, temporary)
45
+ artifacts = case platform.os
46
+ when :linux then build_linux_packages(bundle, temporary)
47
+ when :macos then [build_macos_dmg(bundle, temporary)]
48
+ when :windows then [build_windows_setup(bundle, temporary)]
49
+ end
50
+ artifacts.zip(targets).each { |source_path, target| File.rename(source_path, target) }
51
+ end
52
+ targets
53
+ end
54
+
55
+ private
56
+
57
+ def build_bundle(project, temporary)
58
+ destination = if platform.macos?
59
+ File.join(temporary, "#{safe_filename(config.name)}.app")
60
+ else
61
+ File.join(temporary, "bundle")
62
+ end
63
+ distribution = Distribution.new(
64
+ client: @client, platform:, framework_root: @framework_root, ruby: @ruby,
65
+ tree_shake: @tree_shake, release_config: config
66
+ )
67
+ distribution.bundle(project, name: config.name, destination:)
68
+ @tree_shake_report = distribution.tree_shake_report
69
+ destination
70
+ end
71
+
72
+ def artifact_names
73
+ stem = "#{config.package_name}-#{config.version}-#{platform.id}"
74
+ case platform.os
75
+ when :linux
76
+ ["#{stem}.deb", "#{stem}.rpm"]
77
+ when :macos
78
+ ["#{stem}.dmg"]
79
+ when :windows
80
+ ["#{stem}-setup.exe"]
81
+ end
82
+ end
83
+
84
+ def preflight!
85
+ case platform.os
86
+ when :linux
87
+ command!("rpmbuild", hint: "install rpm-build (Debian/Fedora) or rpm-tools (Arch)")
88
+ when :macos
89
+ command!("hdiutil", hint: "install the macOS command-line tools")
90
+ when :windows
91
+ command!("ISCC.exe", "iscc", hint: "install Inno Setup 6 and add ISCC.exe to PATH")
92
+ end
93
+ end
94
+
95
+ def build_linux_packages(bundle, temporary)
96
+ stage = File.join(temporary, "linux-root")
97
+ application = File.join(stage, "opt", config.package_name)
98
+ FileUtils.mkdir_p(File.dirname(application))
99
+ FileUtils.cp_r(bundle, application)
100
+ FileUtils.remove_entry(File.join(application, "share")) if File.directory?(File.join(application, "share"))
101
+
102
+ command_path = File.join(stage, "usr", "bin", config.package_name)
103
+ FileUtils.mkdir_p(File.dirname(command_path))
104
+ File.write(command_path, <<~SH)
105
+ #!/bin/sh
106
+ exec /opt/#{config.package_name}/run "$@"
107
+ SH
108
+ FileUtils.chmod(0o755, command_path)
109
+
110
+ desktop_path = File.join(stage, "usr", "share", "applications", "#{config.package_name}.desktop")
111
+ FileUtils.mkdir_p(File.dirname(desktop_path))
112
+ File.write(desktop_path, linux_desktop_entry)
113
+
114
+ icon = config.icon_path(@project, platform)
115
+ extension = File.extname(icon).downcase
116
+ icon_directory = extension == ".svg" ? "scalable" : "256x256"
117
+ installed_icon = File.join(stage, "usr", "share", "icons", "hicolor", icon_directory,
118
+ "apps", "#{config.package_name}#{extension}")
119
+ FileUtils.mkdir_p(File.dirname(installed_icon))
120
+ FileUtils.cp(icon, installed_icon)
121
+
122
+ deb = File.join(temporary, artifact_names.fetch(0))
123
+ rpm = File.join(temporary, artifact_names.fetch(1))
124
+ build_deb(stage, deb)
125
+ build_rpm(stage, rpm, temporary)
126
+ [deb, rpm]
127
+ end
128
+
129
+ def linux_desktop_entry
130
+ categories = config.categories.join(";")
131
+ <<~DESKTOP
132
+ [Desktop Entry]
133
+ Type=Application
134
+ Name=#{config.name}
135
+ Comment=#{config.description}
136
+ Exec=/opt/#{config.package_name}/run
137
+ Icon=#{config.package_name}
138
+ Terminal=false
139
+ Categories=#{categories};
140
+ DESKTOP
141
+ end
142
+
143
+ def build_deb(stage, output)
144
+ workspace = File.join(File.dirname(output), "deb-work")
145
+ FileUtils.mkdir_p(workspace)
146
+ control = File.join(workspace, "control")
147
+ File.write(control, deb_control(stage))
148
+ debian_binary = File.join(workspace, "debian-binary")
149
+ File.write(debian_binary, "2.0\n")
150
+ control_archive = File.join(workspace, "control.tar.gz")
151
+ data_archive = File.join(workspace, "data.tar.gz")
152
+ gzip_tar(control_archive, workspace, ["control"])
153
+ gzip_tar(data_archive, stage, Dir.children(stage).sort)
154
+ write_ar(output, [debian_binary, control_archive, data_archive])
155
+ end
156
+
157
+ def deb_control(stage)
158
+ installed_size = (tree_bytes(stage) / 1024.0).ceil
159
+ values = [
160
+ "Package: #{config.package_name}",
161
+ "Version: #{config.version}",
162
+ "Section: utils",
163
+ "Priority: optional",
164
+ "Architecture: #{deb_architecture}",
165
+ "Maintainer: #{config.publisher}",
166
+ "Installed-Size: #{installed_size}",
167
+ "Depends: ruby (>= 3.1)",
168
+ "Description: #{config.description}"
169
+ ]
170
+ values.insert(-2, "Homepage: #{config.homepage}") if config.homepage
171
+ "#{values.join("\n")}\n"
172
+ end
173
+
174
+ def build_rpm(stage, output, temporary)
175
+ topdir = File.join(temporary, "rpmbuild")
176
+ %w[BUILD BUILDROOT RPMS SOURCES SPECS SRPMS].each { |name| FileUtils.mkdir_p(File.join(topdir, name)) }
177
+ spec = File.join(topdir, "SPECS", "#{config.package_name}.spec")
178
+ File.write(spec, rpm_spec(stage))
179
+ rpmbuild = command!("rpmbuild", hint: "install rpm-build (Debian/Fedora) or rpm-tools (Arch)")
180
+ run!([rpmbuild, "--define", "_topdir #{topdir}", "-bb", spec], timeout: 900)
181
+ built = Dir[File.join(topdir, "RPMS", "**", "*.rpm")].sort
182
+ raise ArgumentError, "rpmbuild did not produce an RPM artifact" unless built.length == 1
183
+
184
+ FileUtils.cp(built.first, output)
185
+ end
186
+
187
+ def rpm_spec(stage)
188
+ description = rpm_escape(config.description)
189
+ homepage = config.homepage ? "URL: #{rpm_escape(config.homepage)}\n" : ""
190
+ <<~SPEC
191
+ %global debug_package %{nil}
192
+ Name: #{config.package_name}
193
+ Version: #{config.version}
194
+ Release: 1
195
+ Summary: #{description}
196
+ License: #{rpm_escape(config.license)}
197
+ #{homepage}BuildArch: #{rpm_architecture}
198
+ AutoReqProv: no
199
+ Requires: ruby >= 3.1
200
+
201
+ %description
202
+ #{description}
203
+
204
+ %install
205
+ rm -rf "%{buildroot}"
206
+ mkdir -p "%{buildroot}"
207
+ cp -a #{Shellwords.escape(stage)}/. "%{buildroot}/"
208
+
209
+ %files
210
+ /opt/#{config.package_name}
211
+ /usr/bin/#{config.package_name}
212
+ /usr/share/applications/#{config.package_name}.desktop
213
+ /usr/share/icons/hicolor
214
+ SPEC
215
+ end
216
+
217
+ def build_macos_dmg(bundle, temporary)
218
+ root = File.join(temporary, "dmg-root")
219
+ FileUtils.mkdir_p(root)
220
+ FileUtils.cp_r(bundle, root)
221
+ File.symlink("/Applications", File.join(root, "Applications"))
222
+ output = File.join(temporary, artifact_names.first)
223
+ hdiutil = command!("hdiutil", hint: "install the macOS command-line tools")
224
+ run!([hdiutil, "create", "-volname", config.name, "-srcfolder", root,
225
+ "-ov", "-format", "UDZO", output], timeout: 900)
226
+ raise ArgumentError, "hdiutil did not produce a DMG artifact" unless File.file?(output)
227
+
228
+ output
229
+ end
230
+
231
+ def build_windows_setup(bundle, temporary)
232
+ output_name = File.basename(artifact_names.first, ".exe")
233
+ script = File.join(temporary, "installer.iss")
234
+ File.write(script, windows_inno_script(bundle, temporary, output_name))
235
+ iscc = command!("ISCC.exe", "iscc", hint: "install Inno Setup 6 and add ISCC.exe to PATH")
236
+ run!([iscc, script], timeout: 900)
237
+ output = File.join(temporary, "#{output_name}.exe")
238
+ raise ArgumentError, "Inno Setup did not produce a setup executable" unless File.file?(output)
239
+
240
+ output
241
+ end
242
+
243
+ def windows_inno_script(bundle, output, output_name)
244
+ architectures = platform.arch == :arm64 ? "arm64" : "x64compatible"
245
+ <<~ISS
246
+ [Setup]
247
+ AppId=#{inno(config.identifier)}
248
+ AppName=#{inno(config.name)}
249
+ AppVersion=#{inno(config.version)}
250
+ AppPublisher=#{inno(config.publisher)}
251
+ AppPublisherURL=#{inno(config.homepage || "")}
252
+ DefaultDirName={autopf}\\#{inno(config.name)}
253
+ DefaultGroupName=#{inno(config.name)}
254
+ OutputDir=#{inno(output)}
255
+ OutputBaseFilename=#{inno(output_name)}
256
+ SetupIconFile=#{inno(config.icon_path(@project, platform))}
257
+ UninstallDisplayIcon={app}\\app.ico
258
+ Compression=lzma2
259
+ SolidCompression=yes
260
+ WizardStyle=modern
261
+ ArchitecturesAllowed=#{architectures}
262
+ ArchitecturesInstallIn64BitMode=#{architectures}
263
+
264
+ [Files]
265
+ Source: "#{inno(File.join(bundle, "*"))}"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
266
+
267
+ [Icons]
268
+ Name: "{autoprograms}\\#{inno(config.name)}"; Filename: "{app}\\run.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\\app.ico"
269
+ Name: "{autodesktop}\\#{inno(config.name)}"; Filename: "{app}\\run.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\\app.ico"
270
+ ISS
271
+ end
272
+
273
+ def gzip_tar(output, root, entries)
274
+ tar_path = "#{output}.tar-#{Process.pid}"
275
+ File.open(tar_path, "wb") do |file|
276
+ Gem::Package::TarWriter.new(file) do |tar|
277
+ entries.each { |entry| add_tar_entry(tar, root, File.join(root, entry)) }
278
+ end
279
+ end
280
+ Zlib::GzipWriter.open(output) do |gzip|
281
+ File.open(tar_path, "rb") { |tar| IO.copy_stream(tar, gzip) }
282
+ end
283
+ ensure
284
+ FileUtils.rm_f(tar_path) if tar_path
285
+ end
286
+
287
+ def add_tar_entry(tar, root, path)
288
+ relative = path.delete_prefix("#{root}#{File::SEPARATOR}").tr(File::SEPARATOR, "/")
289
+ name = "./#{relative}"
290
+ stat = File.lstat(path)
291
+ if stat.directory?
292
+ tar.mkdir(name, stat.mode & 0o777)
293
+ Dir.children(path).sort.each { |child| add_tar_entry(tar, root, File.join(path, child)) }
294
+ elsif stat.symlink?
295
+ tar.add_symlink(name, File.readlink(path), stat.mode & 0o777)
296
+ elsif stat.file?
297
+ tar.add_file(name, stat.mode & 0o777) do |target|
298
+ File.open(path, "rb") { |source| IO.copy_stream(source, target) }
299
+ end
300
+ else
301
+ raise ArgumentError, "unsupported file in distribution payload: #{path}"
302
+ end
303
+ end
304
+
305
+ def write_ar(output, files)
306
+ File.open(output, "wb", 0o644) do |archive|
307
+ archive.write("!<arch>\n")
308
+ files.each do |path|
309
+ size = File.size(path)
310
+ name = "#{File.basename(path)}/"
311
+ header = format("%-16s%-12d%-6d%-6d%-8s%-10d`\n",
312
+ name, 0, 0, 0, "100644", size)
313
+ raise ArgumentError, "invalid Debian archive header" unless header.bytesize == 60
314
+
315
+ archive.write(header)
316
+ File.open(path, "rb") { |file| IO.copy_stream(file, archive) }
317
+ archive.write("\n") if size.odd?
318
+ end
319
+ end
320
+ end
321
+
322
+ def command!(*names, hint:)
323
+ candidates = names.dup
324
+ if platform.windows?
325
+ extensions = @environment.fetch("PATHEXT", ".EXE;.CMD;.BAT").split(";")
326
+ candidates += names.flat_map { |name| extensions.map { |extension| "#{name}#{extension}" } }
327
+ end
328
+ @environment.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
329
+ candidates.each do |name|
330
+ path = File.join(directory, name)
331
+ return path if File.file?(path) && File.executable?(path)
332
+ end
333
+ end
334
+ if platform.windows?
335
+ [@environment["ProgramFiles(x86)"], @environment["ProgramFiles"]].compact.each do |root|
336
+ path = File.join(root, "Inno Setup 6", "ISCC.exe")
337
+ return path if File.file?(path)
338
+ end
339
+ end
340
+ raise ArgumentError, "distribution packaging requires #{names.join(' or ')}; #{hint}"
341
+ end
342
+
343
+ def run!(arguments, timeout:)
344
+ result = Command.run(arguments, timeout:, max_output_bytes: 8_000_000)
345
+ return result if result.success?
346
+
347
+ details = [result.stdout, result.stderr].reject(&:empty?).join("\n").strip
348
+ message = "distribution command failed (#{File.basename(arguments.first)}), exit #{result.exitstatus}"
349
+ raise ArgumentError, details.empty? ? message : "#{message}:\n#{details}"
350
+ rescue CommandTimeout, CommandOutputLimit => error
351
+ raise ArgumentError, error.message
352
+ end
353
+
354
+ def safe_filename(value) = value.gsub(/[\\\/:*?"<>|]/, "-")
355
+ def rpm_escape(value) = value.to_s.gsub("%", "%%")
356
+ def inno(value) = value.to_s.gsub("{", "{{").gsub('"', '""')
357
+ def deb_architecture = platform.arch == :arm64 ? "arm64" : "amd64"
358
+ def rpm_architecture = platform.arch == :arm64 ? "aarch64" : "x86_64"
359
+ def tree_bytes(root) = Dir[File.join(root, "**", "*")].sum { |path| File.file?(path) ? File.size(path) : 0 }
360
+ end
361
+ end