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.
@@ -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
- SERVICE_NATIVE_REQUIREMENTS = {
35
- "audio_recorder" => {
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
- "barometer" => {
77
+ "motion" => {
48
78
  ios_info: {
49
- "NSMotionUsageDescription" => "Motion access is required for barometer readings."
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
- "geolocator" => {
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
- command_env = install_tool_env(tools[:env], client_dir)
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
- return build_tool_env(env, inferred_install_platform, client_dir) if inferred_install_platform
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
- services = Array(config["services"])
1028
- extension_keys = services.map { |v| normalize_extension_key(v) }.compact.uniq
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, extension_keys)
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
- stale_keys = SERVICE_NATIVE_REQUIREMENTS.keys - extension_keys
1046
- remove_service_native_requirements(client_dir, stale_keys)
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 = SERVICE_NATIVE_REQUIREMENTS[key]
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
- updated = content.sub(%r{</dict>}, "#{entry}</dict>")
1146
- File.write(path, updated == content ? "#{content}\n#{entry}" : updated)
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
- project_asset_path = "assets/#{self_contained_project_name}/"
1216
- assets << project_asset_path unless assets.include?(project_asset_path)
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
- current_dependency || "^0.0.3"
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)