zui 0.0.1 → 0.0.3
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/Components/Builtins/Accordion.qml +1 -1
- data/Components/Builtins/ActionButton.qml +1 -3
- data/Components/Builtins/AlertDialog.qml +2 -1
- data/Components/Builtins/AnimatedImage.qml +0 -2
- data/Components/Builtins/AreaChart.qml +0 -2
- data/Components/Builtins/AspectRatio.qml +0 -2
- data/Components/Builtins/Avatar.qml +0 -2
- data/Components/Builtins/Badge.qml +0 -2
- data/Components/Builtins/Banner.qml +2 -2
- data/Components/Builtins/BarChart.qml +0 -2
- data/Components/Builtins/BarIconButton.qml +1 -3
- data/Components/Builtins/BarIndicator.qml +3 -3
- data/Components/Builtins/BorderImage.qml +0 -2
- data/Components/Builtins/BorderOverlay.qml +0 -2
- data/Components/Builtins/Breadcrumb.qml +2 -2
- data/Components/Builtins/Button.qml +1 -2
- data/Components/Builtins/ButtonGroup.qml +0 -2
- data/Components/Builtins/Card.qml +0 -2
- data/Components/Builtins/Center.qml +0 -2
- data/Components/Builtins/CheckDelegate.qml +2 -1
- data/Components/Builtins/Checkbox.qml +1 -3
- data/Components/Builtins/Chip.qml +2 -4
- data/Components/Builtins/Column.qml +0 -2
- data/Components/Builtins/ColumnLayout.qml +0 -2
- data/Components/Builtins/ConfirmDialog.qml +0 -2
- data/Components/Builtins/ConstrainedBox.qml +0 -2
- data/Components/Builtins/Container.qml +0 -2
- data/Components/Builtins/CursorSurface.qml +0 -2
- data/Components/Builtins/DelayButton.qml +0 -2
- data/Components/Builtins/Dial.qml +0 -2
- data/Components/Builtins/Divider.qml +0 -2
- data/Components/Builtins/Dropdown.qml +0 -2
- data/Components/Builtins/ExpansionPanel.qml +1 -1
- data/Components/Builtins/FittedBox.qml +0 -2
- data/Components/Builtins/Flickable.qml +0 -2
- data/Components/Builtins/Flipable.qml +0 -2
- data/Components/Builtins/Flow.qml +0 -2
- data/Components/Builtins/FocusScope.qml +0 -2
- data/Components/Builtins/FontLoader.qml +0 -2
- data/Components/Builtins/Grid.qml +0 -2
- data/Components/Builtins/GridLayout.qml +0 -2
- data/Components/Builtins/GridView.qml +1 -1
- data/Components/Builtins/Icon.qml +1 -3
- data/Components/Builtins/Image.qml +0 -2
- data/Components/Builtins/ItemDelegate.qml +2 -2
- data/Components/Builtins/KeyCatcher.qml +0 -2
- data/Components/Builtins/Label.qml +0 -2
- data/Components/Builtins/LineChart.qml +0 -2
- data/Components/Builtins/ListView.qml +1 -3
- data/Components/Builtins/Loader.qml +0 -2
- data/Components/Builtins/Markdown.qml +0 -2
- data/Components/Builtins/ModalSheet.qml +1 -1
- data/Components/Builtins/MultiSelect.qml +0 -2
- data/Components/Builtins/NavigationRail.qml +1 -1
- data/Components/Builtins/NumberField.qml +0 -2
- data/Components/Builtins/OpticalGlyph.qml +1 -3
- data/Components/Builtins/PanelHero.qml +0 -2
- data/Components/Builtins/PasswordField.qml +1 -3
- data/Components/Builtins/Progress.qml +0 -2
- data/Components/Builtins/RadioButton.qml +0 -2
- data/Components/Builtins/RadioGroup.qml +0 -2
- data/Components/Builtins/RangeSlider.qml +0 -2
- data/Components/Builtins/Rectangle.qml +0 -2
- data/Components/Builtins/RichText.qml +0 -2
- data/Components/Builtins/RoundButton.qml +3 -3
- data/Components/Builtins/Row.qml +0 -2
- data/Components/Builtins/RowLayout.qml +0 -2
- data/Components/Builtins/ScreenCapture.qml +1 -1
- data/Components/Builtins/Scroll.qml +0 -2
- data/Components/Builtins/SearchableDropdown.qml +0 -2
- data/Components/Builtins/SectionHeader.qml +0 -2
- data/Components/Builtins/SelectableText.qml +0 -2
- data/Components/Builtins/Separator.qml +0 -2
- data/Components/Builtins/Slider.qml +0 -2
- data/Components/Builtins/Spacer.qml +0 -2
- data/Components/Builtins/SpinBox.qml +0 -2
- data/Components/Builtins/SplitView.qml +0 -2
- data/Components/Builtins/Stack.qml +0 -2
- data/Components/Builtins/StackLayout.qml +0 -2
- data/Components/Builtins/SwipeDelegate.qml +3 -3
- data/Components/Builtins/TabButton.qml +1 -1
- data/Components/Builtins/Text.qml +0 -2
- data/Components/Builtins/TextArea.qml +0 -2
- data/Components/Builtins/TextField.qml +0 -2
- data/Components/Builtins/TextMetrics.qml +0 -2
- data/Components/Builtins/Toast.qml +1 -1
- data/Components/Builtins/Toggle.qml +0 -2
- data/Components/Builtins/ToggleSwitch.qml +0 -2
- data/Components/Builtins/ToolButton.qml +3 -3
- data/Components/Builtins/Tooltip.qml +0 -2
- data/Components/Builtins/VectorImage.qml +1 -2
- data/Components/Builtins/Video.qml +1 -2
- data/Components/Builtins/WidgetButton.qml +0 -2
- data/Components/Builtins/Wrap.qml +0 -2
- data/ControlNode.qml +10 -22
- data/Controls/BarIconButton.qml +1 -1
- data/Controls/BarIndicator.qml +1 -1
- data/Controls/Button.qml +2 -1
- data/Controls/OpticalGlyph.qml +1 -1
- data/Desktop.qml +7 -3
- data/Fonts/FontAwesome-Brands.otf +0 -0
- data/Fonts/FontAwesome-Solid.otf +0 -0
- data/Fonts/LICENSE-FontAwesome.txt +165 -0
- data/Fonts/LICENSE-RobotoMono.txt +93 -0
- data/Fonts/RobotoMono-Bold.otf +0 -0
- data/Fonts/RobotoMono-Regular.otf +0 -0
- data/README.md +27 -14
- data/Service.qml +30 -6
- data/THIRD_PARTY_NOTICES.md +14 -0
- data/Theme/Fonts.qml +32 -0
- data/Theme/Style.qml +1 -1
- data/Theme/qmldir +1 -0
- data/lib/zui/application.rb +28 -3
- data/lib/zui/builder.rb +0 -1
- data/lib/zui/cli.rb +36 -6
- data/lib/zui/client.rb +349 -0
- data/lib/zui/components.rb +0 -1
- data/lib/zui/distribution.rb +53 -16
- data/lib/zui/generator.rb +1 -0
- data/lib/zui/host.rb +16 -94
- data/lib/zui/runner.rb +1 -1
- data/lib/zui/runtime.rb +1 -1
- data/lib/zui.rb +2 -1
- metadata +13 -13
- data/Components/Builtins/WebView.qml +0 -57
- data/native/CMakeLists.txt +0 -45
- data/native/ZuiClipboard.cpp +0 -16
- data/native/ZuiClipboard.h +0 -16
- data/native/ZuiProcess.cpp +0 -84
- data/native/ZuiProcess.h +0 -34
- data/native/main.cpp +0 -64
- data/vendor/host/linux-x86_64/PROVENANCE.md +0 -15
- data/vendor/host/linux-x86_64/zui-host +0 -0
- data/vendor/host/linux-x86_64/zui-host.sha256 +0 -1
data/lib/zui/client.rb
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "timeout"
|
|
10
|
+
require "uri"
|
|
11
|
+
require "zlib"
|
|
12
|
+
require "rubygems/package"
|
|
13
|
+
|
|
14
|
+
module Zui
|
|
15
|
+
class Client
|
|
16
|
+
FORMAT = 1
|
|
17
|
+
MAX_ARCHIVE_BYTES = 1_073_741_824
|
|
18
|
+
MAX_EXPANDED_BYTES = 2_147_483_648
|
|
19
|
+
MAX_ENTRIES = 100_000
|
|
20
|
+
REDIRECT_LIMIT = 5
|
|
21
|
+
|
|
22
|
+
attr_reader :platform, :version
|
|
23
|
+
|
|
24
|
+
def initialize(platform: Platform.current, version: VERSION, environment: ENV,
|
|
25
|
+
cache_root: nil, release_base_url: nil, downloader: nil)
|
|
26
|
+
@platform = platform.assert_supported!
|
|
27
|
+
@version = version.to_s
|
|
28
|
+
@environment = environment
|
|
29
|
+
@cache_root = cache_root
|
|
30
|
+
@release_base_url = release_base_url
|
|
31
|
+
@downloader = downloader || method(:download)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def root
|
|
35
|
+
override = @environment["ZUI_CLIENT_ROOT"]
|
|
36
|
+
return File.expand_path(override) if override && !override.empty?
|
|
37
|
+
|
|
38
|
+
File.join(cache_root, "zui", "clients", version, platform.id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configured?
|
|
42
|
+
validate!(root)
|
|
43
|
+
true
|
|
44
|
+
rescue ArgumentError, Errno::ENOENT
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configure!
|
|
49
|
+
return root if configured?
|
|
50
|
+
raise ArgumentError, "ZUI_CLIENT_ROOT is not a valid configured client: #{root}" if client_root_override?
|
|
51
|
+
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(root))
|
|
53
|
+
File.open("#{root}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
54
|
+
lock.flock(File::LOCK_EX)
|
|
55
|
+
return root if configured?
|
|
56
|
+
|
|
57
|
+
install_download!
|
|
58
|
+
end
|
|
59
|
+
root
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def executable
|
|
63
|
+
manifest = validate!(root)
|
|
64
|
+
File.join(root, manifest.fetch("executable"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def executable_relative_path
|
|
68
|
+
validate!(root).fetch("executable")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def environment_entries
|
|
72
|
+
manifest = validate!(root)
|
|
73
|
+
manifest.fetch("environment", {}).transform_values do |paths|
|
|
74
|
+
Array(paths).map { |path| safe_manifest_path!(path, "client environment path") }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def environment(base = ENV.to_h, client_root: root)
|
|
79
|
+
environment_entries.each_with_object(base.to_h.dup) do |(name, paths), result|
|
|
80
|
+
unless name.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
81
|
+
raise ArgumentError, "invalid environment variable in Zui client: #{name.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
resolved = paths.map { |path| File.join(client_root, path) }
|
|
85
|
+
previous = result[name]
|
|
86
|
+
resolved << previous unless previous.nil? || previous.empty?
|
|
87
|
+
result[name] = resolved.join(File::PATH_SEPARATOR)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def copy_to(destination)
|
|
92
|
+
manifest = validate!(root)
|
|
93
|
+
raise ArgumentError, "client destination already exists: #{destination}" if File.exist?(destination)
|
|
94
|
+
|
|
95
|
+
created = true
|
|
96
|
+
FileUtils.mkdir_p(destination)
|
|
97
|
+
FileUtils.cp_r(Dir.children(root).map { |entry| File.join(root, entry) }, destination)
|
|
98
|
+
validate!(destination)
|
|
99
|
+
manifest
|
|
100
|
+
rescue StandardError
|
|
101
|
+
FileUtils.remove_entry(destination) if created && destination && File.exist?(destination)
|
|
102
|
+
raise
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def manifest
|
|
106
|
+
validate!(root)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def archive_name = "zui-client-#{platform.id}.tar.gz"
|
|
110
|
+
|
|
111
|
+
def archive_url
|
|
112
|
+
override = @environment["ZUI_CLIENT_ARCHIVE"]
|
|
113
|
+
return File.expand_path(override) if override && !override.empty?
|
|
114
|
+
|
|
115
|
+
URI.join("#{release_base_url}/", archive_name)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def checksum_url
|
|
119
|
+
override = @environment["ZUI_CLIENT_CHECKSUM"]
|
|
120
|
+
return File.expand_path(override) if override && !override.empty?
|
|
121
|
+
|
|
122
|
+
source = archive_url
|
|
123
|
+
source.is_a?(URI) ? URI("#{source}.sha256") : "#{source}.sha256"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def install_download!
|
|
129
|
+
Dir.mktmpdir(".zui-client-", File.dirname(root)) do |temporary|
|
|
130
|
+
archive = File.join(temporary, archive_name)
|
|
131
|
+
checksum = "#{archive}.sha256"
|
|
132
|
+
@downloader.call(archive_url, archive)
|
|
133
|
+
@downloader.call(checksum_url, checksum)
|
|
134
|
+
verify_checksum!(archive, checksum)
|
|
135
|
+
|
|
136
|
+
staged = File.join(temporary, "client")
|
|
137
|
+
FileUtils.mkdir_p(staged)
|
|
138
|
+
extract!(archive, staged)
|
|
139
|
+
validate!(staged)
|
|
140
|
+
|
|
141
|
+
installed = "#{root}.install-#{Process.pid}-#{rand(1_000_000)}"
|
|
142
|
+
File.rename(staged, installed)
|
|
143
|
+
if File.exist?(root)
|
|
144
|
+
invalid = "#{root}.invalid-#{Time.now.to_i}-#{Process.pid}"
|
|
145
|
+
File.rename(root, invalid)
|
|
146
|
+
end
|
|
147
|
+
File.rename(installed, root)
|
|
148
|
+
ensure
|
|
149
|
+
FileUtils.remove_entry(installed) if installed && File.exist?(installed)
|
|
150
|
+
end
|
|
151
|
+
validate!(root)
|
|
152
|
+
root
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate!(directory)
|
|
156
|
+
manifest_path = File.join(directory, "client.json")
|
|
157
|
+
raise ArgumentError, "Zui client manifest not found: #{manifest_path}" unless File.file?(manifest_path)
|
|
158
|
+
raise ArgumentError, "Zui client manifest is too large" if File.size(manifest_path) > 65_536
|
|
159
|
+
|
|
160
|
+
manifest = JSON.parse(File.read(manifest_path))
|
|
161
|
+
raise ArgumentError, "invalid Zui client manifest root" unless manifest.is_a?(Hash)
|
|
162
|
+
expected = {
|
|
163
|
+
"format" => FORMAT,
|
|
164
|
+
"framework" => "zui",
|
|
165
|
+
"client_version" => version,
|
|
166
|
+
"platform" => platform.id
|
|
167
|
+
}
|
|
168
|
+
expected.each do |key, value|
|
|
169
|
+
actual = manifest[key]
|
|
170
|
+
raise ArgumentError, "invalid Zui client #{key}: expected #{value.inspect}, got #{actual.inspect}" unless actual == value
|
|
171
|
+
end
|
|
172
|
+
raise ArgumentError, "Zui client is not bundle capable" unless manifest["bundle_capable"] == true
|
|
173
|
+
unless manifest["payload"] == %w[native-host qt-engine]
|
|
174
|
+
raise ArgumentError, "invalid Zui client payload: expected native host and Qt engine only"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
required_paths = manifest["required_paths"]
|
|
178
|
+
unless required_paths.is_a?(Array) && !required_paths.empty? &&
|
|
179
|
+
required_paths.all? { |path| path.is_a?(String) }
|
|
180
|
+
raise ArgumentError, "invalid Zui client required paths"
|
|
181
|
+
end
|
|
182
|
+
required_paths.each do |path|
|
|
183
|
+
relative_path = safe_manifest_path!(path, "client required path")
|
|
184
|
+
resolved_path = File.join(directory, relative_path)
|
|
185
|
+
raise ArgumentError, "Zui client required path is missing: #{resolved_path}" unless File.exist?(resolved_path)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
relative = safe_manifest_path!(manifest["executable"], "client executable")
|
|
189
|
+
executable = File.join(directory, relative)
|
|
190
|
+
unless File.file?(executable) && executable_for_target?(executable)
|
|
191
|
+
raise ArgumentError, "Zui client executable is missing or not executable: #{executable}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
client_environment = manifest.fetch("environment", {})
|
|
195
|
+
raise ArgumentError, "invalid Zui client environment" unless client_environment.is_a?(Hash)
|
|
196
|
+
client_environment.each do |name, paths|
|
|
197
|
+
unless name.is_a?(String) && name.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
198
|
+
raise ArgumentError, "invalid client environment entry: #{name.inspect}"
|
|
199
|
+
end
|
|
200
|
+
unless paths.is_a?(Array) && !paths.empty? && paths.all? { |path| path.is_a?(String) }
|
|
201
|
+
raise ArgumentError, "invalid client environment paths for #{name}"
|
|
202
|
+
end
|
|
203
|
+
paths.each do |path|
|
|
204
|
+
relative_path = safe_manifest_path!(path, "client environment path")
|
|
205
|
+
resolved_path = File.join(directory, relative_path)
|
|
206
|
+
raise ArgumentError, "Zui client environment path is missing: #{resolved_path}" unless File.exist?(resolved_path)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
manifest
|
|
210
|
+
rescue JSON::ParserError => error
|
|
211
|
+
raise ArgumentError, "invalid Zui client manifest: #{error.message}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def executable_for_target?(path)
|
|
215
|
+
# Windows cannot represent the POSIX executable bit when validating a
|
|
216
|
+
# Linux or macOS archive. Native POSIX clients still require that bit.
|
|
217
|
+
platform.windows? || Gem.win_platform? || File.executable?(path)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def verify_checksum!(archive, checksum_file)
|
|
221
|
+
expected = File.read(checksum_file, 4096)[/\A\s*([0-9a-fA-F]{64})(?:\s|\z)/, 1]
|
|
222
|
+
raise ArgumentError, "invalid Zui client checksum file" unless expected
|
|
223
|
+
|
|
224
|
+
actual = Digest::SHA256.file(archive).hexdigest
|
|
225
|
+
raise ArgumentError, "Zui client checksum mismatch" unless actual.casecmp?(expected)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def extract!(archive, destination)
|
|
229
|
+
count = 0
|
|
230
|
+
expanded = 0
|
|
231
|
+
Zlib::GzipReader.open(archive) do |gzip|
|
|
232
|
+
Gem::Package::TarReader.new(gzip) do |tar|
|
|
233
|
+
tar.each do |entry|
|
|
234
|
+
count += 1
|
|
235
|
+
raise ArgumentError, "Zui client archive contains too many files" if count > MAX_ENTRIES
|
|
236
|
+
|
|
237
|
+
relative = safe_relative_path!(entry.full_name, "archive entry")
|
|
238
|
+
target = File.join(destination, relative)
|
|
239
|
+
if entry.directory?
|
|
240
|
+
FileUtils.mkdir_p(target)
|
|
241
|
+
elsif entry.file?
|
|
242
|
+
expanded += entry.header.size
|
|
243
|
+
raise ArgumentError, "Zui client archive expands beyond the safety limit" if expanded > MAX_EXPANDED_BYTES
|
|
244
|
+
|
|
245
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
246
|
+
File.open(target, "wb", entry.header.mode & 0o111 == 0 ? 0o644 : 0o755) do |file|
|
|
247
|
+
IO.copy_stream(entry, file)
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
raise ArgumentError, "Zui client archive contains an unsupported link or special file: #{entry.full_name}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
rescue Zlib::GzipFile::Error, Gem::Package::TarInvalidError => error
|
|
256
|
+
raise ArgumentError, "invalid Zui client archive: #{error.message}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def download(source, destination, redirects = REDIRECT_LIMIT)
|
|
260
|
+
if source.is_a?(String) && File.file?(source)
|
|
261
|
+
FileUtils.cp(source, destination)
|
|
262
|
+
return destination
|
|
263
|
+
end
|
|
264
|
+
uri = source.is_a?(URI) ? source : URI(source.to_s)
|
|
265
|
+
if uri.scheme == "file"
|
|
266
|
+
FileUtils.cp(URI::DEFAULT_PARSER.unescape(uri.path), destination)
|
|
267
|
+
return destination
|
|
268
|
+
end
|
|
269
|
+
raise ArgumentError, "Zui client download must use HTTPS" unless uri.scheme == "https"
|
|
270
|
+
raise ArgumentError, "too many Zui client download redirects" if redirects.negative?
|
|
271
|
+
|
|
272
|
+
http = Net::HTTP.new(uri.host, uri.port, :ENV)
|
|
273
|
+
http.use_ssl = true
|
|
274
|
+
http.open_timeout = 20
|
|
275
|
+
http.read_timeout = 120
|
|
276
|
+
http.write_timeout = 120 if http.respond_to?(:write_timeout=)
|
|
277
|
+
http.start do
|
|
278
|
+
http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
|
|
279
|
+
case response
|
|
280
|
+
when Net::HTTPSuccess
|
|
281
|
+
written = 0
|
|
282
|
+
File.open(destination, "wb") do |file|
|
|
283
|
+
response.read_body do |chunk|
|
|
284
|
+
written += chunk.bytesize
|
|
285
|
+
raise ArgumentError, "Zui client download exceeds the safety limit" if written > MAX_ARCHIVE_BYTES
|
|
286
|
+
file.write(chunk)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
when Net::HTTPRedirection
|
|
290
|
+
location = response["location"] || raise(ArgumentError, "Zui client redirect has no location")
|
|
291
|
+
return download(URI.join(uri, location), destination, redirects - 1)
|
|
292
|
+
else
|
|
293
|
+
raise ArgumentError, "Zui client download failed: HTTP #{response.code}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
destination
|
|
298
|
+
rescue URI::InvalidURIError => error
|
|
299
|
+
raise ArgumentError, "invalid Zui client URL: #{error.message}"
|
|
300
|
+
rescue Timeout::Error, SocketError, OpenSSL::SSL::SSLError => error
|
|
301
|
+
raise ArgumentError, "Zui client download failed: #{error.class}: #{error.message}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def safe_relative_path!(value, label)
|
|
305
|
+
path = value.to_s
|
|
306
|
+
if path.empty? || path.include?("\\") || path.match?(/[[:cntrl:]]/) || path.start_with?("/") ||
|
|
307
|
+
path.match?(/\A[A-Za-z]:/) ||
|
|
308
|
+
path.split("/").any? { |part| part.empty? || part == "." || part == ".." }
|
|
309
|
+
raise ArgumentError, "unsafe #{label}: #{value.inspect}"
|
|
310
|
+
end
|
|
311
|
+
path
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def safe_manifest_path!(value, label)
|
|
315
|
+
path = safe_relative_path!(value, label)
|
|
316
|
+
unless path.match?(/\A[A-Za-z0-9._+ -]+(?:\/[A-Za-z0-9._+ -]+)*\z/)
|
|
317
|
+
raise ArgumentError, "unsafe #{label}: #{value.inspect}"
|
|
318
|
+
end
|
|
319
|
+
path
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def release_base_url
|
|
323
|
+
@release_base_url || @environment["ZUI_CLIENT_BASE_URL"] ||
|
|
324
|
+
"https://github.com/AdamMusa/zui/releases/download/v#{version}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def cache_root
|
|
328
|
+
return File.expand_path(@cache_root) if @cache_root
|
|
329
|
+
|
|
330
|
+
override = @environment["ZUI_CACHE_HOME"]
|
|
331
|
+
return File.expand_path(override) if override && !override.empty?
|
|
332
|
+
|
|
333
|
+
home = @environment["HOME"] || @environment["USERPROFILE"] || Dir.home
|
|
334
|
+
if platform.windows?
|
|
335
|
+
@environment["LOCALAPPDATA"] || @environment["APPDATA"] || File.join(home, "AppData", "Local")
|
|
336
|
+
elsif platform.macos?
|
|
337
|
+
File.join(home, "Library", "Caches")
|
|
338
|
+
else
|
|
339
|
+
@environment["XDG_CACHE_HOME"] || File.join(home, ".cache")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def client_root_override?
|
|
344
|
+
value = @environment["ZUI_CLIENT_ROOT"]
|
|
345
|
+
value && !value.empty?
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
end
|
|
349
|
+
end
|
data/lib/zui/components.rb
CHANGED
|
@@ -50,7 +50,6 @@ module Zui
|
|
|
50
50
|
model_view_3d: [%i[source width height background rotation_x rotation_y rotation_z zoom minimum_zoom maximum_zoom interactive auto_rotate auto_rotate_speed reset_revision field_of_view camera_distance fit_size model_scale center_x center_y center_z key_light_color fill_light_color light_brightness antialiasing visible], %i[click double_click rotation_change zoom_change loaded error status], false],
|
|
51
51
|
font_loader: [%i[source visible], %i[loaded error status], false],
|
|
52
52
|
text_metrics: [%i[text font_family font_size bold italic letter_spacing word_spacing elide elide_width visible], %i[metrics], false],
|
|
53
|
-
web_view: [%i[url html base_url width height zoom_factor background_color active_focus_on_press javascript_enabled local_storage_enabled auto_load_images full_screen_support command command_revision visible], %i[load_start load_progress load_success load_failure url_change title_change navigation full_screen permission new_window error], false],
|
|
54
53
|
animated_image: [%i[source width height fill_mode playing paused speed asynchronous cache mirror smooth visible], %i[frame loaded error status], false],
|
|
55
54
|
video: [%i[source width height auto_play loops volume muted playback_rate fill_mode orientation mirrored visible], %i[play pause stop error position duration], false],
|
|
56
55
|
audio: [%i[source auto_play playback position seek_revision loops volume muted playback_rate visible], %i[play pause stop end loaded status error position duration], false],
|
data/lib/zui/distribution.rb
CHANGED
|
@@ -7,20 +7,25 @@ module Zui
|
|
|
7
7
|
class Distribution
|
|
8
8
|
attr_reader :platform
|
|
9
9
|
|
|
10
|
-
def initialize(
|
|
11
|
-
@host = host
|
|
10
|
+
def initialize(client: nil, platform: Platform.current, framework_root: FRAMEWORK_ROOT)
|
|
12
11
|
@platform = platform.assert_supported!
|
|
12
|
+
@client = client || Client.new(platform: @platform)
|
|
13
13
|
@framework_root = framework_root
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def bundle(source, name: nil, destination: nil)
|
|
17
|
+
created = false
|
|
17
18
|
project = File.expand_path(source)
|
|
18
19
|
raise ArgumentError, "project directory not found: #{project}" unless File.directory?(project)
|
|
19
20
|
raise ArgumentError, "main.rb not found: #{project}" unless File.file?(File.join(project, "main.rb"))
|
|
21
|
+
unless @client.configured?
|
|
22
|
+
raise ArgumentError, "Zui is not configured for #{platform.id}; run `zui doctor --fix` before bundling"
|
|
23
|
+
end
|
|
20
24
|
app_name = name || titleize(File.basename(project))
|
|
21
25
|
destination ||= default_destination(project, app_name)
|
|
22
26
|
destination = File.expand_path(destination)
|
|
23
27
|
raise ArgumentError, "bundle destination already exists: #{destination}" if File.exist?(destination)
|
|
28
|
+
created = true
|
|
24
29
|
|
|
25
30
|
case platform.os
|
|
26
31
|
when :linux then bundle_linux(project, destination, app_name)
|
|
@@ -29,19 +34,18 @@ module Zui
|
|
|
29
34
|
end
|
|
30
35
|
destination
|
|
31
36
|
rescue StandardError
|
|
32
|
-
FileUtils.remove_entry(destination) if destination && File.exist?(destination)
|
|
37
|
+
FileUtils.remove_entry(destination) if created && destination && File.exist?(destination)
|
|
33
38
|
raise
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
private
|
|
37
42
|
|
|
38
43
|
def bundle_linux(project, destination, app_name)
|
|
39
|
-
FileUtils.mkdir_p([File.join(destination, "app"),
|
|
44
|
+
FileUtils.mkdir_p([File.join(destination, "app"),
|
|
40
45
|
File.join(destination, "runtime"), File.join(destination, "share", "applications")])
|
|
41
46
|
install_application(project, File.join(destination, "app"))
|
|
42
47
|
install_runtime(File.join(destination, "runtime"))
|
|
43
|
-
|
|
44
|
-
FileUtils.chmod(0o755, File.join(destination, "bin", "zui-host"))
|
|
48
|
+
@client.copy_to(File.join(destination, "runtime", "native"))
|
|
45
49
|
write_linux_launcher(destination, app_name)
|
|
46
50
|
desktop_name = "#{slug(app_name)}.desktop"
|
|
47
51
|
File.write(File.join(destination, "share", "applications", desktop_name), <<~DESKTOP)
|
|
@@ -62,8 +66,7 @@ module Zui
|
|
|
62
66
|
FileUtils.mkdir_p([macos, File.join(resources, "app"), File.join(resources, "runtime")])
|
|
63
67
|
install_application(project, File.join(resources, "app"))
|
|
64
68
|
install_runtime(File.join(resources, "runtime"))
|
|
65
|
-
|
|
66
|
-
FileUtils.chmod(0o755, File.join(macos, "zui-host"))
|
|
69
|
+
@client.copy_to(File.join(resources, "runtime", "native"))
|
|
67
70
|
File.write(File.join(macos, "run"), macos_launcher(app_name))
|
|
68
71
|
FileUtils.chmod(0o755, File.join(macos, "run"))
|
|
69
72
|
File.write(File.join(contents, "Info.plist"), info_plist(app_name))
|
|
@@ -71,11 +74,11 @@ module Zui
|
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def bundle_windows(project, destination, app_name)
|
|
74
|
-
FileUtils.mkdir_p([File.join(destination, "app"),
|
|
77
|
+
FileUtils.mkdir_p([File.join(destination, "app"),
|
|
75
78
|
File.join(destination, "runtime")])
|
|
76
79
|
install_application(project, File.join(destination, "app"))
|
|
77
80
|
install_runtime(File.join(destination, "runtime"))
|
|
78
|
-
|
|
81
|
+
@client.copy_to(File.join(destination, "runtime", "native"))
|
|
79
82
|
File.write(File.join(destination, "run.rb"), windows_ruby_launcher(app_name))
|
|
80
83
|
File.write(File.join(destination, "run.cmd"), windows_command_launcher)
|
|
81
84
|
write_manifest(destination, app_name)
|
|
@@ -93,7 +96,11 @@ module Zui
|
|
|
93
96
|
Runtime.install_qml(File.join(destination, "qml"), framework_root: @framework_root)
|
|
94
97
|
FileUtils.mkdir_p(File.join(destination, "lib"))
|
|
95
98
|
FileUtils.cp(File.join(@framework_root, "lib", "zui.rb"), File.join(destination, "lib", "zui.rb"))
|
|
96
|
-
|
|
99
|
+
source = File.join(@framework_root, "lib", "zui")
|
|
100
|
+
target = File.join(destination, "lib", "zui")
|
|
101
|
+
FileUtils.mkdir_p(target)
|
|
102
|
+
entries = Dir.children(source).reject { |entry| %w[client_builder.rb client_packager.rb].include?(entry) }
|
|
103
|
+
FileUtils.cp_r(entries.map { |entry| File.join(source, entry) }, target) unless entries.empty?
|
|
97
104
|
end
|
|
98
105
|
|
|
99
106
|
def write_linux_launcher(destination, app_name)
|
|
@@ -101,8 +108,10 @@ module Zui
|
|
|
101
108
|
#!/bin/sh
|
|
102
109
|
set -eu
|
|
103
110
|
bundle_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
111
|
+
native_dir="$bundle_dir/runtime/native"
|
|
112
|
+
#{posix_client_environment}
|
|
104
113
|
ruby_command=${ZUI_RUBY:-ruby}
|
|
105
|
-
exec "$
|
|
114
|
+
exec "$native_dir/#{@client.executable_relative_path}" \
|
|
106
115
|
--qml-root "$bundle_dir/runtime/qml" \
|
|
107
116
|
--project "$bundle_dir/app" \
|
|
108
117
|
--program "$bundle_dir/app/main.rb" \
|
|
@@ -119,8 +128,10 @@ module Zui
|
|
|
119
128
|
set -eu
|
|
120
129
|
contents=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
121
130
|
resources="$contents/Resources"
|
|
131
|
+
native_dir="$resources/runtime/native"
|
|
132
|
+
#{posix_client_environment}
|
|
122
133
|
ruby_command=${ZUI_RUBY:-ruby}
|
|
123
|
-
exec "$
|
|
134
|
+
exec "$native_dir/#{@client.executable_relative_path}" \
|
|
124
135
|
--qml-root "$resources/runtime/qml" \
|
|
125
136
|
--project "$resources/app" \
|
|
126
137
|
--program "$resources/app/main.rb" \
|
|
@@ -137,8 +148,11 @@ module Zui
|
|
|
137
148
|
require "rbconfig"
|
|
138
149
|
|
|
139
150
|
bundle_dir = File.expand_path(__dir__)
|
|
151
|
+
native_dir = File.join(bundle_dir, "runtime", "native")
|
|
152
|
+
environment = {}
|
|
153
|
+
#{windows_environment_builder}
|
|
140
154
|
arguments = [
|
|
141
|
-
File.join(
|
|
155
|
+
File.join(native_dir, #{@client.executable_relative_path.dump}),
|
|
142
156
|
"--qml-root", File.join(bundle_dir, "runtime", "qml"),
|
|
143
157
|
"--project", File.join(bundle_dir, "app"),
|
|
144
158
|
"--program", File.join(bundle_dir, "app", "main.rb"),
|
|
@@ -146,7 +160,7 @@ module Zui
|
|
|
146
160
|
"--load-path", File.join(bundle_dir, "runtime", "lib"),
|
|
147
161
|
"--name", #{app_name.to_s.dump}
|
|
148
162
|
]
|
|
149
|
-
exec(*arguments)
|
|
163
|
+
exec(environment, *arguments)
|
|
150
164
|
RUBY
|
|
151
165
|
end
|
|
152
166
|
|
|
@@ -183,7 +197,7 @@ module Zui
|
|
|
183
197
|
File.write(File.join(directory, "zui-bundle.json"), JSON.pretty_generate(
|
|
184
198
|
"format" => 1, "framework" => "zui", "version" => VERSION,
|
|
185
199
|
"platform" => platform.os.to_s, "architecture" => platform.arch.to_s,
|
|
186
|
-
"name" => app_name
|
|
200
|
+
"name" => app_name, "client_version" => @client.manifest.fetch("client_version")
|
|
187
201
|
))
|
|
188
202
|
end
|
|
189
203
|
|
|
@@ -196,5 +210,28 @@ module Zui
|
|
|
196
210
|
def slug(value) = value.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
197
211
|
def shell_quote(value) = "'#{value.to_s.gsub("'", %q('"'"'))}'"
|
|
198
212
|
def xml_escape(value) = value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
213
|
+
|
|
214
|
+
def posix_client_environment
|
|
215
|
+
@client.environment_entries.map do |name, paths|
|
|
216
|
+
value = paths.map { |path| "$native_dir/#{path}" }.join(":")
|
|
217
|
+
<<~SH.chomp
|
|
218
|
+
zui_environment="#{value}"
|
|
219
|
+
[ -z "${#{name}:-}" ] || zui_environment="$zui_environment:${#{name}}"
|
|
220
|
+
export #{name}="$zui_environment"
|
|
221
|
+
SH
|
|
222
|
+
end.join("\n")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def windows_client_environment = @client.environment_entries
|
|
226
|
+
|
|
227
|
+
def windows_environment_builder
|
|
228
|
+
@client.environment_entries.keys.map do |name|
|
|
229
|
+
<<~RUBY.chomp
|
|
230
|
+
values = #{windows_client_environment.fetch(name).inspect}.map { |path| File.join(native_dir, path) }
|
|
231
|
+
values << ENV[#{name.dump}] unless ENV[#{name.dump}].nil? || ENV[#{name.dump}].empty?
|
|
232
|
+
environment[#{name.dump}] = values.join(File::PATH_SEPARATOR)
|
|
233
|
+
RUBY
|
|
234
|
+
end.join("\n")
|
|
235
|
+
end
|
|
199
236
|
end
|
|
200
237
|
end
|
data/lib/zui/generator.rb
CHANGED
data/lib/zui/host.rb
CHANGED
|
@@ -1,123 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "tmpdir"
|
|
5
|
-
|
|
6
3
|
module Zui
|
|
7
4
|
class Host
|
|
8
|
-
attr_reader :platform
|
|
5
|
+
attr_reader :client, :platform
|
|
9
6
|
|
|
10
|
-
def initialize(platform: Platform.current,
|
|
7
|
+
def initialize(platform: Platform.current, environment: ENV, client: nil)
|
|
11
8
|
@platform = platform.assert_supported!
|
|
12
|
-
@framework_root = framework_root
|
|
13
9
|
@environment = environment
|
|
10
|
+
@client = client || Client.new(platform:, environment:)
|
|
14
11
|
end
|
|
15
12
|
|
|
16
|
-
def executable
|
|
13
|
+
def executable
|
|
17
14
|
override = @environment["ZUI_HOST"]
|
|
18
15
|
return checked_executable(override) if override && !override.empty?
|
|
19
|
-
return
|
|
20
|
-
return cached if File.executable?(cached)
|
|
21
|
-
return nil unless build
|
|
16
|
+
return client.executable if client.configured?
|
|
22
17
|
|
|
23
|
-
|
|
18
|
+
raise ArgumentError, "Zui is not configured for #{platform.id}; run `zui doctor --fix`"
|
|
24
19
|
end
|
|
25
20
|
|
|
26
|
-
def available?
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cmake = find_command("cmake")
|
|
30
|
-
raise ArgumentError, platform_help unless cmake
|
|
31
|
-
|
|
32
|
-
FileUtils.mkdir_p(File.dirname(destination))
|
|
33
|
-
Dir.mktmpdir("zui-host-build-") do |build_dir|
|
|
34
|
-
configure = Command.run([
|
|
35
|
-
cmake, "-S", File.join(@framework_root, "native"), "-B", build_dir,
|
|
36
|
-
"-DCMAKE_BUILD_TYPE=Release"
|
|
37
|
-
], timeout: 180, max_output_bytes: 4_194_304)
|
|
38
|
-
raise ArgumentError, "Zui host configuration failed:\n#{configure.stderr}" unless configure.success?
|
|
39
|
-
|
|
40
|
-
compile = Command.run([cmake, "--build", build_dir, "--config", "Release", "--parallel"],
|
|
41
|
-
timeout: 600, max_output_bytes: 8_388_608)
|
|
42
|
-
raise ArgumentError, "Zui host build failed:\n#{compile.stderr}" unless compile.success?
|
|
43
|
-
|
|
44
|
-
built = locate_build(build_dir)
|
|
45
|
-
raise ArgumentError, "Zui host build produced no executable" unless built
|
|
46
|
-
temporary = "#{destination}.install-#{Process.pid}"
|
|
47
|
-
FileUtils.cp(built, temporary)
|
|
48
|
-
FileUtils.chmod(0o755, temporary)
|
|
49
|
-
File.rename(temporary, destination)
|
|
50
|
-
ensure
|
|
51
|
-
FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
|
|
52
|
-
end
|
|
53
|
-
destination
|
|
54
|
-
end
|
|
21
|
+
def available?
|
|
22
|
+
override = @environment["ZUI_HOST"]
|
|
23
|
+
return File.executable?(File.expand_path(override)) if override && !override.empty?
|
|
55
24
|
|
|
56
|
-
|
|
57
|
-
if platform.macos?
|
|
58
|
-
"building Zui on macOS requires CMake and Qt 6 (for example: brew install cmake qt)"
|
|
59
|
-
elsif platform.windows?
|
|
60
|
-
"building Zui on Windows requires CMake, Qt 6, and a C++17 toolchain (Visual Studio Build Tools or LLVM/MinGW)"
|
|
61
|
-
else
|
|
62
|
-
"building Zui on Linux requires CMake, a C++17 compiler, and Qt 6 Core/Gui/Qml/Quick development packages"
|
|
63
|
-
end
|
|
25
|
+
client.configured?
|
|
64
26
|
end
|
|
65
27
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def bundled
|
|
69
|
-
File.join(@framework_root, "vendor", "host", platform.id, executable_name)
|
|
70
|
-
end
|
|
28
|
+
def configure! = client.configure!
|
|
71
29
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
File.join(cache_root, "zui", "host", VERSION, platform.id, executable_name)
|
|
76
|
-
end
|
|
30
|
+
def environment(base = ENV.to_h)
|
|
31
|
+
override = @environment["ZUI_HOST"]
|
|
32
|
+
return base.to_h.dup if override && !override.empty?
|
|
77
33
|
|
|
78
|
-
|
|
79
|
-
home = @environment["HOME"] || @environment["USERPROFILE"] || Dir.home
|
|
80
|
-
if platform.windows?
|
|
81
|
-
@environment["LOCALAPPDATA"] || @environment["APPDATA"] || File.join(home, "AppData", "Local")
|
|
82
|
-
elsif platform.macos?
|
|
83
|
-
File.join(home, "Library", "Caches")
|
|
84
|
-
else
|
|
85
|
-
File.join(home, ".cache")
|
|
86
|
-
end
|
|
34
|
+
client.environment(base)
|
|
87
35
|
end
|
|
88
36
|
|
|
89
|
-
|
|
37
|
+
private
|
|
90
38
|
|
|
91
39
|
def checked_executable(path)
|
|
92
40
|
expanded = File.expand_path(path)
|
|
93
41
|
raise ArgumentError, "ZUI_HOST is not executable: #{expanded}" unless File.executable?(expanded)
|
|
94
42
|
expanded
|
|
95
43
|
end
|
|
96
|
-
|
|
97
|
-
def find_command(name)
|
|
98
|
-
@environment.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
|
|
99
|
-
command_names(name).each do |command_name|
|
|
100
|
-
path = File.join(directory, command_name)
|
|
101
|
-
return path if File.executable?(path) && !File.directory?(path)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
nil
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def command_names(name)
|
|
108
|
-
return [name] unless platform.windows? && File.extname(name).empty?
|
|
109
|
-
|
|
110
|
-
extensions = @environment.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
|
|
111
|
-
[name] + extensions.flat_map { |extension| ["#{name}#{extension.downcase}", "#{name}#{extension.upcase}"] }.uniq
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def locate_build(build_dir)
|
|
115
|
-
candidates = [
|
|
116
|
-
File.join(build_dir, executable_name),
|
|
117
|
-
File.join(build_dir, "Release", executable_name),
|
|
118
|
-
File.join(build_dir, "zui-host.app", "Contents", "MacOS", "zui-host")
|
|
119
|
-
]
|
|
120
|
-
candidates.find { |path| File.executable?(path) }
|
|
121
|
-
end
|
|
122
44
|
end
|
|
123
45
|
end
|