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,527 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "ripper"
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
module Zui
|
|
9
|
+
class TreeShaker
|
|
10
|
+
CONFIG_FILE = ".zui-bundle.json"
|
|
11
|
+
EXCLUDED_SOURCE_DIRECTORIES = %w[.git dist node_modules spec test tmp vendor].freeze
|
|
12
|
+
EXCLUDED_SOURCE_FILES = %w[config.rb].freeze
|
|
13
|
+
BASE_COMPONENTS = %i[container].freeze
|
|
14
|
+
RUBY_ONLY_DSL_METHODS = %w[animation state].freeze
|
|
15
|
+
COMPONENT_DISPATCH_METHODS = %w[component dynamic qml_component send public_send widget].freeze
|
|
16
|
+
BASE_QML_MODULES = %w[
|
|
17
|
+
QML QtCore QtQml QtQml.Models QtQml.WorkerScript QtQuick QtQuick.Window
|
|
18
|
+
QtQuick.Controls QtQuick.Controls.impl QtQuick.Controls.Basic
|
|
19
|
+
QtQuick.Controls.Basic.impl QtQuick.Controls.Fusion QtQuick.Controls.Fusion.impl
|
|
20
|
+
QtQuick.Layouts QtQuick.Templates
|
|
21
|
+
].freeze
|
|
22
|
+
IMAGE_COMPONENTS = %i[
|
|
23
|
+
alert_dialog animated_image avatar border_image carousel image menu navigation_rail
|
|
24
|
+
tab_button vector_animation vector_image
|
|
25
|
+
].freeze
|
|
26
|
+
NETWORK_COMPONENTS = (IMAGE_COMPONENTS + %i[audio media_player video]).freeze
|
|
27
|
+
TEXT_INPUT_COMPONENTS = %i[
|
|
28
|
+
color_picker date_picker double_spin_box file_picker folder_picker font_picker number_field
|
|
29
|
+
password_field search_field spin_box text_area text_field time_picker
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
Report = Struct.new(:components, :qml_modules, :before_bytes, :after_bytes, :warnings,
|
|
33
|
+
keyword_init: true) do
|
|
34
|
+
def saved_bytes = before_bytes - after_bytes
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
"components" => components.map(&:to_s),
|
|
39
|
+
"qml_modules" => qml_modules,
|
|
40
|
+
"before_bytes" => before_bytes,
|
|
41
|
+
"after_bytes" => after_bytes,
|
|
42
|
+
"saved_bytes" => saved_bytes,
|
|
43
|
+
"warnings" => warnings
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(project:, framework:, native:, platform: Platform.current)
|
|
49
|
+
@project = File.expand_path(project)
|
|
50
|
+
@framework = File.expand_path(framework)
|
|
51
|
+
@native = File.expand_path(native)
|
|
52
|
+
@platform = platform.assert_supported!
|
|
53
|
+
@warnings = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def shake!
|
|
57
|
+
before_bytes = tree_bytes(@framework) + tree_bytes(@native)
|
|
58
|
+
components, adapters = analyze_components
|
|
59
|
+
prune_framework(adapters)
|
|
60
|
+
imports = qml_imports(Dir[File.join(@framework, "**", "*.qml")])
|
|
61
|
+
qml_modules = prune_native_qml(imports)
|
|
62
|
+
prune_native_plugins(components, qml_modules)
|
|
63
|
+
prune_translations
|
|
64
|
+
prune_native_libraries
|
|
65
|
+
update_client_manifest(components, qml_modules)
|
|
66
|
+
after_bytes = tree_bytes(@framework) + tree_bytes(@native)
|
|
67
|
+
Report.new(components: components.sort, qml_modules: qml_modules.sort,
|
|
68
|
+
before_bytes:, after_bytes:, warnings: @warnings.dup.freeze)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def analyze_components
|
|
74
|
+
known = COMPONENTS.keys.to_h { |name| [name.to_s, name] }
|
|
75
|
+
selected = Set.new(BASE_COMPONENTS)
|
|
76
|
+
ruby_sources.each do |path|
|
|
77
|
+
syntax = Ripper.sexp(File.read(path))
|
|
78
|
+
raise ArgumentError, "cannot tree-shake Ruby file with syntax errors: #{path}" unless syntax
|
|
79
|
+
|
|
80
|
+
component_references(syntax, known).each { |component| selected << component }
|
|
81
|
+
end
|
|
82
|
+
configured_components.each do |name|
|
|
83
|
+
selected << known.fetch(name) do
|
|
84
|
+
raise ArgumentError, "unknown component in #{CONFIG_FILE}: #{name}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
adapters_by_type = COMPONENTS.keys.to_h { |name| [name, adapter_name(name)] }
|
|
89
|
+
types_by_adapter = adapters_by_type.invert
|
|
90
|
+
adapters = Set.new(selected.map { |name| adapters_by_type.fetch(name) })
|
|
91
|
+
queue = adapters.to_a
|
|
92
|
+
until queue.empty?
|
|
93
|
+
adapter = queue.shift
|
|
94
|
+
path = File.join(@framework, "Components", "Builtins", adapter)
|
|
95
|
+
raise ArgumentError, "component adapter is missing while tree-shaking: #{path}" unless File.file?(path)
|
|
96
|
+
|
|
97
|
+
File.read(path).scan(/\bBuiltins\.([A-Z][A-Za-z0-9]*)/).flatten.each do |class_name|
|
|
98
|
+
dependency = "#{class_name}.qml"
|
|
99
|
+
next unless types_by_adapter.key?(dependency) && adapters.add?(dependency)
|
|
100
|
+
|
|
101
|
+
selected << types_by_adapter.fetch(dependency)
|
|
102
|
+
queue << dependency
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
[selected.to_a, adapters]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def component_references(node, known, found = Set.new)
|
|
109
|
+
return found unless node.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
case node.first
|
|
112
|
+
when :vcall, :fcall
|
|
113
|
+
add_method_reference(found, known, identifier_value(node[1]))
|
|
114
|
+
when :command
|
|
115
|
+
method_name = identifier_value(node[1])
|
|
116
|
+
add_method_reference(found, known, method_name)
|
|
117
|
+
add_literal_references(found, known, node[2]) if COMPONENT_DISPATCH_METHODS.include?(method_name)
|
|
118
|
+
when :method_add_arg
|
|
119
|
+
method_name = call_name(node[1])
|
|
120
|
+
add_method_reference(found, known, method_name)
|
|
121
|
+
add_literal_references(found, known, node[2]) if COMPONENT_DISPATCH_METHODS.include?(method_name)
|
|
122
|
+
end
|
|
123
|
+
node.each { |child| component_references(child, known, found) if child.is_a?(Array) }
|
|
124
|
+
found
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def call_name(node)
|
|
128
|
+
return nil unless node.is_a?(Array)
|
|
129
|
+
return identifier_value(node[1]) if %i[fcall vcall].include?(node.first)
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def identifier_value(node)
|
|
134
|
+
node.is_a?(Array) && node.first.to_s.start_with?("@") ? node[1].to_s : nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def literal_values(node, values = [])
|
|
138
|
+
return values unless node.is_a?(Array)
|
|
139
|
+
|
|
140
|
+
if node.first == :symbol_literal
|
|
141
|
+
token = node.flatten.find { |part| part.is_a?(String) }
|
|
142
|
+
values << token if token
|
|
143
|
+
elsif node.first == :string_literal
|
|
144
|
+
token = node.flatten.find { |part| part.is_a?(String) }
|
|
145
|
+
values << token if token
|
|
146
|
+
else
|
|
147
|
+
node.each { |child| literal_values(child, values) if child.is_a?(Array) }
|
|
148
|
+
end
|
|
149
|
+
values
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_known_reference(found, known, value)
|
|
153
|
+
component = known[value]
|
|
154
|
+
found << component if component
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def add_method_reference(found, known, value)
|
|
158
|
+
return if RUBY_ONLY_DSL_METHODS.include?(value)
|
|
159
|
+
|
|
160
|
+
add_known_reference(found, known, value)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def add_literal_references(found, known, arguments)
|
|
164
|
+
literal_values(arguments).each { |value| add_known_reference(found, known, value) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ruby_sources
|
|
168
|
+
Dir[File.join(@project, "**", "*.rb")].select do |path|
|
|
169
|
+
relative = path.delete_prefix("#{@project}#{File::SEPARATOR}")
|
|
170
|
+
!EXCLUDED_SOURCE_FILES.include?(relative) &&
|
|
171
|
+
!EXCLUDED_SOURCE_DIRECTORIES.include?(relative.split(File::SEPARATOR).first)
|
|
172
|
+
end.sort
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def configured_components
|
|
176
|
+
path = File.join(@project, CONFIG_FILE)
|
|
177
|
+
return [] unless File.file?(path)
|
|
178
|
+
|
|
179
|
+
document = JSON.parse(File.read(path))
|
|
180
|
+
raise ArgumentError, "#{CONFIG_FILE} must contain a JSON object" unless document.is_a?(Hash)
|
|
181
|
+
components = document.fetch("components", [])
|
|
182
|
+
unless components.is_a?(Array) && components.all? { |name| name.is_a?(String) && !name.empty? }
|
|
183
|
+
raise ArgumentError, "#{CONFIG_FILE} components must be an array of component names"
|
|
184
|
+
end
|
|
185
|
+
components
|
|
186
|
+
rescue JSON::ParserError => error
|
|
187
|
+
raise ArgumentError, "invalid #{CONFIG_FILE}: #{error.message}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def adapter_name(name)
|
|
191
|
+
"#{name.to_s.split('_').map(&:capitalize).join}.qml"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def prune_framework(adapters)
|
|
195
|
+
builtins = File.join(@framework, "Components", "Builtins")
|
|
196
|
+
Dir[File.join(builtins, "*.qml")].each do |path|
|
|
197
|
+
FileUtils.rm_f(path) unless adapters.include?(File.basename(path))
|
|
198
|
+
end
|
|
199
|
+
sources = adapters.map { |name| File.join(builtins, name) }
|
|
200
|
+
support_required = sources.any? do |path|
|
|
201
|
+
source = File.read(path)
|
|
202
|
+
source.include?('import "Support"') || source.include?("Support/")
|
|
203
|
+
end
|
|
204
|
+
shaders_required = adapters.include?("ShaderEffect.qml")
|
|
205
|
+
remove_tree(File.join(builtins, "Support")) unless support_required
|
|
206
|
+
remove_tree(File.join(builtins, "Shaders")) unless shaders_required
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def qml_imports(paths)
|
|
210
|
+
paths.each_with_object(Set.new) do |path, imports|
|
|
211
|
+
File.foreach(path) do |line|
|
|
212
|
+
line.scan(/(?:^|["'])\s*import\s+([A-Za-z][A-Za-z0-9_.]*)\b/) do |match|
|
|
213
|
+
imports << match.first
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def prune_native_qml(framework_imports)
|
|
220
|
+
root = native_path_for("QML_IMPORT_PATH")
|
|
221
|
+
return [] unless root && File.directory?(root)
|
|
222
|
+
|
|
223
|
+
modules = module_directories(root)
|
|
224
|
+
required = Set.new((BASE_QML_MODULES + framework_imports.to_a).select { |name| modules.key?(name) })
|
|
225
|
+
queue = required.to_a
|
|
226
|
+
until queue.empty?
|
|
227
|
+
name = queue.shift
|
|
228
|
+
module_imports(modules.fetch(name), modules).each do |dependency|
|
|
229
|
+
next unless modules.key?(dependency) && required.add?(dependency)
|
|
230
|
+
|
|
231
|
+
queue << dependency
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
module_paths = modules.values
|
|
236
|
+
modules.sort_by { |_name, path| -path.count(File::SEPARATOR) }.each do |name, path|
|
|
237
|
+
next if required.include?(name)
|
|
238
|
+
next if required.any? { |kept| descendant?(modules.fetch(kept), path) }
|
|
239
|
+
|
|
240
|
+
remove_tree(path)
|
|
241
|
+
end
|
|
242
|
+
required.to_a
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def module_directories(root)
|
|
246
|
+
Dir[File.join(root, "**", "qmldir")].each_with_object({}) do |path, modules|
|
|
247
|
+
module_name = File.foreach(path).filter_map { |line| line[/^\s*module\s+(\S+)/, 1] }.first
|
|
248
|
+
modules[module_name] = File.dirname(path) if module_name
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def module_imports(directory, modules)
|
|
253
|
+
imports = Set.new
|
|
254
|
+
qmldir = File.join(directory, "qmldir")
|
|
255
|
+
File.foreach(qmldir) do |line|
|
|
256
|
+
dependency = line[/^\s*(?:depends|import)\s+([A-Za-z][A-Za-z0-9_.]*)\b/, 1]
|
|
257
|
+
imports << dependency if dependency
|
|
258
|
+
end
|
|
259
|
+
nested = modules.values.reject { |candidate| candidate == directory }
|
|
260
|
+
.select { |candidate| descendant?(candidate, directory) }
|
|
261
|
+
qml_files = Dir[File.join(directory, "**", "*.qml")].reject do |path|
|
|
262
|
+
nested.any? { |child| descendant?(path, child) }
|
|
263
|
+
end
|
|
264
|
+
imports.merge(qml_imports(qml_files))
|
|
265
|
+
imports
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def native_path_for(environment_name)
|
|
269
|
+
manifest_path = File.join(@native, "client.json")
|
|
270
|
+
return nil unless File.file?(manifest_path)
|
|
271
|
+
|
|
272
|
+
manifest = JSON.parse(File.read(manifest_path))
|
|
273
|
+
relative = Array(manifest.fetch("environment", {})[environment_name]).first
|
|
274
|
+
return nil unless relative
|
|
275
|
+
|
|
276
|
+
path = File.expand_path(relative, @native)
|
|
277
|
+
unless path == @native || descendant?(path, @native)
|
|
278
|
+
raise ArgumentError, "unsafe native client path while tree-shaking: #{relative.inspect}"
|
|
279
|
+
end
|
|
280
|
+
path
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def prune_native_plugins(components, qml_modules)
|
|
284
|
+
root = native_path_for("QT_PLUGIN_PATH")
|
|
285
|
+
return unless root && File.directory?(root)
|
|
286
|
+
|
|
287
|
+
component_set = components.to_set
|
|
288
|
+
has_images = !(component_set & IMAGE_COMPONENTS).empty?
|
|
289
|
+
has_network = !(component_set & NETWORK_COMPONENTS).empty?
|
|
290
|
+
has_text_input = !(component_set & TEXT_INPUT_COMPONENTS).empty?
|
|
291
|
+
has_multimedia = qml_modules.include?("QtMultimedia")
|
|
292
|
+
has_quick3d = qml_modules.any? { |name| name.start_with?("QtQuick3D") }
|
|
293
|
+
|
|
294
|
+
remove_tree(File.join(root, "assetimporters")) unless has_quick3d
|
|
295
|
+
remove_tree(File.join(root, "multimedia")) unless has_multimedia
|
|
296
|
+
remove_tree(File.join(root, "imageformats")) unless has_images
|
|
297
|
+
remove_tree(File.join(root, "iconengines")) unless has_images
|
|
298
|
+
remove_tree(File.join(root, "networkinformation")) unless has_network
|
|
299
|
+
remove_tree(File.join(root, "tls")) unless has_network
|
|
300
|
+
remove_tree(File.join(root, "platformthemes")) if @platform.linux?
|
|
301
|
+
remove_tree(File.join(root, "platforminputcontexts")) if @platform.linux? && !has_text_input
|
|
302
|
+
remove_tree(File.join(root, "generic")) if @platform.linux?
|
|
303
|
+
FileUtils.rm_f(File.join(root, "wayland-decoration-client", "libadwaita.so")) if @platform.linux?
|
|
304
|
+
prune_linux_platform_plugins(root) if @platform.linux?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def prune_linux_platform_plugins(plugin_root)
|
|
308
|
+
platforms = File.join(plugin_root, "platforms")
|
|
309
|
+
return unless File.directory?(platforms)
|
|
310
|
+
|
|
311
|
+
keep = /\A(?:libqxcb|libqwayland(?:-[A-Za-z0-9_+-]+)?|libqoffscreen)\.so(?:\..*)?\z/
|
|
312
|
+
Dir.children(platforms).each do |name|
|
|
313
|
+
path = File.join(platforms, name)
|
|
314
|
+
FileUtils.rm_f(path) if File.file?(path) && !name.match?(keep)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def prune_translations
|
|
319
|
+
candidates = [File.join(@native, "translations")]
|
|
320
|
+
if @platform.macos?
|
|
321
|
+
candidates << File.join(@native, "zui-host.app", "Contents", "Resources", "translations")
|
|
322
|
+
end
|
|
323
|
+
candidates.each { |path| remove_tree(path) }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def prune_native_libraries
|
|
327
|
+
if @platform.linux?
|
|
328
|
+
prune_linux_libraries
|
|
329
|
+
elsif @platform.macos?
|
|
330
|
+
prune_macos_frameworks
|
|
331
|
+
elsif @platform.windows?
|
|
332
|
+
prune_windows_libraries
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def prune_linux_libraries
|
|
337
|
+
library_root = File.join(@native, "lib")
|
|
338
|
+
return unless File.directory?(library_root)
|
|
339
|
+
|
|
340
|
+
available = Dir.children(library_root).to_h { |name| [name, File.join(library_root, name)] }
|
|
341
|
+
roots = native_binary_roots(["*.so", "*.so.*"])
|
|
342
|
+
closure = Set.new
|
|
343
|
+
queue = roots.dup
|
|
344
|
+
inspected = Set.new
|
|
345
|
+
successful = false
|
|
346
|
+
begin
|
|
347
|
+
until queue.empty?
|
|
348
|
+
binary = queue.shift
|
|
349
|
+
next unless File.file?(binary) && inspected.add?(binary)
|
|
350
|
+
|
|
351
|
+
result = Command.run(["ldd", binary], env: { "LD_LIBRARY_PATH" => library_root },
|
|
352
|
+
timeout: 30, max_output_bytes: 2_000_000)
|
|
353
|
+
next unless result.success?
|
|
354
|
+
|
|
355
|
+
successful = true
|
|
356
|
+
result.stdout.each_line do |line|
|
|
357
|
+
name = line[/^\s*(\S+)\s+=>/, 1]
|
|
358
|
+
path = line[/=>\s+(\/\S+)/, 1] || line[/^\s*(\/\S+)/, 1]
|
|
359
|
+
name ||= File.basename(path) if path
|
|
360
|
+
next unless name && available.key?(name) && closure.add?(name)
|
|
361
|
+
|
|
362
|
+
queue << available.fetch(name)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
rescue Errno::ENOENT, CommandTimeout, CommandOutputLimit
|
|
366
|
+
@warnings << "native Linux library analysis was unavailable; libraries were retained"
|
|
367
|
+
return
|
|
368
|
+
end
|
|
369
|
+
return unless successful
|
|
370
|
+
|
|
371
|
+
available.each do |name, path|
|
|
372
|
+
FileUtils.rm_f(path) unless closure.include?(name) || name.start_with?(".")
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def prune_macos_frameworks
|
|
377
|
+
root = File.join(@native, "zui-host.app", "Contents", "Frameworks")
|
|
378
|
+
return unless File.directory?(root)
|
|
379
|
+
|
|
380
|
+
available = Dir[File.join(root, "*.framework")].to_h { |path| [File.basename(path), path] }
|
|
381
|
+
dylibs = Dir[File.join(root, "*.dylib")].to_h { |path| [File.basename(path), path] }
|
|
382
|
+
roots = native_binary_roots(["*.dylib", "*.so"])
|
|
383
|
+
queue = roots.dup
|
|
384
|
+
required = Set.new
|
|
385
|
+
required_dylibs = Set.new
|
|
386
|
+
inspected = Set.new
|
|
387
|
+
successful = false
|
|
388
|
+
begin
|
|
389
|
+
until queue.empty?
|
|
390
|
+
binary = queue.shift
|
|
391
|
+
next unless File.file?(binary) && inspected.add?(binary)
|
|
392
|
+
|
|
393
|
+
result = Command.run(["otool", "-L", binary], timeout: 30, max_output_bytes: 2_000_000)
|
|
394
|
+
next unless result.success?
|
|
395
|
+
|
|
396
|
+
successful = true
|
|
397
|
+
result.stdout.scan(%r{([^/\s]+\.framework)/}).flatten.each do |name|
|
|
398
|
+
next unless available.key?(name) && required.add?(name)
|
|
399
|
+
|
|
400
|
+
framework_binary = canonical_framework_binary(available.fetch(name), name.delete_suffix(".framework"))
|
|
401
|
+
queue << framework_binary if framework_binary
|
|
402
|
+
end
|
|
403
|
+
result.stdout.each_line do |line|
|
|
404
|
+
dependency = line.strip.split(/\s+\(/, 2).first
|
|
405
|
+
name = File.basename(dependency.to_s)
|
|
406
|
+
next unless dylibs.key?(name) && required_dylibs.add?(name)
|
|
407
|
+
|
|
408
|
+
queue << dylibs.fetch(name)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
rescue Errno::ENOENT, CommandTimeout, CommandOutputLimit
|
|
412
|
+
@warnings << "native macOS framework analysis was unavailable; frameworks were retained"
|
|
413
|
+
return
|
|
414
|
+
end
|
|
415
|
+
return unless successful
|
|
416
|
+
|
|
417
|
+
available.each { |name, path| remove_tree(path) unless required.include?(name) }
|
|
418
|
+
dylibs.each { |name, path| FileUtils.rm_f(path) unless required_dylibs.include?(name) }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def canonical_framework_binary(framework, name)
|
|
422
|
+
Dir[File.join(framework, "**", name)].find { |path| File.file?(path) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def prune_windows_libraries
|
|
426
|
+
bin = File.join(@native, "bin")
|
|
427
|
+
return unless File.directory?(bin)
|
|
428
|
+
|
|
429
|
+
available = Dir[File.join(bin, "*.dll")].to_h { |path| [File.basename(path).downcase, path] }
|
|
430
|
+
queue = native_binary_roots(["*.dll"])
|
|
431
|
+
required = Set.new
|
|
432
|
+
inspected = Set.new
|
|
433
|
+
parsed = false
|
|
434
|
+
until queue.empty?
|
|
435
|
+
binary = queue.shift
|
|
436
|
+
next unless File.file?(binary) && inspected.add?(binary)
|
|
437
|
+
|
|
438
|
+
dependencies = pe_dependencies(binary)
|
|
439
|
+
parsed = true unless dependencies.empty?
|
|
440
|
+
dependencies.each do |name|
|
|
441
|
+
key = name.downcase
|
|
442
|
+
next unless available.key?(key) && required.add?(key)
|
|
443
|
+
|
|
444
|
+
queue << available.fetch(key)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
return unless parsed
|
|
448
|
+
|
|
449
|
+
available.each { |name, path| FileUtils.rm_f(path) unless required.include?(name) }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def pe_dependencies(path)
|
|
453
|
+
data = File.binread(path)
|
|
454
|
+
return [] unless data.start_with?("MZ") && data.bytesize >= 64
|
|
455
|
+
|
|
456
|
+
pe_offset = data.byteslice(0x3c, 4)&.unpack1("V")
|
|
457
|
+
return [] unless pe_offset && data.byteslice(pe_offset, 4) == "PE\0\0"
|
|
458
|
+
|
|
459
|
+
section_count = data.byteslice(pe_offset + 6, 2).unpack1("v")
|
|
460
|
+
optional_size = data.byteslice(pe_offset + 20, 2).unpack1("v")
|
|
461
|
+
optional = pe_offset + 24
|
|
462
|
+
magic = data.byteslice(optional, 2).unpack1("v")
|
|
463
|
+
directory = optional + (magic == 0x20b ? 112 : 96)
|
|
464
|
+
import_rva = data.byteslice(directory + 8, 4)&.unpack1("V")
|
|
465
|
+
return [] unless import_rva&.positive?
|
|
466
|
+
|
|
467
|
+
sections = section_count.times.map do |index|
|
|
468
|
+
offset = optional + optional_size + index * 40
|
|
469
|
+
[data.byteslice(offset + 12, 4).unpack1("V"), data.byteslice(offset + 8, 4).unpack1("V"),
|
|
470
|
+
data.byteslice(offset + 16, 4).unpack1("V"), data.byteslice(offset + 20, 4).unpack1("V")]
|
|
471
|
+
end
|
|
472
|
+
rva_offset = lambda do |rva|
|
|
473
|
+
section = sections.find { |virtual, size, raw_size, _raw| rva >= virtual && rva < virtual + [size, raw_size].max }
|
|
474
|
+
section && section[3] + rva - section[0]
|
|
475
|
+
end
|
|
476
|
+
descriptor = rva_offset.call(import_rva)
|
|
477
|
+
return [] unless descriptor
|
|
478
|
+
|
|
479
|
+
dependencies = []
|
|
480
|
+
loop do
|
|
481
|
+
name_rva = data.byteslice(descriptor + 12, 4)&.unpack1("V")
|
|
482
|
+
break unless name_rva&.positive?
|
|
483
|
+
|
|
484
|
+
name_offset = rva_offset.call(name_rva)
|
|
485
|
+
break unless name_offset
|
|
486
|
+
name = data.byteslice(name_offset..)&.split("\0", 2)&.first
|
|
487
|
+
dependencies << name if name&.match?(/\.dll\z/i)
|
|
488
|
+
descriptor += 20
|
|
489
|
+
end
|
|
490
|
+
dependencies
|
|
491
|
+
rescue NoMethodError, RangeError
|
|
492
|
+
[]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def native_binary_roots(patterns)
|
|
496
|
+
manifest = JSON.parse(File.read(File.join(@native, "client.json")))
|
|
497
|
+
roots = [File.join(@native, manifest.fetch("executable"))]
|
|
498
|
+
[native_path_for("QML_IMPORT_PATH"), native_path_for("QT_PLUGIN_PATH")].compact.each do |root|
|
|
499
|
+
patterns.each { |pattern| roots.concat(Dir[File.join(root, "**", pattern)]) }
|
|
500
|
+
end
|
|
501
|
+
roots.uniq
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def update_client_manifest(components, qml_modules)
|
|
505
|
+
path = File.join(@native, "client.json")
|
|
506
|
+
return unless File.file?(path)
|
|
507
|
+
|
|
508
|
+
manifest = JSON.parse(File.read(path))
|
|
509
|
+
manifest["tree_shaken"] = true
|
|
510
|
+
manifest["components"] = components.map(&:to_s).sort
|
|
511
|
+
manifest["qml_modules"] = qml_modules.sort
|
|
512
|
+
File.write(path, "#{JSON.pretty_generate(manifest)}\n")
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def descendant?(path, parent)
|
|
516
|
+
path.start_with?("#{parent}#{File::SEPARATOR}")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def remove_tree(path)
|
|
520
|
+
FileUtils.remove_entry(path) if File.exist?(path) || File.symlink?(path)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def tree_bytes(root)
|
|
524
|
+
Dir[File.join(root, "**", "*")].sum { |path| File.file?(path) ? File.size(path) : 0 }
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
data/lib/zui.rb
CHANGED
|
@@ -14,14 +14,17 @@ require_relative "zui/application"
|
|
|
14
14
|
require_relative "zui/source_bundle"
|
|
15
15
|
require_relative "zui/platform"
|
|
16
16
|
require_relative "zui/runtime"
|
|
17
|
+
require_relative "zui/tree_shaker"
|
|
18
|
+
require_relative "zui/dist_config"
|
|
17
19
|
require_relative "zui/client"
|
|
18
20
|
require_relative "zui/host"
|
|
19
21
|
require_relative "zui/runner"
|
|
20
22
|
require_relative "zui/generator"
|
|
21
23
|
require_relative "zui/distribution"
|
|
24
|
+
require_relative "zui/dist_packager"
|
|
22
25
|
|
|
23
26
|
module Zui
|
|
24
|
-
VERSION = "0.0.
|
|
27
|
+
VERSION = "0.0.7"
|
|
25
28
|
FRAMEWORK_ROOT = File.expand_path("..", __dir__)
|
|
26
29
|
|
|
27
30
|
def self.app(&definition)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adam Moussa Ali
|
|
@@ -326,6 +326,8 @@ files:
|
|
|
326
326
|
- lib/zui/command.rb
|
|
327
327
|
- lib/zui/component_registry.rb
|
|
328
328
|
- lib/zui/components.rb
|
|
329
|
+
- lib/zui/dist_config.rb
|
|
330
|
+
- lib/zui/dist_packager.rb
|
|
329
331
|
- lib/zui/distribution.rb
|
|
330
332
|
- lib/zui/generator.rb
|
|
331
333
|
- lib/zui/host.rb
|
|
@@ -337,6 +339,7 @@ files:
|
|
|
337
339
|
- lib/zui/scheduler.rb
|
|
338
340
|
- lib/zui/source_bundle.rb
|
|
339
341
|
- lib/zui/state_store.rb
|
|
342
|
+
- lib/zui/tree_shaker.rb
|
|
340
343
|
- lib/zui/value.rb
|
|
341
344
|
homepage: https://github.com/AdamMusa/zui
|
|
342
345
|
licenses:
|