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.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/Components/Builtins/Accordion.qml +1 -1
  3. data/Components/Builtins/ActionButton.qml +1 -3
  4. data/Components/Builtins/AlertDialog.qml +2 -1
  5. data/Components/Builtins/AnimatedImage.qml +0 -2
  6. data/Components/Builtins/AreaChart.qml +0 -2
  7. data/Components/Builtins/AspectRatio.qml +0 -2
  8. data/Components/Builtins/Avatar.qml +0 -2
  9. data/Components/Builtins/Badge.qml +0 -2
  10. data/Components/Builtins/Banner.qml +2 -2
  11. data/Components/Builtins/BarChart.qml +0 -2
  12. data/Components/Builtins/BarIconButton.qml +1 -3
  13. data/Components/Builtins/BarIndicator.qml +3 -3
  14. data/Components/Builtins/BorderImage.qml +0 -2
  15. data/Components/Builtins/BorderOverlay.qml +0 -2
  16. data/Components/Builtins/Breadcrumb.qml +2 -2
  17. data/Components/Builtins/Button.qml +1 -2
  18. data/Components/Builtins/ButtonGroup.qml +0 -2
  19. data/Components/Builtins/Card.qml +0 -2
  20. data/Components/Builtins/Center.qml +0 -2
  21. data/Components/Builtins/CheckDelegate.qml +2 -1
  22. data/Components/Builtins/Checkbox.qml +1 -3
  23. data/Components/Builtins/Chip.qml +2 -4
  24. data/Components/Builtins/Column.qml +0 -2
  25. data/Components/Builtins/ColumnLayout.qml +0 -2
  26. data/Components/Builtins/ConfirmDialog.qml +0 -2
  27. data/Components/Builtins/ConstrainedBox.qml +0 -2
  28. data/Components/Builtins/Container.qml +0 -2
  29. data/Components/Builtins/CursorSurface.qml +0 -2
  30. data/Components/Builtins/DelayButton.qml +0 -2
  31. data/Components/Builtins/Dial.qml +0 -2
  32. data/Components/Builtins/Divider.qml +0 -2
  33. data/Components/Builtins/Dropdown.qml +0 -2
  34. data/Components/Builtins/ExpansionPanel.qml +1 -1
  35. data/Components/Builtins/FittedBox.qml +0 -2
  36. data/Components/Builtins/Flickable.qml +0 -2
  37. data/Components/Builtins/Flipable.qml +0 -2
  38. data/Components/Builtins/Flow.qml +0 -2
  39. data/Components/Builtins/FocusScope.qml +0 -2
  40. data/Components/Builtins/FontLoader.qml +0 -2
  41. data/Components/Builtins/Grid.qml +0 -2
  42. data/Components/Builtins/GridLayout.qml +0 -2
  43. data/Components/Builtins/GridView.qml +1 -1
  44. data/Components/Builtins/Icon.qml +1 -3
  45. data/Components/Builtins/Image.qml +0 -2
  46. data/Components/Builtins/ItemDelegate.qml +2 -2
  47. data/Components/Builtins/KeyCatcher.qml +0 -2
  48. data/Components/Builtins/Label.qml +0 -2
  49. data/Components/Builtins/LineChart.qml +0 -2
  50. data/Components/Builtins/ListView.qml +1 -3
  51. data/Components/Builtins/Loader.qml +0 -2
  52. data/Components/Builtins/Markdown.qml +0 -2
  53. data/Components/Builtins/ModalSheet.qml +1 -1
  54. data/Components/Builtins/MultiSelect.qml +0 -2
  55. data/Components/Builtins/NavigationRail.qml +1 -1
  56. data/Components/Builtins/NumberField.qml +0 -2
  57. data/Components/Builtins/OpticalGlyph.qml +1 -3
  58. data/Components/Builtins/PanelHero.qml +0 -2
  59. data/Components/Builtins/PasswordField.qml +1 -3
  60. data/Components/Builtins/Progress.qml +0 -2
  61. data/Components/Builtins/RadioButton.qml +0 -2
  62. data/Components/Builtins/RadioGroup.qml +0 -2
  63. data/Components/Builtins/RangeSlider.qml +0 -2
  64. data/Components/Builtins/Rectangle.qml +0 -2
  65. data/Components/Builtins/RichText.qml +0 -2
  66. data/Components/Builtins/RoundButton.qml +3 -3
  67. data/Components/Builtins/Row.qml +0 -2
  68. data/Components/Builtins/RowLayout.qml +0 -2
  69. data/Components/Builtins/ScreenCapture.qml +1 -1
  70. data/Components/Builtins/Scroll.qml +0 -2
  71. data/Components/Builtins/SearchableDropdown.qml +0 -2
  72. data/Components/Builtins/SectionHeader.qml +0 -2
  73. data/Components/Builtins/SelectableText.qml +0 -2
  74. data/Components/Builtins/Separator.qml +0 -2
  75. data/Components/Builtins/Slider.qml +0 -2
  76. data/Components/Builtins/Spacer.qml +0 -2
  77. data/Components/Builtins/SpinBox.qml +0 -2
  78. data/Components/Builtins/SplitView.qml +0 -2
  79. data/Components/Builtins/Stack.qml +0 -2
  80. data/Components/Builtins/StackLayout.qml +0 -2
  81. data/Components/Builtins/SwipeDelegate.qml +3 -3
  82. data/Components/Builtins/TabButton.qml +1 -1
  83. data/Components/Builtins/Text.qml +0 -2
  84. data/Components/Builtins/TextArea.qml +0 -2
  85. data/Components/Builtins/TextField.qml +0 -2
  86. data/Components/Builtins/TextMetrics.qml +0 -2
  87. data/Components/Builtins/Toast.qml +1 -1
  88. data/Components/Builtins/Toggle.qml +0 -2
  89. data/Components/Builtins/ToggleSwitch.qml +0 -2
  90. data/Components/Builtins/ToolButton.qml +3 -3
  91. data/Components/Builtins/Tooltip.qml +0 -2
  92. data/Components/Builtins/VectorImage.qml +1 -2
  93. data/Components/Builtins/Video.qml +1 -2
  94. data/Components/Builtins/WidgetButton.qml +0 -2
  95. data/Components/Builtins/Wrap.qml +0 -2
  96. data/ControlNode.qml +10 -22
  97. data/Controls/BarIconButton.qml +1 -1
  98. data/Controls/BarIndicator.qml +1 -1
  99. data/Controls/Button.qml +2 -1
  100. data/Controls/OpticalGlyph.qml +1 -1
  101. data/Desktop.qml +7 -3
  102. data/Fonts/FontAwesome-Brands.otf +0 -0
  103. data/Fonts/FontAwesome-Solid.otf +0 -0
  104. data/Fonts/LICENSE-FontAwesome.txt +165 -0
  105. data/Fonts/LICENSE-RobotoMono.txt +93 -0
  106. data/Fonts/RobotoMono-Bold.otf +0 -0
  107. data/Fonts/RobotoMono-Regular.otf +0 -0
  108. data/README.md +27 -14
  109. data/Service.qml +30 -6
  110. data/THIRD_PARTY_NOTICES.md +14 -0
  111. data/Theme/Fonts.qml +32 -0
  112. data/Theme/Style.qml +1 -1
  113. data/Theme/qmldir +1 -0
  114. data/lib/zui/application.rb +28 -3
  115. data/lib/zui/builder.rb +0 -1
  116. data/lib/zui/cli.rb +36 -6
  117. data/lib/zui/client.rb +349 -0
  118. data/lib/zui/components.rb +0 -1
  119. data/lib/zui/distribution.rb +53 -16
  120. data/lib/zui/generator.rb +1 -0
  121. data/lib/zui/host.rb +16 -94
  122. data/lib/zui/runner.rb +1 -1
  123. data/lib/zui/runtime.rb +1 -1
  124. data/lib/zui.rb +2 -1
  125. metadata +13 -13
  126. data/Components/Builtins/WebView.qml +0 -57
  127. data/native/CMakeLists.txt +0 -45
  128. data/native/ZuiClipboard.cpp +0 -16
  129. data/native/ZuiClipboard.h +0 -16
  130. data/native/ZuiProcess.cpp +0 -84
  131. data/native/ZuiProcess.h +0 -34
  132. data/native/main.cpp +0 -64
  133. data/vendor/host/linux-x86_64/PROVENANCE.md +0 -15
  134. data/vendor/host/linux-x86_64/zui-host +0 -0
  135. 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
