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.
- checksums.yaml +4 -4
- data/ControlNode.qml +2 -909
- data/Desktop.qml +3 -0
- data/README.md +383 -66
- data/Service.qml +14 -2
- data/lib/zui/cli.rb +34 -2
- data/lib/zui/dist_config.rb +180 -0
- data/lib/zui/dist_packager.rb +361 -0
- data/lib/zui/distribution.rb +74 -12
- data/lib/zui/generator.rb +38 -0
- data/lib/zui/tree_shaker.rb +527 -0
- data/lib/zui.rb +4 -1
- metadata +4 -1
|
@@ -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
|