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.
@@ -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
- 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" => {
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 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.
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
- 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
-
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
- return client_dir if client_dir
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, platform: nil)
336
- platform ||= inferred_install_platform
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
- configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
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
- 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
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 load_rails_ruflet_initializer_config unless File.file?(config_path)
602
+ return {} unless File.file?(config_path)
740
603
 
741
- YAML.safe_load(File.read(config_path), aliases: true) || {}
742
- rescue StandardError => e
743
- warn "Failed to load ruflet config: #{e.class}: #{e.message}"
744
- {}
745
- end
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
- rescue LoadError => e
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 Rails Ruflet config: #{e.class}: #{e.message}"
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
- service_definitions = load_service_definitions(client_dir)
1242
- service_extension_keys = service_definitions.keys.flat_map { |key| Array(SERVICE_EXTENSION_MAP[key]) }.uniq
1243
- protected_extension_keys = SERVICE_EXTENSION_MAP.values.flatten.uniq
1244
- configured_extension_keys = Array(config["extensions"]).map { |value| normalize_extension_key(value) }.compact
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 merge_service_native_requirements(extension_keys, service_requirements = DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
1321
- extension_keys.each_with_object({}) do |key, memo|
1322
- requirements = service_requirements[key]
1323
- next unless requirements
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
- memo[:android_permissions] ||= []
1326
- memo[:android_permissions] |= Array(requirements[:android_permissions])
1327
- memo[:ios_permission_definitions] ||= []
1328
- memo[:ios_permission_definitions] |= Array(requirements[:ios_permission_definitions])
1329
- %i[ios_info macos_info macos_entitlements].each do |section|
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 load_service_definitions(client_dir = nil)
1337
- path = services_config_path(client_dir)
1338
- return {} unless path
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
- content = File.read(path)
1382
- marker_pattern = %r{\n?\s*# BEGIN RUFLET PERMISSION DEFINITIONS.*?# END RUFLET PERMISSION DEFINITIONS\n?}m
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 ensure_android_permission(path, permission)
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
- return if content.include?(permission)
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
- permission_line = %( <uses-permission android:name="#{xml_escape(permission)}"/>\n)
1409
- updated = content.sub(/(<manifest\b[^>]*>\s*)/m) { "#{Regexp.last_match(1)}#{permission_line}" }
1410
- File.write(path, updated == content ? "#{permission_line}#{content}" : updated)
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 remove_android_permission(path, permission)
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
- updated = content.gsub(%r{^\s*<uses-permission\s+android:name="#{Regexp.escape(permission)}"\s*/>\s*\n?}, "")
1418
- File.write(path, updated) unless updated == content
1419
- end
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
- def ensure_plist_boolean(path, key, value)
1426
- ensure_plist_entry(path, key, value ? "<true/>" : "<false/>")
1427
- end
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
- content = File.read(path)
1433
- return if content.include?("<key>#{key}</key>")
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
- "#{content}\n#{entry}"
1110
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
1451
1111
  end
1452
- File.write(path, updated)
1453
- end
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
- assets.delete("assets/main.rb")
1522
- assets.delete("assets/ruby_project/")
1523
- project_asset_relative_paths.each do |relative_path|
1524
- assets << "#{project_asset_prefix}#{relative_path}"
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
- assets.delete("assets/main.rb")
1529
- assets.delete("assets/ruby_project/")
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 || repo_checkout_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
- template_dependency = template_client_pubspec_dependencies["ruby_runtime"]
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
- "macos/Runner/DebugProfile.entitlements",
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
- copy_project_asset_file(source.to_s, destination, project_root: project_root.to_s)
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
- first = relative.split(File::SEPARATOR).first
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
- ].include?(first)
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
- return true if %w[main.rb Gemfile ruflet.yaml ruflet.yml manifest.json].include?(basename)
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 name.start_with?("flet_")
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 { |line| line.match?(/^\s*#{Regexp.escape(extension_alias)}\.Extension\(\),\s*$/) }
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:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias|
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:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
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 package_name == "flet" || selected_aliases.include?(import_alias)
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_]+)\.Extension\(\),\s*$/) do |match|
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
  ""