ruflet 0.0.17 → 0.0.19
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/README.md +16 -1
- data/lib/ruflet/cli/build_command.rb +165 -707
- data/lib/ruflet/cli/extra_command.rb +14 -23
- data/lib/ruflet/cli/flutter_sdk.rb +34 -95
- data/lib/ruflet/cli/new_command.rb +89 -49
- data/lib/ruflet/cli/run_command.rb +186 -182
- data/lib/ruflet/cli/templates.rb +8 -9
- data/lib/ruflet/cli.rb +2 -15
- data/lib/ruflet/version.rb +1 -1
- metadata +1 -3
- data/lib/ruflet/cli/android_sdk.rb +0 -306
- data/lib/ruflet/cli/environment_setup.rb +0 -241
|
@@ -13,10 +13,7 @@ module Ruflet
|
|
|
13
13
|
module CLI
|
|
14
14
|
module BuildCommand
|
|
15
15
|
include FlutterSdk
|
|
16
|
-
include EnvironmentSetup
|
|
17
|
-
include AndroidSdk
|
|
18
16
|
CLIENT_EXTENSION_MAP = {
|
|
19
|
-
"ads" => { package: "flet_ads", alias: "ruflet_ads" },
|
|
20
17
|
"audio" => { package: "flet_audio", alias: "ruflet_audio" },
|
|
21
18
|
"audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
|
|
22
19
|
"camera" => { package: "flet_camera", alias: "ruflet_camera" },
|
|
@@ -31,104 +28,29 @@ module Ruflet
|
|
|
31
28
|
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
32
29
|
"rive" => { package: "flet_rive", alias: "ruflet_rive" },
|
|
33
30
|
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
34
|
-
"spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
|
|
35
31
|
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
36
32
|
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
|
|
37
33
|
}.freeze
|
|
38
|
-
|
|
39
|
-
SERVICE_EXTENSION_MAP = {
|
|
34
|
+
PROTECTED_SERVICE_EXTENSIONS = {
|
|
40
35
|
"camera" => %w[camera permission_handler],
|
|
41
36
|
"microphone" => %w[audio_recorder permission_handler],
|
|
42
37
|
"location" => %w[geolocator permission_handler],
|
|
43
38
|
"motion" => %w[permission_handler]
|
|
44
39
|
}.freeze
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"com.apple.security.device.camera" => true
|
|
57
|
-
},
|
|
58
|
-
ios_permission_definitions: %w[
|
|
59
|
-
PERMISSION_CAMERA=1
|
|
60
|
-
]
|
|
61
|
-
},
|
|
62
|
-
"microphone" => {
|
|
63
|
-
android_permissions: ["android.permission.RECORD_AUDIO"],
|
|
64
|
-
ios_info: {
|
|
65
|
-
"NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
|
|
66
|
-
},
|
|
67
|
-
macos_info: {
|
|
68
|
-
"NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
|
|
69
|
-
},
|
|
70
|
-
macos_entitlements: {
|
|
71
|
-
"com.apple.security.device.audio-input" => true
|
|
72
|
-
},
|
|
73
|
-
ios_permission_definitions: %w[
|
|
74
|
-
PERMISSION_MICROPHONE=1
|
|
75
|
-
]
|
|
76
|
-
},
|
|
77
|
-
"motion" => {
|
|
78
|
-
ios_info: {
|
|
79
|
-
"NSMotionUsageDescription" => "Motion access is required for motion and sensor readings."
|
|
80
|
-
},
|
|
81
|
-
ios_permission_definitions: %w[
|
|
82
|
-
PERMISSION_SENSORS=1
|
|
83
|
-
]
|
|
84
|
-
},
|
|
85
|
-
"location" => {
|
|
86
|
-
android_permissions: [
|
|
87
|
-
"android.permission.ACCESS_FINE_LOCATION",
|
|
88
|
-
"android.permission.ACCESS_COARSE_LOCATION"
|
|
89
|
-
],
|
|
90
|
-
ios_info: {
|
|
91
|
-
"NSLocationWhenInUseUsageDescription" => "Location access is required for location-aware experiences."
|
|
92
|
-
},
|
|
93
|
-
macos_info: {
|
|
94
|
-
"NSLocationUsageDescription" => "Location access is required for location-aware experiences."
|
|
95
|
-
},
|
|
96
|
-
macos_entitlements: {
|
|
97
|
-
"com.apple.security.personal-information.location" => true
|
|
98
|
-
},
|
|
99
|
-
ios_permission_definitions: %w[
|
|
100
|
-
PERMISSION_LOCATION=1
|
|
101
|
-
]
|
|
102
|
-
}
|
|
40
|
+
ANDROID_SERVICE_PERMISSIONS = {
|
|
41
|
+
"camera" => %w[android.permission.CAMERA],
|
|
42
|
+
"microphone" => %w[android.permission.RECORD_AUDIO],
|
|
43
|
+
"location" => %w[android.permission.ACCESS_COARSE_LOCATION android.permission.ACCESS_FINE_LOCATION],
|
|
44
|
+
"motion" => %w[android.permission.ACTIVITY_RECOGNITION android.permission.HIGH_SAMPLING_RATE_SENSORS]
|
|
45
|
+
}.freeze
|
|
46
|
+
IOS_SERVICE_USAGE_KEYS = {
|
|
47
|
+
"camera" => "NSCameraUsageDescription",
|
|
48
|
+
"microphone" => "NSMicrophoneUsageDescription",
|
|
49
|
+
"location" => "NSLocationWhenInUseUsageDescription",
|
|
50
|
+
"motion" => "NSMotionUsageDescription"
|
|
103
51
|
}.freeze
|
|
104
52
|
|
|
105
53
|
def command_build(args)
|
|
106
|
-
with_project_build_lock { run_build_command(args) }
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Concurrent builds share build/client and corrupt each other's state
|
|
110
|
-
# (missing app.dill, Xcode build.db I/O errors). flock is released
|
|
111
|
-
# automatically when the process exits, so the lock cannot go stale.
|
|
112
|
-
def with_project_build_lock
|
|
113
|
-
lock_dir = File.join(Dir.pwd, "build")
|
|
114
|
-
FileUtils.mkdir_p(lock_dir)
|
|
115
|
-
lock_path = File.join(lock_dir, ".ruflet_build.lock")
|
|
116
|
-
File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
117
|
-
unless file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
118
|
-
owner = file.read.to_s.strip
|
|
119
|
-
warn "Another ruflet build is already running for this project#{owner.empty? ? '' : " (#{owner})"}."
|
|
120
|
-
warn "Concurrent builds share the same build directory and corrupt each other."
|
|
121
|
-
warn "Wait for it to finish, then retry."
|
|
122
|
-
return 1
|
|
123
|
-
end
|
|
124
|
-
file.truncate(0)
|
|
125
|
-
file.write("pid=#{Process.pid} started=#{Time.now}")
|
|
126
|
-
file.flush
|
|
127
|
-
yield
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def run_build_command(args)
|
|
132
54
|
self_contained = args.delete("--self")
|
|
133
55
|
verbose = args.delete("--verbose") || args.delete("-v")
|
|
134
56
|
platform = (args.shift || "").downcase
|
|
@@ -172,9 +94,9 @@ module Ruflet
|
|
|
172
94
|
backend_url = configured_backend_url(config)
|
|
173
95
|
if self_contained
|
|
174
96
|
build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url
|
|
175
|
-
# Pin the embedded
|
|
176
|
-
#
|
|
177
|
-
#
|
|
97
|
+
# Pin the embedded project so main.self.dart extracts assets/<name>/
|
|
98
|
+
# deterministically instead of inferring from a single main.rb — the
|
|
99
|
+
# app tree now ships many main.rb files (standalone_apps/*/main.rb).
|
|
178
100
|
build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
|
|
179
101
|
else
|
|
180
102
|
unless backend_url
|
|
@@ -211,16 +133,8 @@ module Ruflet
|
|
|
211
133
|
end
|
|
212
134
|
|
|
213
135
|
tools = ensure_flutter!("install", client_dir: client_dir)
|
|
214
|
-
|
|
215
|
-
device_id ||= select_install_device(
|
|
216
|
-
flutter: tools[:flutter],
|
|
217
|
-
env: discovery_env,
|
|
218
|
-
client_dir: client_dir
|
|
219
|
-
)
|
|
220
|
-
return 1 unless device_id
|
|
221
|
-
|
|
136
|
+
command_env = install_tool_env(tools[:env], client_dir)
|
|
222
137
|
install_platform = install_platform_for_device(device_id)
|
|
223
|
-
command_env = install_tool_env(tools[:env], client_dir, platform: install_platform)
|
|
224
138
|
unless sync_built_outputs_for_install(client_dir, platform: install_platform, verbose: !!verbose)
|
|
225
139
|
warn "Could not find built app outputs under ./build"
|
|
226
140
|
warn "Run `ruflet build ...` first, then `ruflet install`."
|
|
@@ -258,72 +172,33 @@ module Ruflet
|
|
|
258
172
|
nil
|
|
259
173
|
end
|
|
260
174
|
|
|
261
|
-
def select_install_device(flutter:, env:, client_dir:, input: $stdin, output: $stdout)
|
|
262
|
-
devices = discover_install_devices(flutter: flutter, env: env, client_dir: client_dir)
|
|
263
|
-
if devices.empty?
|
|
264
|
-
warn "No supported devices are connected."
|
|
265
|
-
warn "Run `ruflet devices` to check device availability."
|
|
266
|
-
return nil
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
output.puts "Available devices:"
|
|
270
|
-
devices.each_with_index do |device, index|
|
|
271
|
-
details = [device["targetPlatform"], device["emulator"] ? "emulator" : "physical"].compact
|
|
272
|
-
output.puts " #{index + 1}) #{device.fetch("name", device["id"])} (#{details.join(", ")}) [#{device["id"]}]"
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
loop do
|
|
276
|
-
output.print "Choose a device [1-#{devices.length}]: "
|
|
277
|
-
output.flush
|
|
278
|
-
choice = input.gets
|
|
279
|
-
unless choice
|
|
280
|
-
warn "Device selection cancelled. Use `--device DEVICE_ID` for noninteractive installs."
|
|
281
|
-
return nil
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
index = Integer(choice.strip, exception: false)
|
|
285
|
-
return devices[index - 1]["id"] if index && index.between?(1, devices.length)
|
|
286
|
-
|
|
287
|
-
output.puts "Enter a number from 1 to #{devices.length}."
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def discover_install_devices(flutter:, env:, client_dir:)
|
|
292
|
-
stdout, stderr, status = Open3.capture3(
|
|
293
|
-
env,
|
|
294
|
-
flutter,
|
|
295
|
-
"devices",
|
|
296
|
-
"--machine",
|
|
297
|
-
chdir: client_dir
|
|
298
|
-
)
|
|
299
|
-
unless status.success?
|
|
300
|
-
warn "Could not list Flutter devices."
|
|
301
|
-
warn stderr.strip unless stderr.to_s.strip.empty?
|
|
302
|
-
return []
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
devices = JSON.parse(stdout)
|
|
306
|
-
Array(devices).select do |device|
|
|
307
|
-
device.is_a?(Hash) && !device["id"].to_s.empty? && device["isSupported"] != false
|
|
308
|
-
end
|
|
309
|
-
rescue JSON::ParserError => e
|
|
310
|
-
warn "Could not parse Flutter device list: #{e.message}"
|
|
311
|
-
[]
|
|
312
|
-
end
|
|
313
|
-
|
|
314
175
|
def ensure_flutter_client_dir(verbose: false)
|
|
315
176
|
client_dir = detect_flutter_client_dir
|
|
316
|
-
|
|
177
|
+
if client_dir
|
|
178
|
+
refresh_hidden_flutter_client_template(client_dir, verbose: verbose)
|
|
179
|
+
return client_dir
|
|
180
|
+
end
|
|
317
181
|
|
|
318
182
|
bootstrapped = bootstrap_flutter_client_template
|
|
319
183
|
build_log(verbose, "bootstrapped client template at #{bootstrapped}") if bootstrapped
|
|
320
184
|
bootstrapped
|
|
321
185
|
end
|
|
322
186
|
|
|
187
|
+
def refresh_hidden_flutter_client_template(client_dir, verbose: false)
|
|
188
|
+
return unless File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir)
|
|
189
|
+
return unless File.file?(File.join(client_dir, ".metadata"))
|
|
190
|
+
return unless Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
191
|
+
|
|
192
|
+
template_root = Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
193
|
+
return unless template_root && Dir.exist?(template_root)
|
|
194
|
+
return if Ruflet::CLI.respond_to?(:client_template_current?, true) &&
|
|
195
|
+
Ruflet::CLI.send(:client_template_current?, client_dir, template_root)
|
|
196
|
+
|
|
197
|
+
Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd)
|
|
198
|
+
build_log(verbose, "refreshed managed Flutter client from template #{template_root}")
|
|
199
|
+
end
|
|
200
|
+
|
|
323
201
|
def build_tool_env(env, platform, client_dir = nil)
|
|
324
|
-
if %w[android apk aab appbundle].include?(platform)
|
|
325
|
-
return android_build_env(unbundled_command_env(env))
|
|
326
|
-
end
|
|
327
202
|
return env unless %w[ios macos].include?(platform)
|
|
328
203
|
|
|
329
204
|
apple_env = unbundled_command_env(env)
|
|
@@ -332,9 +207,8 @@ module Ruflet
|
|
|
332
207
|
apple_env
|
|
333
208
|
end
|
|
334
209
|
|
|
335
|
-
def install_tool_env(env, client_dir
|
|
336
|
-
|
|
337
|
-
return build_tool_env(env, platform, client_dir) if platform
|
|
210
|
+
def install_tool_env(env, client_dir)
|
|
211
|
+
return build_tool_env(env, inferred_install_platform, client_dir) if inferred_install_platform
|
|
338
212
|
|
|
339
213
|
command_env = unbundled_command_env(env)
|
|
340
214
|
command_env["PATH"] = apple_build_path(command_env["PATH"])
|
|
@@ -527,7 +401,9 @@ module Ruflet
|
|
|
527
401
|
def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
|
|
528
402
|
refresh_managed_client_template_files(client_dir, verbose: verbose)
|
|
529
403
|
sync_client_metadata(client_dir, config, verbose: verbose)
|
|
530
|
-
|
|
404
|
+
apply_native_service_permissions(client_dir, config)
|
|
405
|
+
configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
|
|
406
|
+
return false if configured == false
|
|
531
407
|
@ruflet_self_contained_build = self_contained
|
|
532
408
|
apply_service_extension_config(client_dir, config)
|
|
533
409
|
asset_flags = apply_build_config(client_dir, config)
|
|
@@ -642,20 +518,7 @@ module Ruflet
|
|
|
642
518
|
end
|
|
643
519
|
|
|
644
520
|
def unbundled_command_env(env)
|
|
645
|
-
|
|
646
|
-
ensure_utf8_locale(command_env)
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
# CocoaPods refuses to run in a non-UTF-8 terminal and Xcode project
|
|
650
|
-
# files contain UTF-8; guarantee a UTF-8 locale for child tools.
|
|
651
|
-
def ensure_utf8_locale(env)
|
|
652
|
-
locale = env["LC_ALL"].to_s
|
|
653
|
-
locale = env["LANG"].to_s if locale.empty?
|
|
654
|
-
return env if locale.downcase.include?("utf-8") || locale.downcase.include?("utf8")
|
|
655
|
-
|
|
656
|
-
env["LANG"] = "en_US.UTF-8"
|
|
657
|
-
env["LC_ALL"] = "en_US.UTF-8"
|
|
658
|
-
env
|
|
521
|
+
env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") }
|
|
659
522
|
end
|
|
660
523
|
|
|
661
524
|
def run_external_command(env, *cmd, chdir:, unbundled: false)
|
|
@@ -736,60 +599,20 @@ module Ruflet
|
|
|
736
599
|
alt = "ruflet.yml"
|
|
737
600
|
config_path = alt if File.file?(alt)
|
|
738
601
|
end
|
|
739
|
-
return
|
|
602
|
+
return {} unless File.file?(config_path)
|
|
740
603
|
|
|
741
|
-
YAML.safe_load(File.read(config_path), aliases: true) || {}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def load_rails_ruflet_initializer_config
|
|
748
|
-
initializer = File.join(Dir.pwd, "config", "initializers", "ruflet.rb")
|
|
749
|
-
return {} unless File.file?(initializer)
|
|
750
|
-
|
|
751
|
-
require "ruflet_rails"
|
|
752
|
-
|
|
753
|
-
with_minimal_rails_config_context do
|
|
754
|
-
load initializer
|
|
755
|
-
config = Ruflet::Rails.config
|
|
756
|
-
config.respond_to?(:to_ruflet_yaml_hash) ? config.to_ruflet_yaml_hash : {}
|
|
604
|
+
config = YAML.safe_load(File.read(config_path), aliases: true) || {}
|
|
605
|
+
services_path = File.join(File.dirname(File.expand_path(config_path)), "services.yaml")
|
|
606
|
+
if File.file?(services_path)
|
|
607
|
+
service_config = YAML.safe_load(File.read(services_path), aliases: true) || {}
|
|
608
|
+
config["services"] = service_config["services"] if service_config.key?("services")
|
|
757
609
|
end
|
|
758
|
-
|
|
759
|
-
warn "Failed to load Rails Ruflet config: #{e.class}: #{e.message}"
|
|
760
|
-
{}
|
|
610
|
+
config
|
|
761
611
|
rescue StandardError => e
|
|
762
|
-
warn "Failed to load
|
|
612
|
+
warn "Failed to load ruflet config: #{e.class}: #{e.message}"
|
|
763
613
|
{}
|
|
764
614
|
end
|
|
765
615
|
|
|
766
|
-
def with_minimal_rails_config_context
|
|
767
|
-
return yield if defined?(::Rails)
|
|
768
|
-
|
|
769
|
-
rails_module = Module.new
|
|
770
|
-
root = Pathname.new(Dir.pwd)
|
|
771
|
-
env = minimal_rails_env(ENV.fetch("RAILS_ENV", "development"))
|
|
772
|
-
rails_module.define_singleton_method(:root) { root }
|
|
773
|
-
rails_module.define_singleton_method(:env) { env }
|
|
774
|
-
|
|
775
|
-
Object.const_set(:Rails, rails_module)
|
|
776
|
-
yield
|
|
777
|
-
ensure
|
|
778
|
-
Object.send(:remove_const, :Rails) if defined?(rails_module) && Object.const_defined?(:Rails, false) && ::Rails.equal?(rails_module)
|
|
779
|
-
end
|
|
780
|
-
|
|
781
|
-
def minimal_rails_env(name)
|
|
782
|
-
value = name.to_s
|
|
783
|
-
Object.new.tap do |env|
|
|
784
|
-
env.define_singleton_method(:to_s) { value }
|
|
785
|
-
env.define_singleton_method(:to_str) { value }
|
|
786
|
-
env.define_singleton_method(:==) { |other| value == other.to_s }
|
|
787
|
-
env.define_singleton_method(:development?) { value == "development" }
|
|
788
|
-
env.define_singleton_method(:test?) { value == "test" }
|
|
789
|
-
env.define_singleton_method(:production?) { value == "production" }
|
|
790
|
-
end
|
|
791
|
-
end
|
|
792
|
-
|
|
793
616
|
def apply_build_config(client_dir, config = {})
|
|
794
617
|
config_path = ENV["RUFLET_CONFIG"] || (File.file?("ruflet.yaml") ? "ruflet.yaml" : "ruflet.yml")
|
|
795
618
|
config_present = File.file?(config_path)
|
|
@@ -917,7 +740,6 @@ module Ruflet
|
|
|
917
740
|
apply_web_metadata(client_dir, metadata)
|
|
918
741
|
apply_windows_metadata(client_dir, metadata)
|
|
919
742
|
apply_linux_metadata(client_dir, metadata)
|
|
920
|
-
apply_dart_metadata(client_dir, metadata)
|
|
921
743
|
build_log(
|
|
922
744
|
verbose,
|
|
923
745
|
"app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}"
|
|
@@ -995,6 +817,16 @@ module Ruflet
|
|
|
995
817
|
%( applicationId = "#{metadata[:android_application_id]}")
|
|
996
818
|
)
|
|
997
819
|
|
|
820
|
+
Dir.glob(
|
|
821
|
+
File.join(client_dir, "android", "app", "src", "main", "kotlin", "**", "MainActivity.kt")
|
|
822
|
+
).each do |activity_path|
|
|
823
|
+
replace_in_file(
|
|
824
|
+
activity_path,
|
|
825
|
+
/^package\s+[^\s]+$/,
|
|
826
|
+
"package #{metadata[:android_application_id]}"
|
|
827
|
+
)
|
|
828
|
+
end
|
|
829
|
+
|
|
998
830
|
manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
999
831
|
replace_in_file(
|
|
1000
832
|
manifest_path,
|
|
@@ -1113,31 +945,6 @@ module Ruflet
|
|
|
1113
945
|
cmake_path = File.join(client_dir, "linux", "CMakeLists.txt")
|
|
1114
946
|
replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}")))
|
|
1115
947
|
replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
|
|
1116
|
-
|
|
1117
|
-
# The GTK runner hardcodes the window title (header-bar and fallback
|
|
1118
|
-
# paths). Without this it shows the package name (e.g. "ruflet_client")
|
|
1119
|
-
# instead of the configured app name. Match the call, not the literal,
|
|
1120
|
-
# so re-runs stay idempotent.
|
|
1121
|
-
my_application_path = File.join(client_dir, "linux", "runner", "my_application.cc")
|
|
1122
|
-
title = c_string_escape(metadata[:display_name])
|
|
1123
|
-
replace_in_file(
|
|
1124
|
-
my_application_path,
|
|
1125
|
-
/gtk_header_bar_set_title\(header_bar, "[^"]*"\)/,
|
|
1126
|
-
%(gtk_header_bar_set_title(header_bar, "#{title}"))
|
|
1127
|
-
)
|
|
1128
|
-
replace_in_file(
|
|
1129
|
-
my_application_path,
|
|
1130
|
-
/gtk_window_set_title\(window, "[^"]*"\)/,
|
|
1131
|
-
%(gtk_window_set_title(window, "#{title}"))
|
|
1132
|
-
)
|
|
1133
|
-
end
|
|
1134
|
-
|
|
1135
|
-
def apply_dart_metadata(client_dir, metadata)
|
|
1136
|
-
title = dart_single_quote_escape(metadata[:display_name])
|
|
1137
|
-
client_entrypoint_paths(client_dir).each do |entrypoint|
|
|
1138
|
-
replace_in_file(entrypoint, /title: 'Ruflet'/, "title: '#{title}'")
|
|
1139
|
-
replace_in_file(entrypoint, /AppBar\(title: const Text\('Ruflet'\)\)/, "AppBar(title: const Text('#{title}'))")
|
|
1140
|
-
end
|
|
1141
948
|
end
|
|
1142
949
|
|
|
1143
950
|
def replace_plist_value(path, key, value)
|
|
@@ -1222,31 +1029,18 @@ module Ruflet
|
|
|
1222
1029
|
value.to_s.gsub('"', '""')
|
|
1223
1030
|
end
|
|
1224
1031
|
|
|
1225
|
-
# Escape a value for embedding inside a C string literal (used for the
|
|
1226
|
-
# GTK runner window title in my_application.cc). Block form avoids
|
|
1227
|
-
# backslash interpretation in the gsub replacement string.
|
|
1228
|
-
def c_string_escape(value)
|
|
1229
|
-
value.to_s.gsub(/[\\"]/) { |ch| "\\#{ch}" }
|
|
1230
|
-
end
|
|
1231
|
-
|
|
1232
|
-
def dart_single_quote_escape(value)
|
|
1233
|
-
value.to_s.gsub("\\", "\\\\\\").gsub("'", "\\\\'")
|
|
1234
|
-
end
|
|
1235
|
-
|
|
1236
1032
|
def key_defined?(hash, key)
|
|
1237
1033
|
hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym))
|
|
1238
1034
|
end
|
|
1239
1035
|
|
|
1240
1036
|
def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
extension_keys = ((configured_extension_keys - protected_extension_keys) | service_extension_keys).uniq
|
|
1037
|
+
services = configured_service_entries(config).map { |entry| entry[:name] }
|
|
1038
|
+
requested_extensions = Array(config["extensions"]).map { |value| normalize_extension_key(value) }.compact
|
|
1039
|
+
protected_extensions = services.flat_map { |name| PROTECTED_SERVICE_EXTENSIONS.fetch(name, []) }
|
|
1040
|
+
extension_keys = (requested_extensions + protected_extensions + services).uniq
|
|
1246
1041
|
extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
|
|
1247
1042
|
extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
|
|
1248
1043
|
|
|
1249
|
-
sync_client_flet_packages(client_dir, extension_packages)
|
|
1250
1044
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
1251
1045
|
if File.file?(pubspec_path)
|
|
1252
1046
|
sync_client_extension_dependencies(pubspec_path, extension_packages)
|
|
@@ -1256,208 +1050,67 @@ module Ruflet
|
|
|
1256
1050
|
sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
|
|
1257
1051
|
prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
|
|
1258
1052
|
end
|
|
1259
|
-
apply_service_native_requirements(client_dir, service_definitions.keys, service_definitions)
|
|
1260
|
-
end
|
|
1261
|
-
|
|
1262
|
-
def apply_service_native_requirements(client_dir, extension_keys, service_definitions = load_service_definitions(client_dir))
|
|
1263
|
-
service_requirements = service_native_requirements(service_definitions)
|
|
1264
|
-
stale_keys = service_requirements.keys - extension_keys
|
|
1265
|
-
remove_service_native_requirements(client_dir, stale_keys, service_requirements)
|
|
1266
|
-
|
|
1267
|
-
requirements = merge_service_native_requirements(extension_keys, service_requirements)
|
|
1268
|
-
sync_ios_permission_definitions(
|
|
1269
|
-
File.join(client_dir, "ios", "Podfile"),
|
|
1270
|
-
Array(requirements[:ios_permission_definitions])
|
|
1271
|
-
)
|
|
1272
|
-
return if requirements.empty?
|
|
1273
|
-
|
|
1274
|
-
android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
1275
|
-
Array(requirements[:android_permissions]).each do |permission|
|
|
1276
|
-
ensure_android_permission(android_manifest, permission)
|
|
1277
|
-
end
|
|
1278
|
-
|
|
1279
|
-
ios_info = File.join(client_dir, "ios", "Runner", "Info.plist")
|
|
1280
|
-
Hash(requirements[:ios_info]).each do |key, value|
|
|
1281
|
-
ensure_plist_string(ios_info, key, value)
|
|
1282
|
-
end
|
|
1283
|
-
|
|
1284
|
-
macos_info = File.join(client_dir, "macos", "Runner", "Info.plist")
|
|
1285
|
-
Hash(requirements[:macos_info]).each do |key, value|
|
|
1286
|
-
ensure_plist_string(macos_info, key, value)
|
|
1287
|
-
end
|
|
1288
|
-
|
|
1289
|
-
%w[DebugProfile Release].each do |name|
|
|
1290
|
-
entitlements_path = File.join(client_dir, "macos", "Runner", "#{name}.entitlements")
|
|
1291
|
-
Hash(requirements[:macos_entitlements]).each do |key, value|
|
|
1292
|
-
ensure_plist_boolean(entitlements_path, key, value)
|
|
1293
|
-
end
|
|
1294
|
-
end
|
|
1295
|
-
end
|
|
1296
|
-
|
|
1297
|
-
def remove_service_native_requirements(client_dir, extension_keys, service_requirements = service_native_requirements(load_service_definitions(client_dir)))
|
|
1298
|
-
requirements = merge_service_native_requirements(extension_keys, service_requirements)
|
|
1299
|
-
return if requirements.empty?
|
|
1300
|
-
|
|
1301
|
-
android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
1302
|
-
Array(requirements[:android_permissions]).each do |permission|
|
|
1303
|
-
remove_android_permission(android_manifest, permission)
|
|
1304
|
-
end
|
|
1305
|
-
|
|
1306
|
-
[File.join(client_dir, "ios", "Runner", "Info.plist"), File.join(client_dir, "macos", "Runner", "Info.plist")].each do |path|
|
|
1307
|
-
(Hash(requirements[:ios_info]).keys + Hash(requirements[:macos_info]).keys).uniq.each do |key|
|
|
1308
|
-
remove_plist_entry(path, key)
|
|
1309
|
-
end
|
|
1310
|
-
end
|
|
1311
|
-
|
|
1312
|
-
%w[DebugProfile Release].each do |name|
|
|
1313
|
-
entitlements_path = File.join(client_dir, "macos", "Runner", "#{name}.entitlements")
|
|
1314
|
-
Hash(requirements[:macos_entitlements]).each_key do |key|
|
|
1315
|
-
remove_plist_entry(entitlements_path, key)
|
|
1316
|
-
end
|
|
1317
|
-
end
|
|
1318
1053
|
end
|
|
1319
1054
|
|
|
1320
|
-
def
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1055
|
+
def configured_service_entries(config)
|
|
1056
|
+
Array(config["services"]).filter_map do |entry|
|
|
1057
|
+
case entry
|
|
1058
|
+
when Hash
|
|
1059
|
+
name, options = entry.first
|
|
1060
|
+
key = normalize_extension_key(name)
|
|
1061
|
+
next unless key
|
|
1324
1062
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
memo[section] ||= {}
|
|
1331
|
-
memo[section].merge!(requirements[section] || {})
|
|
1063
|
+
description = options.is_a?(Hash) ? options["description"] || options[:description] : options
|
|
1064
|
+
{ name: key, description: description.to_s.strip }
|
|
1065
|
+
else
|
|
1066
|
+
key = normalize_extension_key(entry)
|
|
1067
|
+
{ name: key, description: "" } if key
|
|
1332
1068
|
end
|
|
1333
1069
|
end
|
|
1334
1070
|
end
|
|
1335
1071
|
|
|
1336
|
-
def
|
|
1337
|
-
|
|
1338
|
-
return
|
|
1339
|
-
|
|
1340
|
-
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
1341
|
-
Array(data["services"]).each_with_object({}) do |entry, memo|
|
|
1342
|
-
name, metadata =
|
|
1343
|
-
if entry.is_a?(Hash)
|
|
1344
|
-
entry.first
|
|
1345
|
-
else
|
|
1346
|
-
[entry, {}]
|
|
1347
|
-
end
|
|
1348
|
-
key = normalize_extension_key(name)
|
|
1349
|
-
memo[key] = metadata.is_a?(Hash) ? metadata : {} if key
|
|
1350
|
-
end
|
|
1351
|
-
rescue Psych::Exception => e
|
|
1352
|
-
warn "Could not parse #{path}: #{e.message}"
|
|
1353
|
-
{}
|
|
1354
|
-
end
|
|
1355
|
-
|
|
1356
|
-
def services_config_path(client_dir = nil)
|
|
1357
|
-
candidates = [ENV["RUFLET_SERVICES"], File.expand_path("services.yaml", Dir.pwd)]
|
|
1358
|
-
candidates << File.join(client_dir, "services.yaml") if client_dir
|
|
1359
|
-
candidates.compact.find { |path| File.file?(path) }
|
|
1360
|
-
end
|
|
1361
|
-
|
|
1362
|
-
def service_native_requirements(service_definitions)
|
|
1363
|
-
all_keys = (DEFAULT_SERVICE_NATIVE_REQUIREMENTS.keys | service_definitions.keys)
|
|
1364
|
-
all_keys.each_with_object({}) do |key, memo|
|
|
1365
|
-
defaults = DEFAULT_SERVICE_NATIVE_REQUIREMENTS[key] || {}
|
|
1366
|
-
metadata = service_definitions[key] || {}
|
|
1367
|
-
native = metadata["native"].is_a?(Hash) ? metadata["native"] : metadata
|
|
1368
|
-
memo[key] = {
|
|
1369
|
-
android_permissions: Array(native["android_permissions"] || defaults[:android_permissions]),
|
|
1370
|
-
ios_permission_definitions: Array(native["ios_permission_definitions"] || defaults[:ios_permission_definitions]),
|
|
1371
|
-
ios_info: native["ios_info"].is_a?(Hash) ? native["ios_info"] : (defaults[:ios_info] || {}),
|
|
1372
|
-
macos_info: native["macos_info"].is_a?(Hash) ? native["macos_info"] : (defaults[:macos_info] || {}),
|
|
1373
|
-
macos_entitlements: native["macos_entitlements"].is_a?(Hash) ? native["macos_entitlements"] : (defaults[:macos_entitlements] || {})
|
|
1374
|
-
}
|
|
1375
|
-
end
|
|
1376
|
-
end
|
|
1377
|
-
|
|
1378
|
-
def sync_ios_permission_definitions(path, definitions)
|
|
1379
|
-
return unless File.file?(path)
|
|
1072
|
+
def apply_native_service_permissions(client_dir, config)
|
|
1073
|
+
entries = configured_service_entries(config)
|
|
1074
|
+
return if entries.empty?
|
|
1380
1075
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
updated = content.gsub(marker_pattern, "\n")
|
|
1384
|
-
definitions = Array(definitions).map(&:to_s).reject(&:empty?).uniq.sort
|
|
1385
|
-
if definitions.any?
|
|
1386
|
-
block = <<~RUBY.chomp
|
|
1387
|
-
# BEGIN RUFLET PERMISSION DEFINITIONS
|
|
1388
|
-
target.build_configurations.each do |config|
|
|
1389
|
-
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
|
1390
|
-
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] += #{definitions.inspect}
|
|
1391
|
-
end
|
|
1392
|
-
# END RUFLET PERMISSION DEFINITIONS
|
|
1393
|
-
RUBY
|
|
1394
|
-
updated = updated.sub(
|
|
1395
|
-
/^(\s*)flutter_additional_ios_build_settings\(target\)\s*$/,
|
|
1396
|
-
"\\0\n\n#{block}"
|
|
1397
|
-
)
|
|
1398
|
-
end
|
|
1399
|
-
File.write(path, updated) unless updated == content
|
|
1076
|
+
apply_android_service_permissions(client_dir, entries)
|
|
1077
|
+
apply_ios_service_usage_descriptions(client_dir, entries)
|
|
1400
1078
|
end
|
|
1401
1079
|
|
|
1402
|
-
def
|
|
1080
|
+
def apply_android_service_permissions(client_dir, entries)
|
|
1081
|
+
path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
1403
1082
|
return unless File.file?(path)
|
|
1404
1083
|
|
|
1405
1084
|
content = File.read(path)
|
|
1406
|
-
|
|
1085
|
+
entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
|
|
1086
|
+
next if content.include?(%(android:name="#{permission}"))
|
|
1407
1087
|
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
File.write(path,
|
|
1088
|
+
content.sub!(/<manifest\b[^>]*>\s*/, "\\0 <uses-permission android:name=\"#{permission}\"/>\n")
|
|
1089
|
+
end
|
|
1090
|
+
File.write(path, content)
|
|
1411
1091
|
end
|
|
1412
1092
|
|
|
1413
|
-
def
|
|
1093
|
+
def apply_ios_service_usage_descriptions(client_dir, entries)
|
|
1094
|
+
path = File.join(client_dir, "ios", "Runner", "Info.plist")
|
|
1414
1095
|
return unless File.file?(path)
|
|
1415
1096
|
|
|
1416
1097
|
content = File.read(path)
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
def ensure_plist_string(path, key, value)
|
|
1422
|
-
ensure_plist_entry(path, key, "<string>#{xml_escape(value)}</string>")
|
|
1423
|
-
end
|
|
1098
|
+
entries.each do |entry|
|
|
1099
|
+
key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
|
|
1100
|
+
next unless key
|
|
1424
1101
|
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
def ensure_plist_entry(path, key, value_xml)
|
|
1430
|
-
return unless File.file?(path)
|
|
1102
|
+
description = entry[:description]
|
|
1103
|
+
description = "This app uses #{entry[:name]} access for its Ruflet features." if description.empty?
|
|
1104
|
+
escaped_description = xml_escape(description)
|
|
1105
|
+
pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
|
|
1431
1106
|
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
entry = "\t<key>#{key}</key>\n\t#{value_xml}\n"
|
|
1436
|
-
|
|
1437
|
-
# Insert into the ROOT <dict> (opened right after <plist ...>), never the
|
|
1438
|
-
# first nested </dict>. Modern Flutter Info.plists nest a dict inside
|
|
1439
|
-
# UIApplicationSceneManifest, so subbing the first </dict> would bury
|
|
1440
|
-
# top-level keys (e.g. NS*UsageDescription) where iOS can't read them —
|
|
1441
|
-
# which crashes the app the moment a permission is requested.
|
|
1442
|
-
root_open = content.match(%r{<plist\b[^>]*>\s*<dict>}m)
|
|
1443
|
-
updated =
|
|
1444
|
-
if root_open
|
|
1445
|
-
insert_at = root_open.end(0)
|
|
1446
|
-
content[0...insert_at] + "\n#{entry}" + content[insert_at..]
|
|
1447
|
-
elsif (last = content.rindex("</dict>"))
|
|
1448
|
-
content[0...last] + entry + content[last..]
|
|
1107
|
+
if content.match?(%r{<key>#{Regexp.escape(key)}</key>})
|
|
1108
|
+
content.sub!(%r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m, "<key>#{key}</key>\n\t<string>#{escaped_description}</string>")
|
|
1449
1109
|
else
|
|
1450
|
-
"#{
|
|
1110
|
+
content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
|
|
1451
1111
|
end
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
def remove_plist_entry(path, key)
|
|
1456
|
-
return unless File.file?(path)
|
|
1457
|
-
|
|
1458
|
-
content = File.read(path)
|
|
1459
|
-
updated = content.gsub(%r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:string>.*?</string|true/|false/)>\s*}m, "\n")
|
|
1460
|
-
File.write(path, updated) unless updated == content
|
|
1112
|
+
end
|
|
1113
|
+
File.write(path, content)
|
|
1461
1114
|
end
|
|
1462
1115
|
|
|
1463
1116
|
def clear_flutter_build_state(client_dir, verbose: false)
|
|
@@ -1501,6 +1154,7 @@ module Ruflet
|
|
|
1501
1154
|
remove_self_contained_project_assets(client_dir, verbose: verbose)
|
|
1502
1155
|
remove_local_ruby_runtime_override(client_dir, verbose: verbose)
|
|
1503
1156
|
end
|
|
1157
|
+
true
|
|
1504
1158
|
end
|
|
1505
1159
|
|
|
1506
1160
|
def sync_client_pubspec_for_runtime_mode(client_dir, self_contained:)
|
|
@@ -1510,23 +1164,24 @@ module Ruflet
|
|
|
1510
1164
|
data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
|
|
1511
1165
|
dependencies = data["dependencies"]
|
|
1512
1166
|
dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
|
|
1167
|
+
spinkit_dependency = template_client_pubspec_dependencies["flutter_spinkit"]
|
|
1168
|
+
dependencies["flutter_spinkit"] = spinkit_dependency if spinkit_dependency
|
|
1513
1169
|
flutter = data["flutter"]
|
|
1514
1170
|
flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
|
|
1515
1171
|
assets = Array(flutter["assets"]).map(&:to_s)
|
|
1516
|
-
project_asset_prefix = "assets/#{self_contained_project_name}/"
|
|
1517
|
-
assets.reject! { |asset| asset.start_with?(project_asset_prefix) }
|
|
1518
1172
|
|
|
1519
1173
|
if self_contained
|
|
1520
1174
|
dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1175
|
+
# Flutter does not recurse into asset directories, so every subdirectory
|
|
1176
|
+
# of the embedded project (e.g. standalone_apps/<slug>/) must be listed
|
|
1177
|
+
# explicitly or its files never reach the bundle/manifest on device.
|
|
1178
|
+
self_contained_project_asset_dirs.each do |dir_entry|
|
|
1179
|
+
assets << dir_entry unless assets.include?(dir_entry)
|
|
1525
1180
|
end
|
|
1526
1181
|
else
|
|
1527
1182
|
dependencies.delete("ruby_runtime")
|
|
1528
|
-
|
|
1529
|
-
assets.
|
|
1183
|
+
project_prefix = "assets/#{self_contained_project_name}/"
|
|
1184
|
+
assets.reject! { |a| a.to_s == project_prefix || a.to_s.start_with?(project_prefix) }
|
|
1530
1185
|
end
|
|
1531
1186
|
|
|
1532
1187
|
flutter["assets"] = assets unless assets.empty?
|
|
@@ -1534,46 +1189,11 @@ module Ruflet
|
|
|
1534
1189
|
write_pubspec_yaml(pubspec_path, data)
|
|
1535
1190
|
end
|
|
1536
1191
|
|
|
1537
|
-
RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.6"
|
|
1538
|
-
|
|
1539
1192
|
def ruby_runtime_dependency(current_dependency = nil)
|
|
1540
|
-
local_path = explicit_local_ruby_runtime_path ||
|
|
1193
|
+
local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
|
|
1541
1194
|
return { "path" => local_path } if local_path
|
|
1542
1195
|
|
|
1543
|
-
|
|
1544
|
-
return template_dependency if usable_ruby_runtime_dependency?(template_dependency)
|
|
1545
|
-
|
|
1546
|
-
return current_dependency if usable_ruby_runtime_dependency?(current_dependency)
|
|
1547
|
-
|
|
1548
|
-
RUBY_RUNTIME_FALLBACK_REQUIREMENT
|
|
1549
|
-
end
|
|
1550
|
-
|
|
1551
|
-
# In a ruflet repo checkout the plugin lives next to templates/; build
|
|
1552
|
-
# against it so framework changes are exercised without publishing.
|
|
1553
|
-
def repo_checkout_ruby_runtime_path
|
|
1554
|
-
template_root =
|
|
1555
|
-
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1556
|
-
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1557
|
-
end
|
|
1558
|
-
return nil unless template_root
|
|
1559
|
-
|
|
1560
|
-
candidate = File.expand_path(File.join(template_root, "..", "..", "ruby_runtime"))
|
|
1561
|
-
File.file?(File.join(candidate, "pubspec.yaml")) ? candidate : nil
|
|
1562
|
-
end
|
|
1563
|
-
|
|
1564
|
-
# Relative path dependencies only resolve from the directory the
|
|
1565
|
-
# template was authored in — never copy them into a user's client.
|
|
1566
|
-
def usable_ruby_runtime_dependency?(dependency)
|
|
1567
|
-
case dependency
|
|
1568
|
-
when nil then false
|
|
1569
|
-
when Hash
|
|
1570
|
-
path = dependency["path"] || dependency[:path]
|
|
1571
|
-
return true if path.nil? # hosted/git table form
|
|
1572
|
-
|
|
1573
|
-
Pathname.new(path.to_s).absolute? && File.file?(File.join(path, "pubspec.yaml"))
|
|
1574
|
-
else
|
|
1575
|
-
!dependency.to_s.strip.empty?
|
|
1576
|
-
end
|
|
1196
|
+
current_dependency || "^0.0.3"
|
|
1577
1197
|
end
|
|
1578
1198
|
|
|
1579
1199
|
def explicit_local_ruby_runtime_path
|
|
@@ -1586,6 +1206,13 @@ module Ruflet
|
|
|
1586
1206
|
nil
|
|
1587
1207
|
end
|
|
1588
1208
|
|
|
1209
|
+
def source_checkout_ruby_runtime_path
|
|
1210
|
+
candidate = Pathname.new(File.expand_path("../../../../../ruby_runtime", __dir__))
|
|
1211
|
+
return candidate.to_s if candidate.join("pubspec.yaml").file?
|
|
1212
|
+
|
|
1213
|
+
nil
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1589
1216
|
def refresh_managed_client_template_files(client_dir, verbose: false)
|
|
1590
1217
|
template_root =
|
|
1591
1218
|
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
@@ -1598,13 +1225,12 @@ module Ruflet
|
|
|
1598
1225
|
"lib/main.self.dart",
|
|
1599
1226
|
"lib/main.server.dart",
|
|
1600
1227
|
"lib/ruflet_file_picker_service.dart",
|
|
1601
|
-
"
|
|
1602
|
-
"macos/Runner/Release.entitlements"
|
|
1603
|
-
]
|
|
1604
|
-
stale_files = [
|
|
1228
|
+
"lib/ruflet_spinkit.dart",
|
|
1605
1229
|
"lib/connection_probe.dart",
|
|
1606
1230
|
"lib/connection_probe_io.dart",
|
|
1607
|
-
"lib/connection_probe_stub.dart"
|
|
1231
|
+
"lib/connection_probe_stub.dart",
|
|
1232
|
+
"ios/Podfile",
|
|
1233
|
+
"windows/CMakeLists.txt"
|
|
1608
1234
|
]
|
|
1609
1235
|
|
|
1610
1236
|
managed_files.each do |relative_path|
|
|
@@ -1616,35 +1242,6 @@ module Ruflet
|
|
|
1616
1242
|
FileUtils.cp(source, destination)
|
|
1617
1243
|
build_log(verbose, "refreshed template file #{relative_path}")
|
|
1618
1244
|
end
|
|
1619
|
-
|
|
1620
|
-
stale_files.each do |relative_path|
|
|
1621
|
-
path = File.join(client_dir, relative_path)
|
|
1622
|
-
next unless File.file?(path)
|
|
1623
|
-
|
|
1624
|
-
FileUtils.rm_f(path)
|
|
1625
|
-
build_log(verbose, "removed stale template file #{relative_path}")
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
repair_legacy_self_contained_bootstrap(client_dir, verbose: verbose)
|
|
1629
|
-
end
|
|
1630
|
-
|
|
1631
|
-
def repair_legacy_self_contained_bootstrap(client_dir, verbose: false)
|
|
1632
|
-
path = File.join(client_dir, "lib", "main.self.dart")
|
|
1633
|
-
return unless File.file?(path)
|
|
1634
|
-
|
|
1635
|
-
content = File.read(path)
|
|
1636
|
-
updated = content.gsub(
|
|
1637
|
-
/^\s*await RubyRuntime\.eval\("ENV\['RUFLET_DEBUG'\].*?\n/,
|
|
1638
|
-
""
|
|
1639
|
-
)
|
|
1640
|
-
updated = updated.gsub(
|
|
1641
|
-
/^\s*final digestLength = await RubyRuntime\.eval\(\n.*?^\s*\);\n\s*debugPrint\('Embedded Digest::SHA1 bytesize: \$digestLength'\);\n/m,
|
|
1642
|
-
""
|
|
1643
|
-
)
|
|
1644
|
-
return if updated == content
|
|
1645
|
-
|
|
1646
|
-
File.write(path, updated)
|
|
1647
|
-
build_log(verbose, "removed legacy pre-server RubyRuntime.eval diagnostics")
|
|
1648
1245
|
end
|
|
1649
1246
|
|
|
1650
1247
|
def write_pubspec_yaml(path, data)
|
|
@@ -1669,17 +1266,23 @@ module Ruflet
|
|
|
1669
1266
|
end.join
|
|
1670
1267
|
end
|
|
1671
1268
|
|
|
1269
|
+
# Flutter asset directory entries for every folder of the embedded project
|
|
1270
|
+
# that contains packaged files. Derived from the exact copy list so the
|
|
1271
|
+
# pubspec asset dirs match what sync_self_contained_project_assets writes.
|
|
1272
|
+
def self_contained_project_asset_dirs
|
|
1273
|
+
prefix = "assets/#{self_contained_project_name}"
|
|
1274
|
+
dirs = project_asset_relative_paths.map { |rel| File.dirname(rel) }.uniq
|
|
1275
|
+
project_dirs = dirs.map { |dir| dir == "." ? "#{prefix}/" : "#{prefix}/#{dir}/" }
|
|
1276
|
+
project_dirs.sort
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1672
1279
|
def sync_self_contained_project_assets(client_dir, verbose: false)
|
|
1673
1280
|
project_root = Pathname.new(Dir.pwd)
|
|
1674
1281
|
assets_root = File.join(client_dir, "assets")
|
|
1675
1282
|
destination_root = File.join(assets_root, self_contained_project_name)
|
|
1676
1283
|
FileUtils.rm_rf(destination_root)
|
|
1677
|
-
FileUtils.rm_rf(File.join(assets_root, "ruby_project"))
|
|
1678
1284
|
FileUtils.mkdir_p(destination_root)
|
|
1679
1285
|
|
|
1680
|
-
legacy_entrypoint = File.join(client_dir, "assets", "main.rb")
|
|
1681
|
-
FileUtils.rm_f(legacy_entrypoint)
|
|
1682
|
-
|
|
1683
1286
|
copied = 0
|
|
1684
1287
|
project_asset_relative_paths.each do |relative_path|
|
|
1685
1288
|
source = project_root.join(relative_path)
|
|
@@ -1687,104 +1290,15 @@ module Ruflet
|
|
|
1687
1290
|
|
|
1688
1291
|
destination = File.join(destination_root, relative_path)
|
|
1689
1292
|
FileUtils.mkdir_p(File.dirname(destination))
|
|
1690
|
-
|
|
1293
|
+
FileUtils.cp(source.to_s, destination)
|
|
1691
1294
|
copied += 1
|
|
1692
1295
|
end
|
|
1693
1296
|
|
|
1694
1297
|
build_log(verbose, "copied #{copied} project file#{copied == 1 ? '' : 's'} to assets/#{self_contained_project_name}")
|
|
1695
1298
|
end
|
|
1696
1299
|
|
|
1697
|
-
def copy_project_asset_file(source, destination, project_root: nil)
|
|
1698
|
-
if File.extname(source).downcase == ".rb"
|
|
1699
|
-
ruby_source =
|
|
1700
|
-
if File.basename(source) == "main.rb" && project_root
|
|
1701
|
-
bundled_embedded_ruby_source(source, project_root)
|
|
1702
|
-
else
|
|
1703
|
-
File.read(source)
|
|
1704
|
-
end
|
|
1705
|
-
File.write(destination, normalize_embedded_ruby_source(ruby_source))
|
|
1706
|
-
else
|
|
1707
|
-
FileUtils.cp(source, destination)
|
|
1708
|
-
end
|
|
1709
|
-
end
|
|
1710
|
-
|
|
1711
|
-
def bundled_embedded_ruby_source(source, project_root, visited = {})
|
|
1712
|
-
absolute = File.expand_path(source)
|
|
1713
|
-
return "" if visited[absolute]
|
|
1714
|
-
|
|
1715
|
-
visited[absolute] = true
|
|
1716
|
-
base = File.dirname(absolute)
|
|
1717
|
-
File.read(absolute).lines.map do |line|
|
|
1718
|
-
match = line.match(/^\s*require_relative\s+["']([^"']+)["']\s*$/)
|
|
1719
|
-
next line unless match
|
|
1720
|
-
|
|
1721
|
-
required = File.expand_path(match[1], base)
|
|
1722
|
-
required = "#{required}.rb" unless File.file?(required)
|
|
1723
|
-
next line unless File.file?(required) && required.start_with?(File.expand_path(project_root))
|
|
1724
|
-
|
|
1725
|
-
bundled_embedded_ruby_source(required, project_root, visited)
|
|
1726
|
-
end.join
|
|
1727
|
-
end
|
|
1728
|
-
|
|
1729
|
-
def normalize_embedded_ruby_source(source)
|
|
1730
|
-
source.lines.map do |line|
|
|
1731
|
-
expand_endless_method_line(line)
|
|
1732
|
-
end.join
|
|
1733
|
-
end
|
|
1734
|
-
|
|
1735
|
-
def expand_endless_method_line(line)
|
|
1736
|
-
match = line.match(/^(\s*)def\s+(.+)$/)
|
|
1737
|
-
return line unless match
|
|
1738
|
-
|
|
1739
|
-
indent = match[1]
|
|
1740
|
-
body = match[2].chomp
|
|
1741
|
-
newline = line.end_with?("\n") ? "\n" : ""
|
|
1742
|
-
split = endless_method_split(body)
|
|
1743
|
-
return line unless split
|
|
1744
|
-
|
|
1745
|
-
signature, expression = split
|
|
1746
|
-
"#{indent}def #{signature.rstrip}\n#{indent} #{expression.lstrip}\n#{indent}end#{newline}"
|
|
1747
|
-
end
|
|
1748
|
-
|
|
1749
|
-
def endless_method_split(body)
|
|
1750
|
-
depth = 0
|
|
1751
|
-
quote = nil
|
|
1752
|
-
escape = false
|
|
1753
|
-
|
|
1754
|
-
body.each_char.with_index do |char, index|
|
|
1755
|
-
if quote
|
|
1756
|
-
escape = char == "\\" && !escape
|
|
1757
|
-
if char == quote && !escape
|
|
1758
|
-
quote = nil
|
|
1759
|
-
elsif char != "\\"
|
|
1760
|
-
escape = false
|
|
1761
|
-
end
|
|
1762
|
-
next
|
|
1763
|
-
end
|
|
1764
|
-
|
|
1765
|
-
case char
|
|
1766
|
-
when "'", '"'
|
|
1767
|
-
quote = char
|
|
1768
|
-
when "(", "[", "{"
|
|
1769
|
-
depth += 1
|
|
1770
|
-
when ")", "]", "}"
|
|
1771
|
-
depth -= 1 if depth.positive?
|
|
1772
|
-
when "="
|
|
1773
|
-
next unless depth.zero? && body[index + 1] == " "
|
|
1774
|
-
|
|
1775
|
-
signature = body[0...index]
|
|
1776
|
-
expression = body[(index + 1)..]
|
|
1777
|
-
return [signature, expression] unless signature.strip.empty? || expression.to_s.strip.empty?
|
|
1778
|
-
end
|
|
1779
|
-
end
|
|
1780
|
-
|
|
1781
|
-
nil
|
|
1782
|
-
end
|
|
1783
|
-
|
|
1784
1300
|
def remove_self_contained_project_assets(client_dir, verbose: false)
|
|
1785
1301
|
assets_root = File.join(client_dir, "assets")
|
|
1786
|
-
legacy_entrypoint = File.join(client_dir, "assets", "main.rb")
|
|
1787
|
-
FileUtils.rm_f(legacy_entrypoint)
|
|
1788
1302
|
removed = false
|
|
1789
1303
|
|
|
1790
1304
|
project_root = File.join(assets_root, self_contained_project_name)
|
|
@@ -1793,12 +1307,6 @@ module Ruflet
|
|
|
1793
1307
|
removed = true
|
|
1794
1308
|
end
|
|
1795
1309
|
|
|
1796
|
-
legacy_root = File.join(assets_root, "ruby_project")
|
|
1797
|
-
if Dir.exist?(legacy_root)
|
|
1798
|
-
FileUtils.rm_rf(legacy_root)
|
|
1799
|
-
removed = true
|
|
1800
|
-
end
|
|
1801
|
-
|
|
1802
1310
|
build_log(verbose, "removed embedded self-contained project assets") if removed
|
|
1803
1311
|
end
|
|
1804
1312
|
|
|
@@ -1809,11 +1317,12 @@ module Ruflet
|
|
|
1809
1317
|
Find.find(root.to_s) do |path|
|
|
1810
1318
|
pathname = Pathname.new(path)
|
|
1811
1319
|
relative = pathname.relative_path_from(root).to_s
|
|
1812
|
-
next if relative.empty?
|
|
1320
|
+
next if relative.empty? || relative == "."
|
|
1813
1321
|
|
|
1814
1322
|
if pathname.directory?
|
|
1815
1323
|
if skip_project_asset_directory?(relative)
|
|
1816
1324
|
Find.prune
|
|
1325
|
+
next
|
|
1817
1326
|
else
|
|
1818
1327
|
next
|
|
1819
1328
|
end
|
|
@@ -1834,8 +1343,7 @@ module Ruflet
|
|
|
1834
1343
|
end
|
|
1835
1344
|
|
|
1836
1345
|
def skip_project_asset_directory?(relative)
|
|
1837
|
-
|
|
1838
|
-
%w[
|
|
1346
|
+
excluded_directories = %w[
|
|
1839
1347
|
.git
|
|
1840
1348
|
.bundle
|
|
1841
1349
|
.dart_tool
|
|
@@ -1850,21 +1358,17 @@ module Ruflet
|
|
|
1850
1358
|
ruflet_client
|
|
1851
1359
|
tmp
|
|
1852
1360
|
vendor
|
|
1853
|
-
]
|
|
1361
|
+
]
|
|
1362
|
+
relative.split(File::SEPARATOR).any? do |component|
|
|
1363
|
+
component.start_with?(".") || excluded_directories.include?(component)
|
|
1364
|
+
end
|
|
1854
1365
|
end
|
|
1855
1366
|
|
|
1856
1367
|
def include_project_asset_file?(relative)
|
|
1857
1368
|
basename = File.basename(relative)
|
|
1369
|
+
return false if basename == ".DS_Store"
|
|
1858
1370
|
return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename)
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
ext = File.extname(relative).downcase
|
|
1862
|
-
return true if %w[.rb .json .yml .yaml].include?(ext)
|
|
1863
|
-
|
|
1864
|
-
first = relative.split(File::SEPARATOR).first
|
|
1865
|
-
return true if first == "assets"
|
|
1866
|
-
|
|
1867
|
-
false
|
|
1371
|
+
true
|
|
1868
1372
|
end
|
|
1869
1373
|
|
|
1870
1374
|
def flutter_target_entrypoint(client_dir, self_contained:)
|
|
@@ -1901,10 +1405,10 @@ module Ruflet
|
|
|
1901
1405
|
def prune_client_pubspec(path, selected_packages)
|
|
1902
1406
|
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
1903
1407
|
deps = (data["dependencies"] || {}).dup
|
|
1408
|
+
optional_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
|
|
1904
1409
|
|
|
1905
1410
|
deps.keys.each do |name|
|
|
1906
|
-
next unless
|
|
1907
|
-
next if name == "flet"
|
|
1411
|
+
next unless optional_packages.include?(name)
|
|
1908
1412
|
next if selected_packages.include?(name)
|
|
1909
1413
|
|
|
1910
1414
|
deps.delete(name)
|
|
@@ -1914,50 +1418,6 @@ module Ruflet
|
|
|
1914
1418
|
write_pubspec_yaml(path, data)
|
|
1915
1419
|
end
|
|
1916
1420
|
|
|
1917
|
-
def sync_client_flet_packages(client_dir, selected_packages)
|
|
1918
|
-
template_root =
|
|
1919
|
-
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1920
|
-
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1921
|
-
end
|
|
1922
|
-
return unless template_root
|
|
1923
|
-
|
|
1924
|
-
source_root = File.join(template_root, "flet_packages")
|
|
1925
|
-
target_root = File.join(client_dir, "flet_packages")
|
|
1926
|
-
return unless Dir.exist?(source_root)
|
|
1927
|
-
return if File.expand_path(source_root) == File.expand_path(target_root)
|
|
1928
|
-
|
|
1929
|
-
# The repository's standalone client intentionally carries the full
|
|
1930
|
-
# local package catalog. Only generated/template-derived clients are
|
|
1931
|
-
# conditioned for a particular Ruflet application.
|
|
1932
|
-
return if standalone_ruflet_client_source?(client_dir, template_root)
|
|
1933
|
-
|
|
1934
|
-
known_packages = CLIENT_EXTENSION_MAP.values.map { |meta| meta.fetch(:package) }.uniq
|
|
1935
|
-
required_packages = (["flet"] + selected_packages).uniq
|
|
1936
|
-
|
|
1937
|
-
FileUtils.mkdir_p(target_root)
|
|
1938
|
-
required_packages.each do |package_name|
|
|
1939
|
-
source = File.join(source_root, package_name)
|
|
1940
|
-
target = File.join(target_root, package_name)
|
|
1941
|
-
next unless Dir.exist?(source)
|
|
1942
|
-
next if Dir.exist?(target)
|
|
1943
|
-
|
|
1944
|
-
FileUtils.cp_r(source, target)
|
|
1945
|
-
end
|
|
1946
|
-
|
|
1947
|
-
(known_packages - selected_packages).each do |package_name|
|
|
1948
|
-
FileUtils.rm_rf(File.join(target_root, package_name))
|
|
1949
|
-
end
|
|
1950
|
-
end
|
|
1951
|
-
|
|
1952
|
-
def standalone_ruflet_client_source?(client_dir, template_root)
|
|
1953
|
-
client_root = File.expand_path(client_dir)
|
|
1954
|
-
candidates = [
|
|
1955
|
-
File.expand_path("../../ruflet_client", template_root),
|
|
1956
|
-
File.expand_path("../../../../../ruflet_client", __dir__)
|
|
1957
|
-
]
|
|
1958
|
-
candidates.any? { |candidate| File.expand_path(candidate) == client_root }
|
|
1959
|
-
end
|
|
1960
|
-
|
|
1961
1421
|
def sync_client_extension_dependencies(path, selected_packages)
|
|
1962
1422
|
return if selected_packages.empty?
|
|
1963
1423
|
|
|
@@ -1966,30 +1426,14 @@ module Ruflet
|
|
|
1966
1426
|
|
|
1967
1427
|
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
1968
1428
|
deps = (data["dependencies"] || {}).dup
|
|
1969
|
-
if selected_packages.include?("flet_rive")
|
|
1970
|
-
deps.delete("rive")
|
|
1971
|
-
deps.delete("rive_native")
|
|
1972
|
-
end
|
|
1973
1429
|
selected_packages.each do |package_name|
|
|
1974
1430
|
deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
|
|
1975
1431
|
end
|
|
1976
1432
|
|
|
1977
1433
|
data["dependencies"] = deps
|
|
1978
|
-
sync_local_flet_dependency_override(data, deps["flet"])
|
|
1979
1434
|
write_pubspec_yaml(path, data)
|
|
1980
1435
|
end
|
|
1981
1436
|
|
|
1982
|
-
# Git-backed Flet extensions declare their own Flet source. When Ruflet
|
|
1983
|
-
# vendors the core engine locally, force every extension to resolve that
|
|
1984
|
-
# same copy instead of letting Pub reject the mixed sources.
|
|
1985
|
-
def sync_local_flet_dependency_override(data, flet_dependency)
|
|
1986
|
-
return unless flet_dependency.is_a?(Hash) && key_defined?(flet_dependency, "path")
|
|
1987
|
-
|
|
1988
|
-
overrides = data["dependency_overrides"]
|
|
1989
|
-
overrides = data["dependency_overrides"] = {} unless overrides.is_a?(Hash)
|
|
1990
|
-
overrides["flet"] = flet_dependency
|
|
1991
|
-
end
|
|
1992
|
-
|
|
1993
1437
|
def template_client_pubspec_dependencies
|
|
1994
1438
|
template_root =
|
|
1995
1439
|
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
@@ -2018,7 +1462,9 @@ module Ruflet
|
|
|
2018
1462
|
|
|
2019
1463
|
selected_aliases.each do |extension_alias|
|
|
2020
1464
|
import_line = template.lines.find { |line| line.match?(/\sas #{Regexp.escape(extension_alias)};\s*\z/) }
|
|
2021
|
-
extension_line = template.lines.find
|
|
1465
|
+
extension_line = template.lines.find do |line|
|
|
1466
|
+
line.match?(/^\s*#{Regexp.escape(extension_alias)}\.[A-Za-z0-9_]+\(\),\s*$/)
|
|
1467
|
+
end
|
|
2022
1468
|
|
|
2023
1469
|
content = insert_missing_import(content, import_line) if import_line && !content.include?(import_line)
|
|
2024
1470
|
content = insert_missing_extension(content, extension_line) if extension_line && !content.include?(extension_line)
|
|
@@ -2064,25 +1510,37 @@ module Ruflet
|
|
|
2064
1510
|
def prune_client_main(path, selected_aliases)
|
|
2065
1511
|
content = File.read(path)
|
|
2066
1512
|
alias_to_package = {}
|
|
1513
|
+
optional_aliases = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:alias) }.uniq
|
|
2067
1514
|
|
|
2068
|
-
content.scan(%r{^import 'package:(
|
|
1515
|
+
content.scan(%r{^import 'package:([^/]+)/[^']+'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias|
|
|
2069
1516
|
alias_to_package[import_alias] = package_name
|
|
2070
1517
|
end
|
|
1518
|
+
content.scan(%r{^import '([^']+)'\s+as ([a-zA-Z0-9_]+);$}m) do |_source, import_alias|
|
|
1519
|
+
alias_to_package[import_alias] = :local if optional_aliases.include?(import_alias)
|
|
1520
|
+
end
|
|
2071
1521
|
|
|
2072
|
-
content = content.gsub(%r{^import 'package:(
|
|
1522
|
+
content = content.gsub(%r{^import 'package:([^/]+)/[^']+'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
|
|
2073
1523
|
package_name = Regexp.last_match(1)
|
|
2074
1524
|
import_alias = Regexp.last_match(2)
|
|
2075
|
-
if
|
|
1525
|
+
if !optional_aliases.include?(import_alias) || selected_aliases.include?(import_alias)
|
|
1526
|
+
match
|
|
1527
|
+
else
|
|
1528
|
+
""
|
|
1529
|
+
end
|
|
1530
|
+
end
|
|
1531
|
+
content = content.gsub(%r{^import '([^']+)'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
|
|
1532
|
+
import_alias = Regexp.last_match(2)
|
|
1533
|
+
if !optional_aliases.include?(import_alias) || selected_aliases.include?(import_alias)
|
|
2076
1534
|
match
|
|
2077
1535
|
else
|
|
2078
1536
|
""
|
|
2079
1537
|
end
|
|
2080
1538
|
end
|
|
2081
1539
|
|
|
2082
|
-
content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.
|
|
1540
|
+
content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.[A-Za-z0-9_]+\(\),\s*$/) do |match|
|
|
2083
1541
|
extension_alias = Regexp.last_match(2)
|
|
2084
1542
|
package_name = alias_to_package[extension_alias]
|
|
2085
|
-
if package_name.nil? || selected_aliases.include?(extension_alias)
|
|
1543
|
+
if package_name.nil? || !optional_aliases.include?(extension_alias) || selected_aliases.include?(extension_alias)
|
|
2086
1544
|
match
|
|
2087
1545
|
else
|
|
2088
1546
|
""
|