ruflet 0.0.14 → 0.0.16
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 +40 -1
- data/lib/ruflet/cli/android_sdk.rb +306 -0
- data/lib/ruflet/cli/build_command.rb +408 -30
- data/lib/ruflet/cli/environment_setup.rb +241 -0
- data/lib/ruflet/cli/extra_command.rb +15 -3
- data/lib/ruflet/cli/flutter_sdk.rb +87 -9
- data/lib/ruflet/cli/new_command.rb +41 -7
- data/lib/ruflet/cli/run_command.rb +106 -167
- data/lib/ruflet/cli.rb +13 -0
- data/lib/ruflet/version.rb +1 -1
- metadata +5 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "find"
|
|
5
5
|
require "json"
|
|
6
|
+
require "open3"
|
|
6
7
|
require "pathname"
|
|
7
8
|
require "rbconfig"
|
|
8
9
|
require "uri"
|
|
@@ -12,6 +13,8 @@ module Ruflet
|
|
|
12
13
|
module CLI
|
|
13
14
|
module BuildCommand
|
|
14
15
|
include FlutterSdk
|
|
16
|
+
include EnvironmentSetup
|
|
17
|
+
include AndroidSdk
|
|
15
18
|
CLIENT_EXTENSION_MAP = {
|
|
16
19
|
"ads" => { package: "flet_ads", alias: "ruflet_ads" },
|
|
17
20
|
"audio" => { package: "flet_audio", alias: "ruflet_audio" },
|
|
@@ -26,13 +29,37 @@ module Ruflet
|
|
|
26
29
|
"lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
|
|
27
30
|
"map" => { package: "flet_map", alias: "ruflet_map" },
|
|
28
31
|
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
32
|
+
"rive" => { package: "flet_rive", alias: "ruflet_rive" },
|
|
29
33
|
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
34
|
+
"spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
|
|
30
35
|
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
31
36
|
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
|
|
32
37
|
}.freeze
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
"
|
|
39
|
+
SERVICE_EXTENSION_MAP = {
|
|
40
|
+
"camera" => %w[camera permission_handler],
|
|
41
|
+
"microphone" => %w[audio_recorder permission_handler],
|
|
42
|
+
"location" => %w[geolocator permission_handler],
|
|
43
|
+
"motion" => %w[permission_handler]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
DEFAULT_SERVICE_NATIVE_REQUIREMENTS = {
|
|
47
|
+
"camera" => {
|
|
48
|
+
android_permissions: ["android.permission.CAMERA"],
|
|
49
|
+
ios_info: {
|
|
50
|
+
"NSCameraUsageDescription" => "Camera access is required for camera experiences."
|
|
51
|
+
},
|
|
52
|
+
macos_info: {
|
|
53
|
+
"NSCameraUsageDescription" => "Camera access is required for camera experiences."
|
|
54
|
+
},
|
|
55
|
+
macos_entitlements: {
|
|
56
|
+
"com.apple.security.device.camera" => true
|
|
57
|
+
},
|
|
58
|
+
ios_permission_definitions: %w[
|
|
59
|
+
PERMISSION_CAMERA=1
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"microphone" => {
|
|
36
63
|
android_permissions: ["android.permission.RECORD_AUDIO"],
|
|
37
64
|
ios_info: {
|
|
38
65
|
"NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
|
|
@@ -42,14 +69,20 @@ module Ruflet
|
|
|
42
69
|
},
|
|
43
70
|
macos_entitlements: {
|
|
44
71
|
"com.apple.security.device.audio-input" => true
|
|
45
|
-
}
|
|
72
|
+
},
|
|
73
|
+
ios_permission_definitions: %w[
|
|
74
|
+
PERMISSION_MICROPHONE=1
|
|
75
|
+
]
|
|
46
76
|
},
|
|
47
|
-
"
|
|
77
|
+
"motion" => {
|
|
48
78
|
ios_info: {
|
|
49
|
-
"NSMotionUsageDescription" => "Motion access is required for
|
|
50
|
-
}
|
|
79
|
+
"NSMotionUsageDescription" => "Motion access is required for motion and sensor readings."
|
|
80
|
+
},
|
|
81
|
+
ios_permission_definitions: %w[
|
|
82
|
+
PERMISSION_SENSORS=1
|
|
83
|
+
]
|
|
51
84
|
},
|
|
52
|
-
"
|
|
85
|
+
"location" => {
|
|
53
86
|
android_permissions: [
|
|
54
87
|
"android.permission.ACCESS_FINE_LOCATION",
|
|
55
88
|
"android.permission.ACCESS_COARSE_LOCATION"
|
|
@@ -62,11 +95,40 @@ module Ruflet
|
|
|
62
95
|
},
|
|
63
96
|
macos_entitlements: {
|
|
64
97
|
"com.apple.security.personal-information.location" => true
|
|
65
|
-
}
|
|
98
|
+
},
|
|
99
|
+
ios_permission_definitions: %w[
|
|
100
|
+
PERMISSION_LOCATION=1
|
|
101
|
+
]
|
|
66
102
|
}
|
|
67
103
|
}.freeze
|
|
68
104
|
|
|
69
105
|
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)
|
|
70
132
|
self_contained = args.delete("--self")
|
|
71
133
|
verbose = args.delete("--verbose") || args.delete("-v")
|
|
72
134
|
platform = (args.shift || "").downcase
|
|
@@ -110,6 +172,10 @@ module Ruflet
|
|
|
110
172
|
backend_url = configured_backend_url(config)
|
|
111
173
|
if self_contained
|
|
112
174
|
build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url
|
|
175
|
+
# Pin the embedded entry project so the runtime doesn't have to guess.
|
|
176
|
+
# Apps that bundle nested projects (e.g. ruflet_studio's standalone_apps,
|
|
177
|
+
# each with their own main.rb) otherwise trip auto-discovery.
|
|
178
|
+
build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
|
|
113
179
|
else
|
|
114
180
|
unless backend_url
|
|
115
181
|
warn "build config error: backend_url is required for server-driven builds"
|
|
@@ -145,8 +211,16 @@ module Ruflet
|
|
|
145
211
|
end
|
|
146
212
|
|
|
147
213
|
tools = ensure_flutter!("install", client_dir: client_dir)
|
|
148
|
-
|
|
214
|
+
discovery_env = unbundled_command_env(tools[:env])
|
|
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
|
+
|
|
149
222
|
install_platform = install_platform_for_device(device_id)
|
|
223
|
+
command_env = install_tool_env(tools[:env], client_dir, platform: install_platform)
|
|
150
224
|
unless sync_built_outputs_for_install(client_dir, platform: install_platform, verbose: !!verbose)
|
|
151
225
|
warn "Could not find built app outputs under ./build"
|
|
152
226
|
warn "Run `ruflet build ...` first, then `ruflet install`."
|
|
@@ -184,6 +258,59 @@ module Ruflet
|
|
|
184
258
|
nil
|
|
185
259
|
end
|
|
186
260
|
|
|
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
|
+
|
|
187
314
|
def ensure_flutter_client_dir(verbose: false)
|
|
188
315
|
client_dir = detect_flutter_client_dir
|
|
189
316
|
return client_dir if client_dir
|
|
@@ -194,6 +321,9 @@ module Ruflet
|
|
|
194
321
|
end
|
|
195
322
|
|
|
196
323
|
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
|
|
197
327
|
return env unless %w[ios macos].include?(platform)
|
|
198
328
|
|
|
199
329
|
apple_env = unbundled_command_env(env)
|
|
@@ -202,8 +332,9 @@ module Ruflet
|
|
|
202
332
|
apple_env
|
|
203
333
|
end
|
|
204
334
|
|
|
205
|
-
def install_tool_env(env, client_dir)
|
|
206
|
-
|
|
335
|
+
def install_tool_env(env, client_dir, platform: nil)
|
|
336
|
+
platform ||= inferred_install_platform
|
|
337
|
+
return build_tool_env(env, platform, client_dir) if platform
|
|
207
338
|
|
|
208
339
|
command_env = unbundled_command_env(env)
|
|
209
340
|
command_env["PATH"] = apple_build_path(command_env["PATH"])
|
|
@@ -511,7 +642,20 @@ module Ruflet
|
|
|
511
642
|
end
|
|
512
643
|
|
|
513
644
|
def unbundled_command_env(env)
|
|
514
|
-
env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") }
|
|
645
|
+
command_env = env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") }
|
|
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
|
|
515
659
|
end
|
|
516
660
|
|
|
517
661
|
def run_external_command(env, *cmd, chdir:, unbundled: false)
|
|
@@ -923,6 +1067,23 @@ module Ruflet
|
|
|
923
1067
|
cmake_path = File.join(client_dir, "linux", "CMakeLists.txt")
|
|
924
1068
|
replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}")))
|
|
925
1069
|
replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
|
|
1070
|
+
|
|
1071
|
+
# The GTK runner hardcodes the window title (header-bar and fallback
|
|
1072
|
+
# paths). Without this it shows the package name (e.g. "ruflet_client")
|
|
1073
|
+
# instead of the configured app name. Match the call, not the literal,
|
|
1074
|
+
# so re-runs stay idempotent.
|
|
1075
|
+
my_application_path = File.join(client_dir, "linux", "runner", "my_application.cc")
|
|
1076
|
+
title = c_string_escape(metadata[:display_name])
|
|
1077
|
+
replace_in_file(
|
|
1078
|
+
my_application_path,
|
|
1079
|
+
/gtk_header_bar_set_title\(header_bar, "[^"]*"\)/,
|
|
1080
|
+
%(gtk_header_bar_set_title(header_bar, "#{title}"))
|
|
1081
|
+
)
|
|
1082
|
+
replace_in_file(
|
|
1083
|
+
my_application_path,
|
|
1084
|
+
/gtk_window_set_title\(window, "[^"]*"\)/,
|
|
1085
|
+
%(gtk_window_set_title(window, "#{title}"))
|
|
1086
|
+
)
|
|
926
1087
|
end
|
|
927
1088
|
|
|
928
1089
|
def apply_dart_metadata(client_dir, metadata)
|
|
@@ -1015,6 +1176,13 @@ module Ruflet
|
|
|
1015
1176
|
value.to_s.gsub('"', '""')
|
|
1016
1177
|
end
|
|
1017
1178
|
|
|
1179
|
+
# Escape a value for embedding inside a C string literal (used for the
|
|
1180
|
+
# GTK runner window title in my_application.cc). Block form avoids
|
|
1181
|
+
# backslash interpretation in the gsub replacement string.
|
|
1182
|
+
def c_string_escape(value)
|
|
1183
|
+
value.to_s.gsub(/[\\"]/) { |ch| "\\#{ch}" }
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1018
1186
|
def dart_single_quote_escape(value)
|
|
1019
1187
|
value.to_s.gsub("\\", "\\\\\\").gsub("'", "\\\\'")
|
|
1020
1188
|
end
|
|
@@ -1024,11 +1192,15 @@ module Ruflet
|
|
|
1024
1192
|
end
|
|
1025
1193
|
|
|
1026
1194
|
def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
|
|
1027
|
-
|
|
1028
|
-
|
|
1195
|
+
service_definitions = load_service_definitions(client_dir)
|
|
1196
|
+
service_extension_keys = service_definitions.keys.flat_map { |key| Array(SERVICE_EXTENSION_MAP[key]) }.uniq
|
|
1197
|
+
protected_extension_keys = SERVICE_EXTENSION_MAP.values.flatten.uniq
|
|
1198
|
+
configured_extension_keys = Array(config["extensions"]).map { |value| normalize_extension_key(value) }.compact
|
|
1199
|
+
extension_keys = ((configured_extension_keys - protected_extension_keys) | service_extension_keys).uniq
|
|
1029
1200
|
extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
|
|
1030
1201
|
extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
|
|
1031
1202
|
|
|
1203
|
+
sync_client_flet_packages(client_dir, extension_packages)
|
|
1032
1204
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
1033
1205
|
if File.file?(pubspec_path)
|
|
1034
1206
|
sync_client_extension_dependencies(pubspec_path, extension_packages)
|
|
@@ -1038,14 +1210,19 @@ module Ruflet
|
|
|
1038
1210
|
sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
|
|
1039
1211
|
prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
|
|
1040
1212
|
end
|
|
1041
|
-
apply_service_native_requirements(client_dir,
|
|
1213
|
+
apply_service_native_requirements(client_dir, service_definitions.keys, service_definitions)
|
|
1042
1214
|
end
|
|
1043
1215
|
|
|
1044
|
-
def apply_service_native_requirements(client_dir, extension_keys)
|
|
1045
|
-
|
|
1046
|
-
|
|
1216
|
+
def apply_service_native_requirements(client_dir, extension_keys, service_definitions = load_service_definitions(client_dir))
|
|
1217
|
+
service_requirements = service_native_requirements(service_definitions)
|
|
1218
|
+
stale_keys = service_requirements.keys - extension_keys
|
|
1219
|
+
remove_service_native_requirements(client_dir, stale_keys, service_requirements)
|
|
1047
1220
|
|
|
1048
|
-
requirements = merge_service_native_requirements(extension_keys)
|
|
1221
|
+
requirements = merge_service_native_requirements(extension_keys, service_requirements)
|
|
1222
|
+
sync_ios_permission_definitions(
|
|
1223
|
+
File.join(client_dir, "ios", "Podfile"),
|
|
1224
|
+
Array(requirements[:ios_permission_definitions])
|
|
1225
|
+
)
|
|
1049
1226
|
return if requirements.empty?
|
|
1050
1227
|
|
|
1051
1228
|
android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
@@ -1071,8 +1248,8 @@ module Ruflet
|
|
|
1071
1248
|
end
|
|
1072
1249
|
end
|
|
1073
1250
|
|
|
1074
|
-
def remove_service_native_requirements(client_dir, extension_keys)
|
|
1075
|
-
requirements = merge_service_native_requirements(extension_keys)
|
|
1251
|
+
def remove_service_native_requirements(client_dir, extension_keys, service_requirements = service_native_requirements(load_service_definitions(client_dir)))
|
|
1252
|
+
requirements = merge_service_native_requirements(extension_keys, service_requirements)
|
|
1076
1253
|
return if requirements.empty?
|
|
1077
1254
|
|
|
1078
1255
|
android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
@@ -1094,13 +1271,15 @@ module Ruflet
|
|
|
1094
1271
|
end
|
|
1095
1272
|
end
|
|
1096
1273
|
|
|
1097
|
-
def merge_service_native_requirements(extension_keys)
|
|
1274
|
+
def merge_service_native_requirements(extension_keys, service_requirements = DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
|
|
1098
1275
|
extension_keys.each_with_object({}) do |key, memo|
|
|
1099
|
-
requirements =
|
|
1276
|
+
requirements = service_requirements[key]
|
|
1100
1277
|
next unless requirements
|
|
1101
1278
|
|
|
1102
1279
|
memo[:android_permissions] ||= []
|
|
1103
1280
|
memo[:android_permissions] |= Array(requirements[:android_permissions])
|
|
1281
|
+
memo[:ios_permission_definitions] ||= []
|
|
1282
|
+
memo[:ios_permission_definitions] |= Array(requirements[:ios_permission_definitions])
|
|
1104
1283
|
%i[ios_info macos_info macos_entitlements].each do |section|
|
|
1105
1284
|
memo[section] ||= {}
|
|
1106
1285
|
memo[section].merge!(requirements[section] || {})
|
|
@@ -1108,6 +1287,72 @@ module Ruflet
|
|
|
1108
1287
|
end
|
|
1109
1288
|
end
|
|
1110
1289
|
|
|
1290
|
+
def load_service_definitions(client_dir = nil)
|
|
1291
|
+
path = services_config_path(client_dir)
|
|
1292
|
+
return {} unless path
|
|
1293
|
+
|
|
1294
|
+
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
1295
|
+
Array(data["services"]).each_with_object({}) do |entry, memo|
|
|
1296
|
+
name, metadata =
|
|
1297
|
+
if entry.is_a?(Hash)
|
|
1298
|
+
entry.first
|
|
1299
|
+
else
|
|
1300
|
+
[entry, {}]
|
|
1301
|
+
end
|
|
1302
|
+
key = normalize_extension_key(name)
|
|
1303
|
+
memo[key] = metadata.is_a?(Hash) ? metadata : {} if key
|
|
1304
|
+
end
|
|
1305
|
+
rescue Psych::Exception => e
|
|
1306
|
+
warn "Could not parse #{path}: #{e.message}"
|
|
1307
|
+
{}
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
def services_config_path(client_dir = nil)
|
|
1311
|
+
candidates = [ENV["RUFLET_SERVICES"], File.expand_path("services.yaml", Dir.pwd)]
|
|
1312
|
+
candidates << File.join(client_dir, "services.yaml") if client_dir
|
|
1313
|
+
candidates.compact.find { |path| File.file?(path) }
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def service_native_requirements(service_definitions)
|
|
1317
|
+
all_keys = (DEFAULT_SERVICE_NATIVE_REQUIREMENTS.keys | service_definitions.keys)
|
|
1318
|
+
all_keys.each_with_object({}) do |key, memo|
|
|
1319
|
+
defaults = DEFAULT_SERVICE_NATIVE_REQUIREMENTS[key] || {}
|
|
1320
|
+
metadata = service_definitions[key] || {}
|
|
1321
|
+
native = metadata["native"].is_a?(Hash) ? metadata["native"] : metadata
|
|
1322
|
+
memo[key] = {
|
|
1323
|
+
android_permissions: Array(native["android_permissions"] || defaults[:android_permissions]),
|
|
1324
|
+
ios_permission_definitions: Array(native["ios_permission_definitions"] || defaults[:ios_permission_definitions]),
|
|
1325
|
+
ios_info: native["ios_info"].is_a?(Hash) ? native["ios_info"] : (defaults[:ios_info] || {}),
|
|
1326
|
+
macos_info: native["macos_info"].is_a?(Hash) ? native["macos_info"] : (defaults[:macos_info] || {}),
|
|
1327
|
+
macos_entitlements: native["macos_entitlements"].is_a?(Hash) ? native["macos_entitlements"] : (defaults[:macos_entitlements] || {})
|
|
1328
|
+
}
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def sync_ios_permission_definitions(path, definitions)
|
|
1333
|
+
return unless File.file?(path)
|
|
1334
|
+
|
|
1335
|
+
content = File.read(path)
|
|
1336
|
+
marker_pattern = %r{\n?\s*# BEGIN RUFLET PERMISSION DEFINITIONS.*?# END RUFLET PERMISSION DEFINITIONS\n?}m
|
|
1337
|
+
updated = content.gsub(marker_pattern, "\n")
|
|
1338
|
+
definitions = Array(definitions).map(&:to_s).reject(&:empty?).uniq.sort
|
|
1339
|
+
if definitions.any?
|
|
1340
|
+
block = <<~RUBY.chomp
|
|
1341
|
+
# BEGIN RUFLET PERMISSION DEFINITIONS
|
|
1342
|
+
target.build_configurations.each do |config|
|
|
1343
|
+
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
|
1344
|
+
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] += #{definitions.inspect}
|
|
1345
|
+
end
|
|
1346
|
+
# END RUFLET PERMISSION DEFINITIONS
|
|
1347
|
+
RUBY
|
|
1348
|
+
updated = updated.sub(
|
|
1349
|
+
/^(\s*)flutter_additional_ios_build_settings\(target\)\s*$/,
|
|
1350
|
+
"\\0\n\n#{block}"
|
|
1351
|
+
)
|
|
1352
|
+
end
|
|
1353
|
+
File.write(path, updated) unless updated == content
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1111
1356
|
def ensure_android_permission(path, permission)
|
|
1112
1357
|
return unless File.file?(path)
|
|
1113
1358
|
|
|
@@ -1142,8 +1387,23 @@ module Ruflet
|
|
|
1142
1387
|
return if content.include?("<key>#{key}</key>")
|
|
1143
1388
|
|
|
1144
1389
|
entry = "\t<key>#{key}</key>\n\t#{value_xml}\n"
|
|
1145
|
-
|
|
1146
|
-
|
|
1390
|
+
|
|
1391
|
+
# Insert into the ROOT <dict> (opened right after <plist ...>), never the
|
|
1392
|
+
# first nested </dict>. Modern Flutter Info.plists nest a dict inside
|
|
1393
|
+
# UIApplicationSceneManifest, so subbing the first </dict> would bury
|
|
1394
|
+
# top-level keys (e.g. NS*UsageDescription) where iOS can't read them —
|
|
1395
|
+
# which crashes the app the moment a permission is requested.
|
|
1396
|
+
root_open = content.match(%r{<plist\b[^>]*>\s*<dict>}m)
|
|
1397
|
+
updated =
|
|
1398
|
+
if root_open
|
|
1399
|
+
insert_at = root_open.end(0)
|
|
1400
|
+
content[0...insert_at] + "\n#{entry}" + content[insert_at..]
|
|
1401
|
+
elsif (last = content.rindex("</dict>"))
|
|
1402
|
+
content[0...last] + entry + content[last..]
|
|
1403
|
+
else
|
|
1404
|
+
"#{content}\n#{entry}"
|
|
1405
|
+
end
|
|
1406
|
+
File.write(path, updated)
|
|
1147
1407
|
end
|
|
1148
1408
|
|
|
1149
1409
|
def remove_plist_entry(path, key)
|
|
@@ -1207,18 +1467,20 @@ module Ruflet
|
|
|
1207
1467
|
flutter = data["flutter"]
|
|
1208
1468
|
flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
|
|
1209
1469
|
assets = Array(flutter["assets"]).map(&:to_s)
|
|
1470
|
+
project_asset_prefix = "assets/#{self_contained_project_name}/"
|
|
1471
|
+
assets.reject! { |asset| asset.start_with?(project_asset_prefix) }
|
|
1210
1472
|
|
|
1211
1473
|
if self_contained
|
|
1212
1474
|
dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
|
|
1213
1475
|
assets.delete("assets/main.rb")
|
|
1214
1476
|
assets.delete("assets/ruby_project/")
|
|
1215
|
-
|
|
1216
|
-
|
|
1477
|
+
project_asset_relative_paths.each do |relative_path|
|
|
1478
|
+
assets << "#{project_asset_prefix}#{relative_path}"
|
|
1479
|
+
end
|
|
1217
1480
|
else
|
|
1218
1481
|
dependencies.delete("ruby_runtime")
|
|
1219
1482
|
assets.delete("assets/main.rb")
|
|
1220
1483
|
assets.delete("assets/ruby_project/")
|
|
1221
|
-
assets.delete("assets/#{self_contained_project_name}/")
|
|
1222
1484
|
end
|
|
1223
1485
|
|
|
1224
1486
|
flutter["assets"] = assets unless assets.empty?
|
|
@@ -1226,11 +1488,46 @@ module Ruflet
|
|
|
1226
1488
|
write_pubspec_yaml(pubspec_path, data)
|
|
1227
1489
|
end
|
|
1228
1490
|
|
|
1491
|
+
RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.6"
|
|
1492
|
+
|
|
1229
1493
|
def ruby_runtime_dependency(current_dependency = nil)
|
|
1230
|
-
local_path = explicit_local_ruby_runtime_path
|
|
1494
|
+
local_path = explicit_local_ruby_runtime_path || repo_checkout_ruby_runtime_path
|
|
1231
1495
|
return { "path" => local_path } if local_path
|
|
1232
1496
|
|
|
1233
|
-
|
|
1497
|
+
template_dependency = template_client_pubspec_dependencies["ruby_runtime"]
|
|
1498
|
+
return template_dependency if usable_ruby_runtime_dependency?(template_dependency)
|
|
1499
|
+
|
|
1500
|
+
return current_dependency if usable_ruby_runtime_dependency?(current_dependency)
|
|
1501
|
+
|
|
1502
|
+
RUBY_RUNTIME_FALLBACK_REQUIREMENT
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# In a ruflet repo checkout the plugin lives next to templates/; build
|
|
1506
|
+
# against it so framework changes are exercised without publishing.
|
|
1507
|
+
def repo_checkout_ruby_runtime_path
|
|
1508
|
+
template_root =
|
|
1509
|
+
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1510
|
+
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1511
|
+
end
|
|
1512
|
+
return nil unless template_root
|
|
1513
|
+
|
|
1514
|
+
candidate = File.expand_path(File.join(template_root, "..", "..", "ruby_runtime"))
|
|
1515
|
+
File.file?(File.join(candidate, "pubspec.yaml")) ? candidate : nil
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
# Relative path dependencies only resolve from the directory the
|
|
1519
|
+
# template was authored in — never copy them into a user's client.
|
|
1520
|
+
def usable_ruby_runtime_dependency?(dependency)
|
|
1521
|
+
case dependency
|
|
1522
|
+
when nil then false
|
|
1523
|
+
when Hash
|
|
1524
|
+
path = dependency["path"] || dependency[:path]
|
|
1525
|
+
return true if path.nil? # hosted/git table form
|
|
1526
|
+
|
|
1527
|
+
Pathname.new(path.to_s).absolute? && File.file?(File.join(path, "pubspec.yaml"))
|
|
1528
|
+
else
|
|
1529
|
+
!dependency.to_s.strip.empty?
|
|
1530
|
+
end
|
|
1234
1531
|
end
|
|
1235
1532
|
|
|
1236
1533
|
def explicit_local_ruby_runtime_path
|
|
@@ -1271,6 +1568,27 @@ module Ruflet
|
|
|
1271
1568
|
FileUtils.cp(source, destination)
|
|
1272
1569
|
build_log(verbose, "refreshed template file #{relative_path}")
|
|
1273
1570
|
end
|
|
1571
|
+
|
|
1572
|
+
repair_legacy_self_contained_bootstrap(client_dir, verbose: verbose)
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
def repair_legacy_self_contained_bootstrap(client_dir, verbose: false)
|
|
1576
|
+
path = File.join(client_dir, "lib", "main.self.dart")
|
|
1577
|
+
return unless File.file?(path)
|
|
1578
|
+
|
|
1579
|
+
content = File.read(path)
|
|
1580
|
+
updated = content.gsub(
|
|
1581
|
+
/^\s*await RubyRuntime\.eval\("ENV\['RUFLET_DEBUG'\].*?\n/,
|
|
1582
|
+
""
|
|
1583
|
+
)
|
|
1584
|
+
updated = updated.gsub(
|
|
1585
|
+
/^\s*final digestLength = await RubyRuntime\.eval\(\n.*?^\s*\);\n\s*debugPrint\('Embedded Digest::SHA1 bytesize: \$digestLength'\);\n/m,
|
|
1586
|
+
""
|
|
1587
|
+
)
|
|
1588
|
+
return if updated == content
|
|
1589
|
+
|
|
1590
|
+
File.write(path, updated)
|
|
1591
|
+
build_log(verbose, "removed legacy pre-server RubyRuntime.eval diagnostics")
|
|
1274
1592
|
end
|
|
1275
1593
|
|
|
1276
1594
|
def write_pubspec_yaml(path, data)
|
|
@@ -1540,6 +1858,50 @@ module Ruflet
|
|
|
1540
1858
|
write_pubspec_yaml(path, data)
|
|
1541
1859
|
end
|
|
1542
1860
|
|
|
1861
|
+
def sync_client_flet_packages(client_dir, selected_packages)
|
|
1862
|
+
template_root =
|
|
1863
|
+
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1864
|
+
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1865
|
+
end
|
|
1866
|
+
return unless template_root
|
|
1867
|
+
|
|
1868
|
+
source_root = File.join(template_root, "flet_packages")
|
|
1869
|
+
target_root = File.join(client_dir, "flet_packages")
|
|
1870
|
+
return unless Dir.exist?(source_root)
|
|
1871
|
+
return if File.expand_path(source_root) == File.expand_path(target_root)
|
|
1872
|
+
|
|
1873
|
+
# The repository's standalone client intentionally carries the full
|
|
1874
|
+
# local package catalog. Only generated/template-derived clients are
|
|
1875
|
+
# conditioned for a particular Ruflet application.
|
|
1876
|
+
return if standalone_ruflet_client_source?(client_dir, template_root)
|
|
1877
|
+
|
|
1878
|
+
known_packages = CLIENT_EXTENSION_MAP.values.map { |meta| meta.fetch(:package) }.uniq
|
|
1879
|
+
required_packages = (["flet"] + selected_packages).uniq
|
|
1880
|
+
|
|
1881
|
+
FileUtils.mkdir_p(target_root)
|
|
1882
|
+
required_packages.each do |package_name|
|
|
1883
|
+
source = File.join(source_root, package_name)
|
|
1884
|
+
target = File.join(target_root, package_name)
|
|
1885
|
+
next unless Dir.exist?(source)
|
|
1886
|
+
next if Dir.exist?(target)
|
|
1887
|
+
|
|
1888
|
+
FileUtils.cp_r(source, target)
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
(known_packages - selected_packages).each do |package_name|
|
|
1892
|
+
FileUtils.rm_rf(File.join(target_root, package_name))
|
|
1893
|
+
end
|
|
1894
|
+
end
|
|
1895
|
+
|
|
1896
|
+
def standalone_ruflet_client_source?(client_dir, template_root)
|
|
1897
|
+
client_root = File.expand_path(client_dir)
|
|
1898
|
+
candidates = [
|
|
1899
|
+
File.expand_path("../../ruflet_client", template_root),
|
|
1900
|
+
File.expand_path("../../../../../ruflet_client", __dir__)
|
|
1901
|
+
]
|
|
1902
|
+
candidates.any? { |candidate| File.expand_path(candidate) == client_root }
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1543
1905
|
def sync_client_extension_dependencies(path, selected_packages)
|
|
1544
1906
|
return if selected_packages.empty?
|
|
1545
1907
|
|
|
@@ -1548,14 +1910,30 @@ module Ruflet
|
|
|
1548
1910
|
|
|
1549
1911
|
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
1550
1912
|
deps = (data["dependencies"] || {}).dup
|
|
1913
|
+
if selected_packages.include?("flet_rive")
|
|
1914
|
+
deps.delete("rive")
|
|
1915
|
+
deps.delete("rive_native")
|
|
1916
|
+
end
|
|
1551
1917
|
selected_packages.each do |package_name|
|
|
1552
1918
|
deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
|
|
1553
1919
|
end
|
|
1554
1920
|
|
|
1555
1921
|
data["dependencies"] = deps
|
|
1922
|
+
sync_local_flet_dependency_override(data, deps["flet"])
|
|
1556
1923
|
write_pubspec_yaml(path, data)
|
|
1557
1924
|
end
|
|
1558
1925
|
|
|
1926
|
+
# Git-backed Flet extensions declare their own Flet source. When Ruflet
|
|
1927
|
+
# vendors the core engine locally, force every extension to resolve that
|
|
1928
|
+
# same copy instead of letting Pub reject the mixed sources.
|
|
1929
|
+
def sync_local_flet_dependency_override(data, flet_dependency)
|
|
1930
|
+
return unless flet_dependency.is_a?(Hash) && key_defined?(flet_dependency, "path")
|
|
1931
|
+
|
|
1932
|
+
overrides = data["dependency_overrides"]
|
|
1933
|
+
overrides = data["dependency_overrides"] = {} unless overrides.is_a?(Hash)
|
|
1934
|
+
overrides["flet"] = flet_dependency
|
|
1935
|
+
end
|
|
1936
|
+
|
|
1559
1937
|
def template_client_pubspec_dependencies
|
|
1560
1938
|
template_root =
|
|
1561
1939
|
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|