@@ -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],
@@ -7,20 +7,25 @@ module Zui
7
7
  class Distribution
8
8
  attr_reader :platform
9
9
 
10
- def initialize(host: Host.new, platform: Platform.current, framework_root: FRAMEWORK_ROOT)
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"), File.join(destination, "bin"),
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
- FileUtils.cp(@host.executable, File.join(destination, "bin", "zui-host"))
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
- FileUtils.cp(@host.executable, File.join(macos, "zui-host"))
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"), File.join(destination, "bin"),
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
- FileUtils.cp(@host.executable, File.join(destination, "bin", "zui-host.exe"))
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
- FileUtils.cp_r(File.join(@framework_root, "lib", "zui"), File.join(destination, "lib", "zui"))
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 "$bundle_dir/bin/zui-host" \
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 "$contents/MacOS/zui-host" \
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(bundle_dir, "bin", "zui-host.exe"),
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
@@ -69,6 +69,7 @@ module Zui
69
69
  A native Linux, macOS, and Windows desktop application written in Ruby with Zui.
70
70
 
71
71
  ```bash
72
+ zui doctor --fix # once for each Zui version
72
73
  zui run main.rb
73
74
  zui bundle
74
75
  ```
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, framework_root: FRAMEWORK_ROOT, environment: ENV)
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(build: true)
13
+ def executable
17
14
  override = @environment["ZUI_HOST"]
18
15
  return checked_executable(override) if override && !override.empty?
19
- return bundled if File.executable?(bundled)
20
- return cached if File.executable?(cached)
21
- return nil unless build
16
+ return client.executable if client.configured?
22
17
 
23
- build!(cached)
18
+ raise ArgumentError, "Zui is not configured for #{platform.id}; run `zui doctor --fix`"
24
19
  end
25
20
 
26
- def available? = !executable(build: false).nil?
27
-
28
- def build!(destination = cached)
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
- def platform_help
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
- private
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 cached
73
- cache_root = @environment["XDG_CACHE_HOME"]
74
- cache_root = default_cache_root if cache_root.nil? || cache_root.empty?
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
- def default_cache_root
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
- def executable_name = platform.os == :windows ? "zui-host.exe" : "zui-host"
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