zui 0.0.6 → 0.0.8

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