ruflet 0.0.21 → 0.0.22

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "find"
5
+ require "digest"
5
6
  require "json"
6
7
  require "open3"
7
8
  require "pathname"
@@ -14,23 +15,49 @@ module Ruflet
14
15
  module BuildCommand
15
16
  include FlutterSdk
16
17
  CLIENT_EXTENSION_MAP = {
17
- "audio" => { package: "flet_audio", alias: "ruflet_audio" },
18
- "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
19
- "camera" => { package: "flet_camera", alias: "ruflet_camera" },
20
- "charts" => { package: "flet_charts", alias: "ruflet_charts" },
21
- "code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
22
- "color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
23
- "datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
24
- "flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
25
- "geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
26
- "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
27
- "map" => { package: "flet_map", alias: "ruflet_map" },
28
- "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
18
+ "ads" => { package: "ruflet_ads", alias: "ruflet_ads" },
19
+ "audio" => { package: "ruflet_audio", alias: "ruflet_audio" },
20
+ "audio_recorder" => { package: "ruflet_audio_recorder", alias: "ruflet_audio_recorder" },
21
+ "camera" => { package: "ruflet_camera", alias: "ruflet_camera" },
22
+ "charts" => { package: "ruflet_charts", alias: "ruflet_charts" },
23
+ "code_editor" => { package: "ruflet_code_editor", alias: "ruflet_code_editor" },
24
+ "color_pickers" => { package: "ruflet_color_pickers", alias: "ruflet_color_picker" },
25
+ "datatable2" => { package: "ruflet_datatable2", alias: "ruflet_datatable2" },
26
+ "flashlight" => { package: "ruflet_flashlight", alias: "ruflet_flashlight" },
27
+ "geolocator" => { package: "ruflet_geolocator", alias: "ruflet_geolocator" },
28
+ "lottie" => { package: "ruflet_lottie", alias: "ruflet_lottie" },
29
+ "map" => { package: "ruflet_map", alias: "ruflet_map" },
30
+ "permission_handler" => { package: "ruflet_permission_handler", alias: "ruflet_permission_handler" },
29
31
  "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
30
- "rive" => { package: "flet_rive", alias: "ruflet_rive" },
31
- "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
32
- "video" => { package: "flet_video", alias: "ruflet_video" },
33
- "webview" => { package: "flet_webview", alias: "ruflet_webview" }
32
+ "rive" => { package: "ruflet_rive", alias: "ruflet_rive" },
33
+ "secure_storage" => { package: "ruflet_secure_storage", alias: "ruflet_secure_storage" },
34
+ "spinkit" => { package: "ruflet_spinkit", alias: "ruflet_spinkit" },
35
+ "video" => { package: "ruflet_video", alias: "ruflet_video" },
36
+ "webview" => { package: "ruflet_webview", alias: "ruflet_webview" }
37
+ }.freeze
38
+ # Native Swift products matching the Ruflet extensions selected by the
39
+ # ordinary Ruflet build configuration. The same resolved extension keys
40
+ # drive Dart imports, Swift imports/registration, and Xcode linkage.
41
+ NATIVE_APPLE_EXTENSION_MAP = {
42
+ "ads" => { module: "RufletAds", initializer: "RufletAds.Extension()" },
43
+ "audio" => { module: "RufletAudio", initializer: "RufletAudioExtension()" },
44
+ "audio_recorder" => { module: "RufletAudioRecorder", initializer: "RufletAudioRecorderExtension()" },
45
+ "camera" => { module: "RufletCamera", initializer: "RufletCameraExtension()" },
46
+ "charts" => { module: "RufletCharts", initializer: "RufletChartsExtension()" },
47
+ "code_editor" => { module: "RufletCodeEditor", initializer: "RufletCodeEditorExtension()" },
48
+ "color_pickers" => { module: "RufletColorPickers", initializer: "RufletColorPickersExtension()" },
49
+ "datatable2" => { module: "RufletDataTable2", initializer: "RufletDataTable2Extension()" },
50
+ "flashlight" => { module: "RufletFlashlight", initializer: "RufletFlashlightExtension()" },
51
+ "geolocator" => { module: "RufletGeolocator", initializer: "RufletGeolocatorExtension()" },
52
+ "lottie" => { module: "RufletLottie", initializer: "RufletLottieExtension()" },
53
+ "map" => { module: "RufletMap", initializer: "RufletMap.Extension()" },
54
+ "permission_handler" => { module: "RufletPermissionHandler", initializer: "RufletPermissionHandlerExtension()" },
55
+ "qrcode_scanner" => { module: "RufletQRScanner", initializer: "RufletQRScannerExtension()" },
56
+ "rive" => { module: "RufletRive", initializer: "RufletRiveExtension()" },
57
+ "secure_storage" => { module: "RufletSecureStorage", initializer: "RufletSecureStorageExtension()" },
58
+ "spinkit" => { module: "RufletSpinKit", initializer: "RufletSpinKitExtension()" },
59
+ "video" => { module: "RufletVideo", initializer: "RufletVideoExtension()" },
60
+ "webview" => { module: "RufletWebView", initializer: "RufletWebViewExtension()" }
34
61
  }.freeze
35
62
  PROTECTED_SERVICE_EXTENSIONS = {
36
63
  "camera" => %w[camera permission_handler],
@@ -39,8 +66,22 @@ module Ruflet
39
66
  "motion" => %w[permission_handler]
40
67
  }.freeze
41
68
  EXTENSION_REQUIRED_SERVICES = {
69
+ "audio_recorder" => %w[microphone],
70
+ "camera" => %w[camera],
71
+ "flashlight" => %w[camera],
72
+ "geolocator" => %w[location],
42
73
  "qrcode_scanner" => %w[camera]
43
74
  }.freeze
75
+ MANAGED_EXTENSION_STATE_PATH = File.join(".ruflet", "extension_dependencies.json")
76
+ RUFLET_SOURCE_INTEGRITY_PATH = File.join(
77
+ "tool", "conformance", "ruflet_source_integrity.json"
78
+ ).freeze
79
+ RUFLET_SOURCE_IGNORED_DIRECTORIES = %w[
80
+ .git .dart_tool .cache build .pub-cache .idea .vscode coverage
81
+ ].freeze
82
+ RUFLET_SOURCE_IGNORED_FILES = %w[
83
+ pubspec.lock .DS_Store .flutter-plugins .flutter-plugins-dependencies
84
+ ].freeze
44
85
  ANDROID_SERVICE_PERMISSIONS = {
45
86
  "camera" => %w[android.permission.CAMERA],
46
87
  "microphone" => %w[android.permission.RECORD_AUDIO],
@@ -50,9 +91,34 @@ module Ruflet
50
91
  IOS_SERVICE_USAGE_KEYS = {
51
92
  "camera" => "NSCameraUsageDescription",
52
93
  "microphone" => "NSMicrophoneUsageDescription",
53
- "location" => "NSLocationWhenInUseUsageDescription",
94
+ "location" => %w[
95
+ NSLocationWhenInUseUsageDescription
96
+ NSLocationAlwaysAndWhenInUseUsageDescription
97
+ ],
98
+ "motion" => "NSMotionUsageDescription",
99
+ "photo_library" => "NSPhotoLibraryUsageDescription"
100
+ }.freeze
101
+ MACOS_SERVICE_USAGE_KEYS = {
102
+ "camera" => "NSCameraUsageDescription",
103
+ "microphone" => "NSMicrophoneUsageDescription",
104
+ "location" => "NSLocationUsageDescription",
54
105
  "motion" => "NSMotionUsageDescription"
55
106
  }.freeze
107
+ MACOS_SERVICE_ENTITLEMENTS = {
108
+ "camera" => "com.apple.security.device.camera",
109
+ "microphone" => "com.apple.security.device.audio-input",
110
+ "location" => "com.apple.security.personal-information.location"
111
+ }.freeze
112
+ MANAGED_ANDROID_PERMISSIONS = (
113
+ ANDROID_SERVICE_PERMISSIONS.values.flatten +
114
+ %w[android.permission.FLASHLIGHT android.permission.MODIFY_AUDIO_SETTINGS]
115
+ ).uniq.freeze
116
+ MANAGED_IOS_USAGE_KEYS = (
117
+ IOS_SERVICE_USAGE_KEYS.values.flatten +
118
+ %w[NSLocationAlwaysAndWhenInUseUsageDescription NSPhotoLibraryUsageDescription]
119
+ ).uniq.freeze
120
+ MANAGED_MACOS_USAGE_KEYS = MACOS_SERVICE_USAGE_KEYS.values.uniq.freeze
121
+ MANAGED_MACOS_SERVICE_ENTITLEMENTS = MACOS_SERVICE_ENTITLEMENTS.values.uniq.freeze
56
122
  # Clients that are told which server to use at launch rather than at build
57
123
  # time, so they may be built without a configured backend_url.
58
124
  RUNTIME_RESOLVED_BACKEND_PLATFORMS = %w[web macos windows linux].freeze
@@ -68,12 +134,36 @@ module Ruflet
68
134
  "linux" => { splash: false, icon: false }
69
135
  }.freeze
70
136
 
137
+ EMBEDDED_RUNTIME_PROFILES = %i[lite full].freeze
138
+ FULL_RUNTIME_MANIFEST = "ruflet-full-runtime.json"
139
+ FULL_RUNTIME_BUNDLE_SCHEMA = 3
140
+
71
141
  def command_build(args)
72
- self_contained = args.delete("--self")
142
+ self_contained_flag = args.delete("--self")
143
+ lite = args.delete("--lite")
144
+ full = args.delete("--full")
145
+ if lite && full
146
+ warn "build config error: --lite and --full are mutually exclusive"
147
+ return 1
148
+ end
149
+ # --self enables the embedded runtime and defaults its profile to lite.
150
+ # An explicit profile still wins, so --self --full selects full CRuby.
151
+ self_contained = self_contained_flag || lite || full
152
+ runtime_profile = full ? :full : :lite
153
+ @ruflet_runtime_profile = self_contained ? runtime_profile : :server
154
+ @ruflet_runtime_profile_explicit = !!(lite || full)
155
+ experimental = args.delete("--experimental")
156
+ experimental_alias = args.delete("--exp")
157
+ experimental ||= experimental_alias
73
158
  verbose = args.delete("--verbose") || args.delete("-v")
74
159
  platform = (args.shift || "").downcase
75
160
  if platform.empty?
76
- warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--self] [--verbose]"
161
+ warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--lite|--full|--self] [--experimental|--exp] [--verbose]"
162
+ return 1
163
+ end
164
+
165
+ if experimental && !%w[ios ipa macos].include?(platform)
166
+ warn "build config error: --experimental is supported only for ios and macos"
77
167
  return 1
78
168
  end
79
169
 
@@ -87,12 +177,15 @@ module Ruflet
87
177
  # signing, icons, package name — is the same as a plain iOS build.
88
178
  requested_platform = platform
89
179
  platform = "ios" if platform == "ipa"
180
+ @ruflet_build_platform = platform
181
+ @ruflet_experimental_native_renderer = !!experimental
90
182
 
91
183
  # The embedded Ruby VM is a native plugin with no browser
92
184
  # implementation, so a self-contained web build produces an app that
93
185
  # cannot start. Say so rather than shipping one that hangs.
94
186
  if self_contained && platform == "web"
95
- warn "build config error: --self is not supported for web"
187
+ profile_flag = @ruflet_runtime_profile_explicit ? "--#{runtime_profile}" : "--self"
188
+ warn "build config error: #{profile_flag} is not supported for web"
96
189
  warn "A web client runs no embedded Ruby; build it with `ruflet build web`."
97
190
  return 1
98
191
  end
@@ -105,8 +198,13 @@ module Ruflet
105
198
  return 1
106
199
  end
107
200
 
108
- build_note("Preparing #{platform} build (#{self_contained ? 'self-contained' : 'server-driven'})")
201
+ renderer = experimental ? ", experimental native renderer" : ""
202
+ mode = self_contained ? "self-contained #{runtime_profile}" : "server-driven"
203
+ build_note("Preparing #{platform} build (#{mode}#{renderer})")
109
204
  config = load_ruflet_config
205
+ if runtime_profile == :full && self_contained
206
+ return 1 unless configure_full_runtime_distribution(config, platform, verbose: !!verbose)
207
+ end
110
208
  tools = ensure_flutter!("build", client_dir: client_dir)
111
209
  command_env = build_tool_env(tools[:env], platform, client_dir)
112
210
  ok = prepare_flutter_client(
@@ -125,11 +223,13 @@ module Ruflet
125
223
  build_args += ["--target", target_entrypoint] if target_entrypoint
126
224
  backend_url = configured_backend_url(config)
127
225
  if self_contained
128
- build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url
129
- # Pin the embedded project so main.self.dart extracts assets/<name>/
130
- # deterministically instead of inferring from a single main.rb — the
131
- # app tree now ships many main.rb files (standalone_apps/*/main.rb).
226
+ # Pin the embedded project for platform autostart instead of asking
227
+ # the native layer to infer it from every main.rb/main.mrb in the
228
+ # bundled asset tree.
132
229
  build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
230
+ if @ruflet_runtime_profile_explicit
231
+ build_args += ["--dart-define", "RUFLET_RUNTIME_PROFILE=#{runtime_profile}"]
232
+ end
133
233
  elsif backend_url
134
234
  build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
135
235
  elsif RUNTIME_RESOLVED_BACKEND_PLATFORMS.include?(platform)
@@ -143,10 +243,18 @@ module Ruflet
143
243
  warn "Set app.backend_url or backend_url in ruflet.yaml"
144
244
  return 1
145
245
  end
246
+ if experimental
247
+ build_args += ["--dart-define", "RUFLET_EXPERIMENTAL_NATIVE_RENDERER=true"]
248
+ end
146
249
  build_args << "-v" if verbose
147
250
  stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
148
251
 
149
- build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
252
+ logged_mode = if self_contained
253
+ @ruflet_runtime_profile_explicit ? runtime_profile : "self"
254
+ else
255
+ "server"
256
+ end
257
+ build_log(verbose, "mode=#{logged_mode}")
150
258
  build_log(verbose, "client_dir=#{client_dir}")
151
259
  build_log(verbose, "flutter=#{tools[:flutter]}")
152
260
  build_log(verbose, "dart=#{tools[:dart]}")
@@ -155,6 +263,26 @@ module Ruflet
155
263
 
156
264
  build_note("Running Flutter #{build_args.join(' ')}")
157
265
  ok = run_external_command(command_env, tools[:flutter], *build_args, chdir: client_dir, unbundled: true)
266
+
267
+ # A physical iPhone app and a simulator app are different Apple
268
+ # products, but Ruflet users should not have to learn that distinction.
269
+ # Keep both outputs behind the ordinary self-contained iOS build so the
270
+ # following `ruflet install` can target whichever mobile device is
271
+ # connected. An explicit --simulator remains a simulator-only build.
272
+ if ok && self_contained && requested_platform == "ios" &&
273
+ !build_args.include?("--simulator") && !full_runtime_device_only?
274
+ simulator_args = build_args.dup
275
+ simulator_args.delete("--codesign")
276
+ simulator_args.insert(simulator_args.index("ios") + 1, "--simulator")
277
+ stage_ios_simulator_ruby_runtime(client_dir, simulator_args, verbose: !!verbose)
278
+ build_log(verbose, "command=#{([tools[:flutter]] + simulator_args).join(' ')}")
279
+ build_note("Running Flutter #{simulator_args.join(' ')}")
280
+ ok = run_external_command(
281
+ command_env, tools[:flutter], *simulator_args,
282
+ chdir: client_dir, unbundled: true
283
+ )
284
+ end
285
+
158
286
  export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok
159
287
  ok ? 0 : 1
160
288
  end
@@ -198,6 +326,10 @@ module Ruflet
198
326
 
199
327
  private
200
328
 
329
+ def experimental_native_renderer?
330
+ @ruflet_experimental_native_renderer == true
331
+ end
332
+
201
333
  def extract_option_value!(args, *flags)
202
334
  flags.each do |flag|
203
335
  index = args.index(flag)
@@ -213,6 +345,15 @@ module Ruflet
213
345
  def ensure_flutter_client_dir(verbose: false)
214
346
  client_dir = detect_flutter_client_dir
215
347
  if client_dir
348
+ if File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir) &&
349
+ !valid_flutter_client_root?(client_dir) &&
350
+ !File.file?(File.join(client_dir, ".metadata"))
351
+ if Ruflet::CLI.respond_to?(:copy_ruflet_client_template, true)
352
+ Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd)
353
+ client_dir = hidden_flutter_client_dir
354
+ build_log(verbose, "repaired invalid managed Flutter client root")
355
+ end
356
+ end
216
357
  refresh_hidden_flutter_client_template(client_dir, verbose: verbose)
217
358
  return client_dir
218
359
  end
@@ -222,6 +363,11 @@ module Ruflet
222
363
  bootstrapped
223
364
  end
224
365
 
366
+ def valid_flutter_client_root?(path)
367
+ File.file?(File.join(path, "pubspec.yaml")) &&
368
+ File.file?(File.join(path, "lib", "main.dart"))
369
+ end
370
+
225
371
  def refresh_hidden_flutter_client_template(client_dir, verbose: false)
226
372
  return unless File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir)
227
373
  return unless File.file?(File.join(client_dir, ".metadata"))
@@ -437,7 +583,18 @@ module Ruflet
437
583
  end
438
584
 
439
585
  def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
440
- refresh_managed_client_template_files(client_dir, verbose: verbose)
586
+ refreshed = refresh_managed_client_template_files(
587
+ client_dir, platform: platform, verbose: verbose)
588
+ return false if refreshed == false
589
+ unless configure_apple_renderer_integration(
590
+ client_dir, platform: platform, enabled: experimental_native_renderer?,
591
+ verbose: verbose)
592
+ return false
593
+ end
594
+ if experimental_native_renderer?
595
+ sync_application_apple_extensions(
596
+ client_dir, platform: platform, verbose: verbose)
597
+ end
441
598
  metadata = sync_client_metadata(client_dir, config, verbose: verbose)
442
599
  return false unless validate_mobile_app_identity(metadata, platform: platform)
443
600
 
@@ -446,8 +603,22 @@ module Ruflet
446
603
  apply_ios_signing_team(client_dir, config) if %w[ios ipa macos].include?(platform.to_s)
447
604
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
448
605
  return false if configured == false
606
+ configure_native_apple_runtime(
607
+ client_dir, platform: platform, self_contained: self_contained,
608
+ config: config, experimental: experimental_native_renderer?, verbose: verbose)
609
+ configure_android_runtime(
610
+ client_dir, platform: platform, self_contained: self_contained,
611
+ verbose: verbose)
449
612
  @ruflet_self_contained_build = self_contained
450
- apply_service_extension_config(client_dir, config)
613
+ extension_keys = apply_service_extension_config(client_dir, config)
614
+ if @ruflet_extension_selection_applied && !validate_flutter_extension_selection(client_dir)
615
+ return false
616
+ end
617
+ if experimental_native_renderer?
618
+ apply_native_apple_extension_config(
619
+ client_dir, platform: platform, extension_keys: extension_keys,
620
+ verbose: verbose)
621
+ end
451
622
  asset_flags = apply_build_config(client_dir, config)
452
623
  if asset_flags[:error]
453
624
  warn asset_flags[:error]
@@ -465,6 +636,9 @@ module Ruflet
465
636
  warn "flutter pub get failed"
466
637
  return false
467
638
  end
639
+ if @ruflet_extension_selection_applied && !validate_local_ruflet_packages(client_dir)
640
+ return false
641
+ end
468
642
 
469
643
  unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
470
644
  return false
@@ -601,7 +775,11 @@ module Ruflet
601
775
  return true unless build_args.include?("ios")
602
776
  return true unless build_args.include?("--simulator")
603
777
 
604
- runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
778
+ runtime_root = if embedded_runtime_profile == :full
779
+ @ruflet_full_runtime_path
780
+ else
781
+ explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
782
+ end
605
783
  return true unless runtime_root
606
784
 
607
785
  source = File.join(
@@ -1226,10 +1404,10 @@ module Ruflet
1226
1404
  android_application_id: normalize_bundle_identifier(
1227
1405
  first_present(app["android_application_id"], config["android_application_id"], bundle_identifier)
1228
1406
  ),
1229
- ios_bundle_identifier: normalize_bundle_identifier(
1407
+ ios_bundle_identifier: normalize_apple_bundle_identifier(
1230
1408
  first_present(app["ios_bundle_identifier"], config["ios_bundle_identifier"], bundle_identifier)
1231
1409
  ),
1232
- macos_bundle_identifier: normalize_bundle_identifier(
1410
+ macos_bundle_identifier: normalize_apple_bundle_identifier(
1233
1411
  first_present(app["macos_bundle_identifier"], config["macos_bundle_identifier"], bundle_identifier)
1234
1412
  ),
1235
1413
  linux_application_id: normalize_bundle_identifier(
@@ -1508,6 +1686,20 @@ module Ruflet
1508
1686
  segments.join(".")
1509
1687
  end
1510
1688
 
1689
+ def normalize_apple_bundle_identifier(value)
1690
+ segments = value.to_s.strip.downcase.split(".").map do |segment|
1691
+ normalized = segment.gsub(/[^a-z0-9-]+/, "-")
1692
+ normalized.gsub!(/\A-+|-+\z/, "")
1693
+ normalized.gsub!(/-+/, "-")
1694
+ normalized = "app" if normalized.empty?
1695
+ normalized = "app-#{normalized}" if normalized.match?(/\A\d/)
1696
+ normalized
1697
+ end
1698
+ segments.reject!(&:empty?)
1699
+ segments = %w[com example app] if segments.empty?
1700
+ segments.join(".")
1701
+ end
1702
+
1511
1703
  def humanize_name(name)
1512
1704
  name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
1513
1705
  end
@@ -1538,28 +1730,53 @@ module Ruflet
1538
1730
  end
1539
1731
 
1540
1732
  def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
1541
- services = configured_service_entries(config).map { |entry| entry[:name] }
1542
- requested_extensions = Array(config["extensions"]).map { |value| normalize_extension_key(value) }.compact
1543
- protected_extensions = services.flat_map { |name| PROTECTED_SERVICE_EXTENSIONS.fetch(name, []) }
1544
- extension_keys = (requested_extensions + protected_extensions + services).uniq
1545
- extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1546
- extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1547
-
1548
- external = external_extension_entries(config)
1733
+ extension_keys = configured_extension_keys(config)
1734
+ # The experimental Apple host renders these extensions in Swift. Keep
1735
+ # the resolved keys for native registration, but do not also ship the
1736
+ # equivalent Dart plugins and their native Flutter frameworks.
1737
+ flutter_extension_keys = experimental_native_renderer? ? [] : extension_keys
1738
+ extension_packages = flutter_extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1739
+ extension_aliases = flutter_extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1740
+
1741
+ configured_external = external_extension_entries(config)
1742
+ external = experimental_native_renderer? ? [] : configured_external
1743
+ previous_external_names = managed_external_extension_names(client_dir)
1744
+ discovered_external_names = discover_registered_external_extension_names(client_dir)
1745
+ removable_external_names = (
1746
+ previous_external_names + discovered_external_names + configured_external.map { |entry| entry[:name] }
1747
+ ).uniq
1549
1748
 
1550
1749
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1551
1750
  if File.file?(pubspec_path)
1552
1751
  sync_client_extension_dependencies(pubspec_path, extension_packages)
1553
1752
  prune_client_pubspec(pubspec_path, extension_packages)
1554
- sync_external_extension_dependencies(pubspec_path, external)
1753
+ sync_external_extension_dependencies(
1754
+ pubspec_path,
1755
+ external,
1756
+ managed_names: removable_external_names
1757
+ )
1555
1758
  end
1759
+ sync_client_package_directories(client_dir, extension_packages)
1556
1760
  client_entrypoint_paths(client_dir).each do |entrypoint|
1557
1761
  next unless File.file?(entrypoint)
1558
1762
 
1559
1763
  sync_client_main_extensions(entrypoint, extension_aliases)
1560
1764
  prune_client_main(entrypoint, extension_aliases)
1765
+ prune_external_extension_registrations(
1766
+ entrypoint,
1767
+ removable_external_names - external.map { |entry| entry[:name] }
1768
+ )
1561
1769
  sync_external_extension_registrations(entrypoint, external)
1562
1770
  end
1771
+ write_managed_external_extension_names(client_dir, external.map { |entry| entry[:name] })
1772
+ @ruflet_selected_flutter_extension_packages = extension_packages
1773
+ @ruflet_selected_external_extension_packages = external.map { |entry| entry[:name] }
1774
+ @ruflet_managed_external_extension_packages = removable_external_names
1775
+ @ruflet_extension_selection_applied = true
1776
+ if experimental_native_renderer? && (!extension_keys.empty? || !configured_external.empty?)
1777
+ build_note("Native Apple extensions selected; excluded equivalent Flutter plugins from the app bundle")
1778
+ end
1779
+ extension_keys
1563
1780
  end
1564
1781
 
1565
1782
  # An extension may name a package the template does not bundle, declared
@@ -1617,18 +1834,97 @@ module Ruflet
1617
1834
  nil
1618
1835
  end
1619
1836
 
1620
- def sync_external_extension_dependencies(pubspec_path, entries)
1621
- return if entries.empty?
1622
-
1837
+ def sync_external_extension_dependencies(pubspec_path, entries, managed_names: [])
1623
1838
  data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1624
1839
  dependencies = (data["dependencies"] || {}).dup
1840
+ selected_names = entries.map { |entry| entry[:name] }
1841
+ Array(managed_names).each do |name|
1842
+ dependencies.delete(name) unless selected_names.include?(name)
1843
+ end
1625
1844
  entries.each { |entry| dependencies[entry[:name]] = entry[:dependency] }
1626
1845
  data["dependencies"] = dependencies
1627
1846
  write_pubspec_yaml(pubspec_path, data)
1628
- build_note("Added #{entries.map { |e| e[:name] }.join(', ')} from the extension configuration")
1847
+ build_note("Added #{selected_names.join(', ')} from the extension configuration") unless selected_names.empty?
1848
+ end
1849
+
1850
+ def managed_extension_state_path(client_dir)
1851
+ File.join(client_dir, MANAGED_EXTENSION_STATE_PATH)
1629
1852
  end
1630
1853
 
1631
- # Flet extension packages expose an Extension class from a library named
1854
+ def managed_external_extension_names(client_dir)
1855
+ path = managed_extension_state_path(client_dir)
1856
+ return [] unless File.file?(path)
1857
+
1858
+ data = JSON.parse(read_text_file(path))
1859
+ Array(data["external_packages"]).map(&:to_s).reject(&:empty?).uniq
1860
+ rescue StandardError
1861
+ []
1862
+ end
1863
+
1864
+ def write_managed_external_extension_names(client_dir, names)
1865
+ path = managed_extension_state_path(client_dir)
1866
+ FileUtils.mkdir_p(File.dirname(path))
1867
+ write_text_file(path, JSON.pretty_generate("external_packages" => Array(names).map(&:to_s).uniq.sort))
1868
+ end
1869
+
1870
+ def discover_registered_external_extension_names(client_dir)
1871
+ known_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1872
+ client_entrypoint_paths(client_dir).flat_map do |path|
1873
+ next [] unless File.file?(path)
1874
+
1875
+ content = read_text_file(path)
1876
+ content.scan(%r{import 'package:([^/]+)/[^']+'\s+as\s+([A-Za-z0-9_]+);}).filter_map do |package, import_alias|
1877
+ next if known_packages.include?(package)
1878
+ next unless content.match?(/^\s*#{Regexp.escape(import_alias)}\.Extension\(\),\s*$/)
1879
+
1880
+ package
1881
+ end
1882
+ end.uniq
1883
+ end
1884
+
1885
+ def prune_external_extension_registrations(path, package_names)
1886
+ return if package_names.empty?
1887
+
1888
+ content = read_text_file(path)
1889
+ original = content.dup
1890
+ package_names.each do |package|
1891
+ escaped = Regexp.escape(package)
1892
+ aliases = content.scan(%r{^import 'package:#{escaped}/[^']+'\s+as\s+([A-Za-z0-9_]+);\s*$}).flatten
1893
+ content.gsub!(%r{^import 'package:#{escaped}/[^']+'\s+as\s+[A-Za-z0-9_]+;\s*\n}, "")
1894
+ aliases.each do |import_alias|
1895
+ content.gsub!(/^\s*#{Regexp.escape(import_alias)}\.Extension\(\),\s*\n/, "")
1896
+ end
1897
+ end
1898
+ write_text_file(path, content) unless content == original
1899
+ end
1900
+
1901
+ def validate_flutter_extension_selection(client_dir)
1902
+ selected = Array(@ruflet_selected_flutter_extension_packages)
1903
+ selected_external = Array(@ruflet_selected_external_extension_packages)
1904
+ pubspec_path = File.join(client_dir, "pubspec.yaml")
1905
+ return true unless File.file?(pubspec_path)
1906
+
1907
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1908
+ dependencies = data["dependencies"].is_a?(Hash) ? data["dependencies"].keys.map(&:to_s) : []
1909
+ optional = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1910
+ unexpected = (dependencies & optional) - selected
1911
+ missing = selected - dependencies
1912
+ managed_external = Array(@ruflet_managed_external_extension_packages)
1913
+ unexpected_external = (dependencies & managed_external) - selected_external
1914
+ errors = []
1915
+ errors << "unused Flutter extensions remain: #{unexpected.join(', ')}" unless unexpected.empty?
1916
+ errors << "selected Flutter extensions are missing: #{missing.join(', ')}" unless missing.empty?
1917
+ errors << "unused external Flutter extensions remain: #{unexpected_external.join(', ')}" unless unexpected_external.empty?
1918
+ return true if errors.empty?
1919
+
1920
+ errors.each { |message| warn "build config error: #{message}" }
1921
+ false
1922
+ rescue StandardError => e
1923
+ warn "build config error: could not verify Flutter extension pruning: #{e.message}"
1924
+ false
1925
+ end
1926
+
1927
+ # Ruflet extension packages expose an Extension class from a library named
1632
1928
  # after the package, so the import and registration can be derived.
1633
1929
  def sync_external_extension_registrations(path, entries)
1634
1930
  return if entries.empty?
@@ -1665,6 +1961,28 @@ module Ruflet
1665
1961
  end
1666
1962
  end
1667
1963
 
1964
+ def configured_service_entries_with_requirements(config)
1965
+ entries = configured_service_entries(config)
1966
+ configured_extensions = Array(config["extensions"]).filter_map do |entry|
1967
+ normalize_extension_key(entry) unless entry.is_a?(Hash)
1968
+ end
1969
+ configured_extensions.flat_map do |extension|
1970
+ EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
1971
+ end.each do |service|
1972
+ entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
1973
+ end
1974
+ entries
1975
+ end
1976
+
1977
+ def configured_extension_keys(config)
1978
+ services = configured_service_entries(config).map { |entry| entry[:name] }
1979
+ requested_extensions = Array(config["extensions"]).filter_map do |value|
1980
+ normalize_extension_key(value) unless value.is_a?(Hash)
1981
+ end
1982
+ protected_extensions = services.flat_map { |name| PROTECTED_SERVICE_EXTENSIONS.fetch(name, []) }
1983
+ (requested_extensions + protected_extensions + services).uniq
1984
+ end
1985
+
1668
1986
  ANDROID_SIGNING_KEYS = {
1669
1987
  "storeFile" => "RUFLET_ANDROID_KEYSTORE",
1670
1988
  "storePassword" => "RUFLET_ANDROID_KEYSTORE_PASSWORD",
@@ -1739,18 +2057,10 @@ module Ruflet
1739
2057
  end
1740
2058
 
1741
2059
  def apply_native_service_permissions(client_dir, config)
1742
- entries = configured_service_entries(config)
1743
- configured_extensions = Array(config["extensions"]).filter_map { |entry| normalize_extension_key(entry) }
1744
- extension_services = configured_extensions.flat_map do |extension|
1745
- EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
1746
- end
1747
- extension_services.each do |service|
1748
- entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
1749
- end
1750
- return if entries.empty?
1751
-
2060
+ entries = configured_service_entries_with_requirements(config)
1752
2061
  apply_android_service_permissions(client_dir, entries)
1753
2062
  apply_ios_service_usage_descriptions(client_dir, entries)
2063
+ apply_macos_service_permissions(client_dir, entries)
1754
2064
  end
1755
2065
 
1756
2066
  def apply_android_service_permissions(client_dir, entries)
@@ -1758,6 +2068,12 @@ module Ruflet
1758
2068
  return unless File.file?(path)
1759
2069
 
1760
2070
  content = read_text_file(path)
2071
+ MANAGED_ANDROID_PERMISSIONS.each do |permission|
2072
+ content.gsub!(
2073
+ %r{^\s*<uses-permission\b[^>]*android:name="#{Regexp.escape(permission)}"[^>]*/>\s*\n?},
2074
+ ""
2075
+ )
2076
+ end
1761
2077
  entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
1762
2078
  next if content.include?(%(android:name="#{permission}"))
1763
2079
 
@@ -1768,22 +2084,56 @@ module Ruflet
1768
2084
 
1769
2085
  def apply_ios_service_usage_descriptions(client_dir, entries)
1770
2086
  path = File.join(client_dir, "ios", "Runner", "Info.plist")
2087
+ apply_apple_service_usage_descriptions(
2088
+ path, entries, IOS_SERVICE_USAGE_KEYS, MANAGED_IOS_USAGE_KEYS)
2089
+ end
2090
+
2091
+ def apply_macos_service_permissions(client_dir, entries)
2092
+ plist = File.join(client_dir, "macos", "Runner", "Info.plist")
2093
+ apply_apple_service_usage_descriptions(
2094
+ plist, entries, MACOS_SERVICE_USAGE_KEYS, MANAGED_MACOS_USAGE_KEYS)
2095
+
2096
+ %w[DebugProfile.entitlements Release.entitlements].each do |name|
2097
+ path = File.join(client_dir, "macos", "Runner", name)
2098
+ next unless File.file?(path)
2099
+
2100
+ content = read_text_file(path)
2101
+ MANAGED_MACOS_SERVICE_ENTITLEMENTS.each do |key|
2102
+ content.gsub!(
2103
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m,
2104
+ ""
2105
+ )
2106
+ end
2107
+ entries.filter_map { |entry| MACOS_SERVICE_ENTITLEMENTS[entry[:name]] }.uniq.each do |key|
2108
+ pair = "\t<key>#{key}</key>\n\t<true/>\n"
2109
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2110
+ end
2111
+ write_text_file(path, content)
2112
+ end
2113
+ end
2114
+
2115
+ def apply_apple_service_usage_descriptions(path, entries, usage_keys, managed_keys)
1771
2116
  return unless File.file?(path)
1772
2117
 
1773
2118
  content = read_text_file(path)
2119
+ managed_keys.each do |key|
2120
+ content.gsub!(
2121
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m,
2122
+ ""
2123
+ )
2124
+ end
1774
2125
  entries.each do |entry|
1775
- key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
1776
- next unless key
1777
-
1778
2126
  description = entry[:description]
1779
2127
  description = "This app uses #{entry[:name]} access for its Ruflet features." if description.empty?
1780
2128
  escaped_description = xml_escape(description)
1781
- pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
2129
+ Array(usage_keys[entry[:name]]).each do |key|
2130
+ pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
1782
2131
 
1783
- if content.match?(%r{<key>#{Regexp.escape(key)}</key>})
1784
- content.sub!(%r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m, "<key>#{key}</key>\n\t<string>#{escaped_description}</string>")
1785
- else
1786
- content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2132
+ if content.match?(%r{<key>#{Regexp.escape(key)}</key>})
2133
+ content.sub!(%r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m, "<key>#{key}</key>\n\t<string>#{escaped_description}</string>")
2134
+ else
2135
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2136
+ end
1787
2137
  end
1788
2138
  end
1789
2139
  write_text_file(path, content)
@@ -1798,12 +2148,25 @@ module Ruflet
1798
2148
  end
1799
2149
 
1800
2150
  def clear_stale_platform_outputs(client_dir, platform, verbose: false)
1801
- return unless platform == "ios"
1802
-
1803
- stale_paths = %w[
1804
- build/ios/Debug-iphonesimulator
1805
- build/ios/iphonesimulator
1806
- ]
2151
+ stale_paths = case platform
2152
+ when "ios"
2153
+ %w[
2154
+ build/ios/Debug-iphonesimulator
2155
+ build/ios/iphonesimulator
2156
+ ]
2157
+ when "android", "apk", "aab"
2158
+ # Flutter and Gradle merge assets incrementally. When a build switches
2159
+ # from full to lite, removed project gems can otherwise survive in the
2160
+ # next APK even though pubspec.yaml and the source asset tree are clean.
2161
+ %w[
2162
+ build/app/intermediates/flutter
2163
+ build/app/intermediates/assets
2164
+ build/app/intermediates/compressed_assets
2165
+ build/app/intermediates/incremental/mergeReleaseAssets
2166
+ ]
2167
+ else
2168
+ []
2169
+ end
1807
2170
 
1808
2171
  stale_paths.each do |relative_path|
1809
2172
  path = File.join(client_dir, relative_path)
@@ -1812,6 +2175,23 @@ module Ruflet
1812
2175
  FileUtils.rm_rf(path)
1813
2176
  build_log(verbose, "cleared stale #{relative_path}")
1814
2177
  end
2178
+
2179
+ return unless platform == "ios"
2180
+
2181
+ # Xcode's incremental App.framework copy does not remove files that
2182
+ # disappeared from a Flutter asset directory. Clear only the generated
2183
+ # embedded project tree so a removed gem/archive cannot survive the
2184
+ # next signed build; native compilation outputs remain reusable.
2185
+ asset_pattern = File.join(
2186
+ client_dir, "build", "ios", "**", "flutter_assets", "assets",
2187
+ self_contained_project_name)
2188
+ Dir.glob(asset_pattern).each do |asset_root|
2189
+ next unless Dir.exist?(asset_root)
2190
+
2191
+ FileUtils.rm_rf(asset_root)
2192
+ relative_path = Pathname.new(asset_root).relative_path_from(Pathname.new(client_dir))
2193
+ build_log(verbose, "cleared stale #{relative_path}")
2194
+ end
1815
2195
  end
1816
2196
 
1817
2197
  def client_entrypoint_paths(client_dir)
@@ -1822,14 +2202,14 @@ module Ruflet
1822
2202
 
1823
2203
  def configure_client_runtime_mode(client_dir, self_contained:, verbose: false)
1824
2204
  build_log(verbose, "configuring #{self_contained ? 'self-contained' : 'server-driven'} runtime")
1825
- sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
1826
2205
  if self_contained
1827
- sync_self_contained_project_assets(client_dir, verbose: verbose)
2206
+ return false unless sync_self_contained_project_assets(client_dir, verbose: verbose)
1828
2207
  remove_local_ruby_runtime_override(client_dir, verbose: verbose)
1829
2208
  else
1830
2209
  remove_self_contained_project_assets(client_dir, verbose: verbose)
1831
2210
  remove_local_ruby_runtime_override(client_dir, verbose: verbose)
1832
2211
  end
2212
+ sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
1833
2213
  true
1834
2214
  end
1835
2215
 
@@ -1840,24 +2220,25 @@ module Ruflet
1840
2220
  data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1841
2221
  dependencies = data["dependencies"]
1842
2222
  dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
1843
- spinkit_dependency = template_client_pubspec_dependencies["flet_spinkit"]
1844
- dependencies["flet_spinkit"] = spinkit_dependency if spinkit_dependency
1845
2223
  flutter = data["flutter"]
1846
2224
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1847
2225
  assets = Array(flutter["assets"]).map(&:to_s)
2226
+ project_prefix = "assets/#{self_contained_project_name}/"
2227
+ # The exact directory list changes between lite and full (most notably
2228
+ # vendor/bundle). Remove the previous profile's generated entries
2229
+ # before adding the directories that exist in this build.
2230
+ assets.reject! { |asset| asset == project_prefix || asset.start_with?(project_prefix) }
1848
2231
 
1849
2232
  if self_contained
1850
2233
  dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
1851
2234
  # Flutter does not recurse into asset directories, so every subdirectory
1852
2235
  # of the embedded project (e.g. standalone_apps/<slug>/) must be listed
1853
2236
  # explicitly or its files never reach the bundle/manifest on device.
1854
- self_contained_project_asset_dirs.each do |dir_entry|
2237
+ self_contained_project_asset_dirs(client_dir).each do |dir_entry|
1855
2238
  assets << dir_entry unless assets.include?(dir_entry)
1856
2239
  end
1857
2240
  else
1858
2241
  dependencies.delete("ruby_runtime")
1859
- project_prefix = "assets/#{self_contained_project_name}/"
1860
- assets.reject! { |a| a.to_s == project_prefix || a.to_s.start_with?(project_prefix) }
1861
2242
  end
1862
2243
 
1863
2244
  flutter["assets"] = assets unless assets.empty?
@@ -1871,12 +2252,69 @@ module Ruflet
1871
2252
  PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.9"
1872
2253
 
1873
2254
  def ruby_runtime_dependency(current_dependency = nil)
2255
+ if embedded_runtime_profile == :full && @ruflet_full_runtime_path
2256
+ return { "path" => @ruflet_full_runtime_path }
2257
+ end
2258
+
1874
2259
  local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
1875
2260
  return { "path" => local_path } if local_path
1876
2261
 
1877
2262
  current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
1878
2263
  end
1879
2264
 
2265
+ def configure_full_runtime_distribution(config, platform, verbose: false)
2266
+ configured = ENV["RUFLET_FULL_RUNTIME_PATH"].to_s.strip
2267
+ build_config = config["build"].is_a?(Hash) ? config["build"] : {}
2268
+ configured = build_config["full_runtime_path"].to_s.strip if configured.empty?
2269
+ candidates = []
2270
+ candidates << File.expand_path(configured, Dir.pwd) unless configured.empty?
2271
+ local_runtime = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
2272
+ candidates << local_runtime if local_runtime
2273
+
2274
+ candidates.compact.uniq.each do |root|
2275
+ manifest_path = File.join(root, FULL_RUNTIME_MANIFEST)
2276
+ pubspec_path = File.join(root, "pubspec.yaml")
2277
+ next unless File.file?(manifest_path) && File.file?(pubspec_path)
2278
+
2279
+ manifest = JSON.parse(read_text_file(manifest_path))
2280
+ next unless manifest["engine"] == "cruby"
2281
+ platforms = Array(manifest["platforms"]).map(&:to_s)
2282
+ target = normalized_runtime_platform(platform)
2283
+ next unless platforms.include?(target)
2284
+
2285
+ pubspec = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
2286
+ next unless pubspec["name"] == "ruby_runtime"
2287
+
2288
+ @ruflet_full_runtime_path = File.expand_path(root)
2289
+ @ruflet_full_runtime_manifest = manifest
2290
+ build_log(
2291
+ verbose,
2292
+ "full CRuby runtime=#{@ruflet_full_runtime_path} ruby=#{manifest['ruby_version']}"
2293
+ )
2294
+ return true
2295
+ rescue JSON::ParserError, Psych::SyntaxError => e
2296
+ build_log(verbose, "ignored invalid full runtime at #{root}: #{e.message}")
2297
+ end
2298
+
2299
+ warn "build config error: --full needs a CRuby runtime distribution for #{normalized_runtime_platform(platform)}"
2300
+ warn "Set RUFLET_FULL_RUNTIME_PATH to a ruby_runtime Flutter package containing #{FULL_RUNTIME_MANIFEST}."
2301
+ warn "The bundled ruby_runtime is the lite mruby engine and will not be mislabeled as CRuby."
2302
+ false
2303
+ end
2304
+
2305
+ def full_runtime_device_only?
2306
+ embedded_runtime_profile == :full &&
2307
+ @ruflet_full_runtime_manifest.is_a?(Hash) &&
2308
+ @ruflet_full_runtime_manifest["device_only"] == true
2309
+ end
2310
+
2311
+ def normalized_runtime_platform(platform)
2312
+ return "android" if %w[apk android aab appbundle].include?(platform.to_s)
2313
+ return "ios" if %w[ios ipa].include?(platform.to_s)
2314
+
2315
+ platform.to_s
2316
+ end
2317
+
1880
2318
  def explicit_local_ruby_runtime_path
1881
2319
  env_path = ENV["RUFLET_RUBY_RUNTIME_PATH"].to_s.strip
1882
2320
  return nil if env_path.empty?
@@ -1894,28 +2332,59 @@ module Ruflet
1894
2332
  nil
1895
2333
  end
1896
2334
 
1897
- def refresh_managed_client_template_files(client_dir, verbose: false)
2335
+ def refresh_managed_client_template_files(client_dir, platform: nil, verbose: false)
1898
2336
  template_root =
1899
2337
  if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
1900
2338
  Ruflet::CLI.send(:resolve_ruflet_client_template_root)
1901
2339
  end
1902
2340
  return unless template_root && Dir.exist?(template_root)
2341
+ return false unless validate_template_ruflet_source_integrity(
2342
+ template_root, verbose: verbose)
2343
+
2344
+ if experimental_native_renderer? && %w[ios ipa macos].include?(platform.to_s)
2345
+ return false unless validate_native_apple_renderer_distribution(
2346
+ template_root, verbose: verbose)
2347
+ end
1903
2348
 
1904
2349
  managed_files = [
1905
2350
  "lib/main.dart",
1906
2351
  "lib/main.self.dart",
1907
2352
  "lib/main.server.dart",
1908
- "lib/ruflet_file_picker_service.dart",
2353
+ "lib/native_renderer.dart",
1909
2354
  "lib/connection_probe.dart",
1910
2355
  "lib/connection_probe_io.dart",
1911
2356
  "lib/connection_probe_stub.dart",
1912
2357
  "ios/Podfile",
2358
+ # Carries the usage descriptions and the local-network/ATS policy a
2359
+ # preview client needs. Without refreshing it, a client generated
2360
+ # before those keys existed keeps a plist that silently blocks
2361
+ # connections to a development server on the local network.
2362
+ "ios/Runner/Info.plist",
1913
2363
  "windows/CMakeLists.txt",
1914
2364
  # Release signing lives here; an existing client would otherwise keep
1915
2365
  # signing release builds with the debug key.
1916
2366
  "android/app/build.gradle.kts"
1917
2367
  ]
1918
2368
 
2369
+ case platform.to_s
2370
+ when "ios", "ipa"
2371
+ managed_files.concat(
2372
+ [
2373
+ "ios/Runner/AppDelegate.swift",
2374
+ "ios/Runner/RufletEngineChoice.swift",
2375
+ "ios/Runner.xcodeproj/project.pbxproj"
2376
+ ]
2377
+ )
2378
+ when "macos"
2379
+ managed_files.concat(
2380
+ [
2381
+ "macos/Runner/MainFlutterWindow.swift",
2382
+ "macos/Runner/RufletEngineChoice.swift",
2383
+ "macos/Runner.xcodeproj/project.pbxproj"
2384
+ ]
2385
+ )
2386
+ end
2387
+
1919
2388
  managed_files.each do |relative_path|
1920
2389
  source = File.join(template_root, relative_path)
1921
2390
  next unless File.file?(source)
@@ -1925,6 +2394,494 @@ module Ruflet
1925
2394
  FileUtils.cp(source, destination)
1926
2395
  build_log(verbose, "refreshed template file #{relative_path}")
1927
2396
  end
2397
+
2398
+ if experimental_native_renderer? && %w[ios ipa macos].include?(platform.to_s)
2399
+ sync_managed_template_tree(
2400
+ template_root, client_dir, "apple_packages/ruflet_apple", verbose: verbose)
2401
+ end
2402
+
2403
+ # Older managed clients carried a macOS-only FilePicker override. Ruflet's
2404
+ # core service owns FilePicker now, so this duplicate must not survive a
2405
+ # template refresh.
2406
+ FileUtils.rm_f(File.join(client_dir, "lib", "ruflet_file_picker_service.dart"))
2407
+ true
2408
+ end
2409
+
2410
+ # Every application build copies its engine and extension packages out of
2411
+ # the template. Validate the pinned inventory before that copy so a stale,
2412
+ # partially updated, or locally contaminated template can never become an
2413
+ # application dependency by accident.
2414
+ def validate_template_ruflet_source_integrity(template_root, verbose: false)
2415
+ packages_root = File.join(template_root, "ruflet_packages")
2416
+ return true unless Dir.exist?(packages_root)
2417
+
2418
+ manifest_path = File.join(template_root, RUFLET_SOURCE_INTEGRITY_PATH)
2419
+ raise "missing #{RUFLET_SOURCE_INTEGRITY_PATH}" unless File.file?(manifest_path)
2420
+
2421
+ manifest = JSON.parse(read_text_file(manifest_path))
2422
+ raise "unsupported source manifest version" unless manifest["manifest_version"] == 5
2423
+ raise "unexpected source package" unless manifest["package_name"] == "ruflet-engine"
2424
+ raise "invalid source revision" unless manifest["source_ref"].to_s.match?(/\A[0-9a-f]{40}\z/)
2425
+
2426
+ files = manifest.fetch("files")
2427
+ raise "empty source inventory" unless files.is_a?(Hash) && !files.empty?
2428
+
2429
+ actual = []
2430
+ Find.find(packages_root) do |path|
2431
+ next if path == packages_root
2432
+
2433
+ relative = path.delete_prefix("#{packages_root}#{File::SEPARATOR}")
2434
+ if RUFLET_SOURCE_IGNORED_DIRECTORIES.include?(File.basename(path))
2435
+ Find.prune if File.directory?(path)
2436
+ next
2437
+ end
2438
+ next if RUFLET_SOURCE_IGNORED_FILES.include?(File.basename(path))
2439
+ next if File.directory?(path)
2440
+ raise "symbolic link in engine source: #{path}" if File.symlink?(path)
2441
+
2442
+ actual << relative
2443
+ end
2444
+ expected = files.keys.sort
2445
+ unless actual.sort == expected
2446
+ missing = expected - actual
2447
+ unexpected = actual - expected
2448
+ details = []
2449
+ details << "missing: #{missing.first(5).join(', ')}" unless missing.empty?
2450
+ details << "unexpected: #{unexpected.first(5).join(', ')}" unless unexpected.empty?
2451
+ raise "source inventory mismatch (#{details.join('; ')})"
2452
+ end
2453
+
2454
+ files.each do |relative, entry|
2455
+ path = File.expand_path(relative, packages_root)
2456
+ prefix = "#{File.expand_path(packages_root)}#{File::SEPARATOR}"
2457
+ raise "unsafe source path: #{relative}" unless path.start_with?(prefix)
2458
+ raise "source path mismatch: #{relative}" unless entry["source_path"] == relative
2459
+ raise "non-exact source entry: #{relative}" unless entry["classification"] == "exact_source"
2460
+
2461
+ expected_sha = entry["vendored_sha256"].to_s
2462
+ raise "invalid source digest: #{relative}" unless expected_sha.match?(/\A[0-9a-f]{64}\z/)
2463
+ actual_sha = Digest::SHA256.file(path).hexdigest
2464
+ raise "source content drift: #{relative}" unless actual_sha == expected_sha
2465
+ end
2466
+
2467
+ build_note(
2468
+ "Verified #{files.length} pinned Ruflet engine and extension source files " \
2469
+ "at #{manifest.fetch('source_ref')}"
2470
+ )
2471
+ true
2472
+ rescue StandardError => e
2473
+ warn "build config error: Ruflet template source verification failed: #{e.message}"
2474
+ build_log(verbose, "template source integrity failure at #{template_root}")
2475
+ false
2476
+ end
2477
+
2478
+ # The standalone client template owns the editable Apple renderer. Build
2479
+ # clients receive an exact managed copy from that package; there is no
2480
+ # second renderer in this repository and no fallback implementation.
2481
+ def validate_native_apple_renderer_distribution(template_root, verbose: false)
2482
+ package_root = File.join(template_root, "apple_packages", "ruflet_apple")
2483
+ required = [
2484
+ "Package.swift",
2485
+ "Sources/RufletEngine/RufletApp.swift",
2486
+ "Sources/RufletEngine/RufletAppView.swift"
2487
+ ]
2488
+ missing = required.reject { |relative| File.file?(File.join(package_root, relative)) }
2489
+ return true if missing.empty?
2490
+
2491
+ warn "build config error: the authoritative Apple renderer is incomplete"
2492
+ missing.each { |relative| warn " missing: apple_packages/ruflet_apple/#{relative}" }
2493
+ false
2494
+ end
2495
+
2496
+ def configure_apple_renderer_integration(client_dir, platform:, enabled:, verbose: false)
2497
+ apple_platform = case platform.to_s
2498
+ when "ios", "ipa" then "ios"
2499
+ when "macos" then "macos"
2500
+ end
2501
+ return true unless apple_platform
2502
+
2503
+ if enabled
2504
+ package = File.join(client_dir, "apple_packages", "ruflet_apple", "Package.swift")
2505
+ choice = File.join(client_dir, apple_platform, "Runner", "RufletEngineChoice.swift")
2506
+ unless File.file?(package) && File.file?(choice)
2507
+ warn "build config error: the Ruflet client template does not contain the native Apple renderer"
2508
+ warn "Refresh the Ruflet template, then run the build again."
2509
+ return false
2510
+ end
2511
+ configure_ios_scene_delegate(client_dir, native: true) if apple_platform == "ios"
2512
+ build_log(verbose, "enabled experimental native Apple renderer")
2513
+ return true
2514
+ end
2515
+
2516
+ unless restore_flutter_apple_host(client_dir, apple_platform, verbose: verbose)
2517
+ return false
2518
+ end
2519
+ strip_native_apple_xcode_project(client_dir, apple_platform)
2520
+ FileUtils.rm_f(File.join(client_dir, apple_platform, "Runner", "RufletEngineChoice.swift"))
2521
+ FileUtils.rm_rf(File.join(client_dir, "apple_packages"))
2522
+ FileUtils.rm_rf(File.join(client_dir, "apple_extensions"))
2523
+ configure_ios_scene_delegate(client_dir, native: false) if apple_platform == "ios"
2524
+ apple_info_plist_paths(client_dir).each do |path|
2525
+ remove_plist_value(path, "RufletExperimentalNativeRenderer")
2526
+ remove_plist_value(path, "GADApplicationIdentifier")
2527
+ end
2528
+ build_log(verbose, "using Flutter renderer; removed native Apple package integration")
2529
+ true
2530
+ end
2531
+
2532
+ def restore_flutter_apple_host(client_dir, platform, verbose: false)
2533
+ template_root = if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
2534
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
2535
+ end
2536
+ filename = platform == "ios" ? "AppDelegate.swift" : "MainFlutterWindow.swift"
2537
+ source = template_root && File.join(template_root, "flutter_hosts", platform, "Runner", filename)
2538
+ unless source && File.file?(source)
2539
+ warn "build config error: the Ruflet client template is missing the Flutter-only #{platform} host"
2540
+ return false
2541
+ end
2542
+
2543
+ destination = File.join(client_dir, platform, "Runner", filename)
2544
+ FileUtils.mkdir_p(File.dirname(destination))
2545
+ FileUtils.cp(source, destination)
2546
+ build_log(verbose, "restored Flutter-only #{platform} host")
2547
+ true
2548
+ end
2549
+
2550
+ def strip_native_apple_xcode_project(client_dir, platform)
2551
+ path = File.join(client_dir, platform, "Runner.xcodeproj", "project.pbxproj")
2552
+ return unless File.file?(path)
2553
+
2554
+ content = read_text_file(path)
2555
+ content.gsub!(
2556
+ %r{^\s*[A-F0-9]+ /\* XCLocalSwiftPackageReference "\.\./apple_(?:extensions|packages/ruflet_apple)" \*/ = \{.*?^\s*\};\n}m,
2557
+ ""
2558
+ )
2559
+ content = content.lines.reject do |line|
2560
+ line.include?("Ruflet") ||
2561
+ line.include?("../apple_extensions") ||
2562
+ line.include?("../apple_packages/ruflet_apple")
2563
+ end.join
2564
+ write_text_file(path, content)
2565
+ end
2566
+
2567
+ def configure_ios_scene_delegate(client_dir, native:)
2568
+ path = File.join(client_dir, "ios", "Runner", "Info.plist")
2569
+ return unless File.file?(path)
2570
+
2571
+ content = read_text_file(path)
2572
+ delegate = native ? "RufletSceneDelegate" : "SceneDelegate"
2573
+ content.gsub!(
2574
+ %r{(<key>UISceneDelegateClassName</key>\s*<string>)\$\(PRODUCT_MODULE_NAME\)\.(?:RufletSceneDelegate|FlutterSceneDelegate|SceneDelegate)(</string>)}m,
2575
+ "\\1$(PRODUCT_MODULE_NAME).#{delegate}\\2"
2576
+ )
2577
+ write_text_file(path, content)
2578
+ end
2579
+
2580
+ def apple_info_plist_paths(client_dir)
2581
+ [
2582
+ File.join(client_dir, "ios", "Runner", "Info.plist"),
2583
+ File.join(client_dir, "macos", "Runner", "Info.plist")
2584
+ ].select { |path| File.file?(path) }
2585
+ end
2586
+
2587
+ def remove_plist_value(path, key)
2588
+ content = read_text_file(path)
2589
+ content.gsub!(
2590
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*(?:<string>.*?</string>|<(?:true|false)\s*/>)}m,
2591
+ ""
2592
+ )
2593
+ write_text_file(path, content)
2594
+ end
2595
+
2596
+ def remove_plist_dictionary_boolean(path, dictionary_key, key)
2597
+ content = read_text_file(path)
2598
+ dictionary = content.match(
2599
+ %r{<key>#{Regexp.escape(dictionary_key)}</key>\s*<dict>}m)
2600
+ return unless dictionary
2601
+
2602
+ opening = content.index("<dict>", dictionary.begin(0))
2603
+ return unless opening
2604
+
2605
+ depth = 0
2606
+ closing = nil
2607
+ content.to_enum(:scan, %r{</?dict>}).each do
2608
+ token = Regexp.last_match
2609
+ next if token.begin(0) < opening
2610
+
2611
+ depth += token[0] == "<dict>" ? 1 : -1
2612
+ if depth.zero?
2613
+ closing = token
2614
+ break
2615
+ end
2616
+ end
2617
+ return unless closing
2618
+
2619
+ body_start = opening + "<dict>".length
2620
+ body = content[body_start...closing.begin(0)]
2621
+ updated_body = body.gsub(
2622
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m,
2623
+ ""
2624
+ )
2625
+ return if updated_body == body
2626
+
2627
+ if updated_body.strip.empty?
2628
+ pair_start = dictionary.begin(0)
2629
+ line_start = content.rindex("\n", pair_start - 1)
2630
+ if line_start && content[(line_start + 1)...pair_start].strip.empty?
2631
+ pair_start = line_start + 1
2632
+ end
2633
+ content[pair_start...closing.end(0)] = ""
2634
+ else
2635
+ content[body_start...closing.begin(0)] = updated_body
2636
+ end
2637
+ write_text_file(path, content)
2638
+ end
2639
+
2640
+ def apply_native_apple_extension_config(
2641
+ client_dir, platform:, extension_keys:, verbose: false
2642
+ )
2643
+ apple_platform = %w[ios ipa].include?(platform.to_s) ? "ios" : platform.to_s
2644
+ return unless %w[ios macos].include?(apple_platform)
2645
+
2646
+ selected = Array(extension_keys).filter_map do |key|
2647
+ spec = NATIVE_APPLE_EXTENSION_MAP[key]
2648
+ [key, spec] if spec
2649
+ end
2650
+ selected_modules = selected.map { |_key, spec| spec.fetch(:module) }
2651
+ unselected_modules = NATIVE_APPLE_EXTENSION_MAP.values
2652
+ .map { |spec| spec.fetch(:module) }
2653
+ .uniq - selected_modules
2654
+
2655
+ choice_path = File.join(client_dir, apple_platform, "Runner", "RufletEngineChoice.swift")
2656
+ if File.file?(choice_path)
2657
+ imports = selected_modules.map { |name| "import #{name}" }.join("\n")
2658
+ registrations = selected.map do |_key, spec|
2659
+ " result.append(#{spec.fetch(:initializer)})"
2660
+ end.join("\n")
2661
+ source = <<~SWIFT
2662
+ import Foundation
2663
+ import RufletApple
2664
+ import RufletAppExtensions
2665
+ #{imports}
2666
+
2667
+ /// Generated by `ruflet build --experimental` from the same
2668
+ /// services/extensions configuration used by the Flutter client.
2669
+ enum RufletEngineChoice {
2670
+ static var usesNativeRenderer: Bool {
2671
+ let value = Bundle.main.object(
2672
+ forInfoDictionaryKey: "RufletExperimentalNativeRenderer")
2673
+ if let flag = value as? Bool { return flag }
2674
+ if let flag = value as? NSNumber { return flag.boolValue }
2675
+ return false
2676
+ }
2677
+
2678
+ static func pageURL(from raw: String) -> URL? {
2679
+ RufletPageAddress.parse(raw)
2680
+ }
2681
+
2682
+ @MainActor static var extensions: [any RufletExtension] {
2683
+ var result = RufletAppExtensionRegistry.extensions
2684
+ #{registrations}
2685
+ return result
2686
+ }
2687
+ }
2688
+ SWIFT
2689
+ write_text_file(choice_path, source)
2690
+ end
2691
+
2692
+ pbxproj = File.join(client_dir, apple_platform, "Runner.xcodeproj", "project.pbxproj")
2693
+ if File.file?(pbxproj)
2694
+ content = read_text_file(pbxproj)
2695
+ content = content.lines.reject do |line|
2696
+ unselected_modules.any? do |name|
2697
+ line.match?(/\/\* #{Regexp.escape(name)}(?: in Frameworks)? \*\//)
2698
+ end
2699
+ end.join
2700
+ write_text_file(pbxproj, content)
2701
+ end
2702
+
2703
+ unless selected_modules.include?("RufletAds")
2704
+ remove_plist_value(
2705
+ File.join(client_dir, "ios", "Runner", "Info.plist"),
2706
+ "GADApplicationIdentifier") if apple_platform == "ios"
2707
+ end
2708
+ build_log(
2709
+ verbose,
2710
+ "native Apple extensions=#{selected.empty? ? 'core only' : selected.map(&:first).join(',')}"
2711
+ )
2712
+ end
2713
+
2714
+ def sync_managed_template_tree(template_root, client_dir, relative_path, verbose: false)
2715
+ source = File.join(template_root, relative_path)
2716
+ return unless Dir.exist?(source)
2717
+
2718
+ destination = File.join(client_dir, relative_path)
2719
+ sync_apple_source_tree(source, destination)
2720
+ build_log(verbose, "refreshed template tree #{relative_path}")
2721
+ end
2722
+
2723
+ # Application-defined RufletExtension implementations live outside the
2724
+ # generated Flutter client. New projects own <project>/apple_extensions;
2725
+ # existing projects receive the empty template registry until they opt
2726
+ # in. Both server-driven and self-contained builds use the same package.
2727
+ def sync_application_apple_extensions(client_dir, platform:, verbose: false)
2728
+ return unless %w[ios ipa macos].include?(platform.to_s)
2729
+
2730
+ template_root = if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
2731
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
2732
+ end
2733
+ project_source = File.join(Dir.pwd, "apple_extensions")
2734
+ template_source = template_root && File.join(template_root, "apple_extensions")
2735
+ source = if File.file?(File.join(project_source, "Package.swift"))
2736
+ project_source
2737
+ elsif template_source && File.file?(File.join(template_source, "Package.swift"))
2738
+ template_source
2739
+ end
2740
+ return unless source
2741
+
2742
+ destination = File.join(client_dir, "apple_extensions")
2743
+ sync_apple_source_tree(source, destination)
2744
+ owner = source == project_source ? "project" : "template default"
2745
+ build_log(verbose, "refreshed #{owner} Apple extensions")
2746
+ end
2747
+
2748
+ def sync_apple_source_tree(source, destination)
2749
+ FileUtils.rm_rf(destination)
2750
+ FileUtils.mkdir_p(destination)
2751
+ # SwiftPM and Dart create mutable caches directly inside the package.
2752
+ # Copying the whole directory and deleting those caches afterwards is
2753
+ # too late: framework resource symlinks inside `.build` can collide or
2754
+ # escape while FileUtils is still traversing them. Treat only the
2755
+ # package's source tree as managed template input from the outset.
2756
+ generated_entries = %w[
2757
+ .swiftpm .dart_tool .claude DerivedData xcuserdata
2758
+ ]
2759
+ Dir.children(source).each do |entry|
2760
+ next if generated_entries.include?(entry) || entry == ".build" || entry.start_with?(".build-")
2761
+
2762
+ FileUtils.cp_r(
2763
+ File.join(source, entry), File.join(destination, entry),
2764
+ preserve: true)
2765
+ end
2766
+ end
2767
+
2768
+ def configure_native_apple_runtime(
2769
+ client_dir, platform:, self_contained:, config: {}, experimental: true, verbose: false
2770
+ )
2771
+ plist_paths = case platform.to_s
2772
+ when "ios", "ipa"
2773
+ [File.join(client_dir, "ios", "Runner", "Info.plist")]
2774
+ when "macos"
2775
+ [File.join(client_dir, "macos", "Runner", "Info.plist")]
2776
+ else
2777
+ []
2778
+ end
2779
+
2780
+ plist_paths.each do |path|
2781
+ next unless File.file?(path)
2782
+
2783
+ upsert_plist_string(
2784
+ path, "RufletEmbeddedProject",
2785
+ self_contained ? self_contained_project_name : "")
2786
+ upsert_plist_boolean(path, "RufletRuntimeAutostart", self_contained)
2787
+ upsert_plist_string(
2788
+ path, "RufletRuntimeProfile",
2789
+ self_contained ? embedded_runtime_profile.to_s : "")
2790
+ upsert_plist_boolean(
2791
+ path, "RufletExperimentalNativeRenderer",
2792
+ experimental)
2793
+ apple_platform = platform.to_s == "ipa" ? "ios" : platform.to_s
2794
+ platform_config = platform_build_config(config, apple_platform)
2795
+ if platform_config["local_network"] == true
2796
+ # The embedded VM is in-process, but the application can still
2797
+ # explicitly connect to LAN services (for example RufletApp).
2798
+ description = platform_config["local_network_usage_description"].to_s.strip
2799
+ description = "Connect to Ruflet applications and services on your local network." if description.empty?
2800
+ upsert_plist_string(path, "NSLocalNetworkUsageDescription", description)
2801
+ upsert_plist_dictionary_boolean(
2802
+ path, "NSAppTransportSecurity", "NSAllowsLocalNetworking", true)
2803
+ elsif self_contained
2804
+ remove_plist_value(path, "NSLocalNetworkUsageDescription")
2805
+ remove_plist_dictionary_boolean(
2806
+ path, "NSAppTransportSecurity", "NSAllowsLocalNetworking")
2807
+ end
2808
+ message = if self_contained
2809
+ "native Apple runtime autostarts #{self_contained_project_name}"
2810
+ else
2811
+ "native Apple runtime uses the server URL resolved by Dart"
2812
+ end
2813
+ build_log(verbose, message)
2814
+ end
2815
+ end
2816
+
2817
+ def configure_android_runtime(client_dir, platform:, self_contained:, verbose: false)
2818
+ return unless %w[apk android aab appbundle].include?(platform.to_s)
2819
+
2820
+ path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
2821
+ return unless File.file?(path)
2822
+
2823
+ upsert_android_metadata(path, "ruflet.runtime.autostart", self_contained.to_s)
2824
+ upsert_android_metadata(
2825
+ path, "ruflet.runtime.project",
2826
+ self_contained ? self_contained_project_name : "")
2827
+ upsert_android_metadata(
2828
+ path, "ruflet.runtime.profile",
2829
+ self_contained ? embedded_runtime_profile.to_s : "")
2830
+ build_log(
2831
+ verbose,
2832
+ self_contained ? "android runtime autostarts #{self_contained_project_name} (#{embedded_runtime_profile})" : "android embedded runtime autostart disabled"
2833
+ )
2834
+ end
2835
+
2836
+ def upsert_android_metadata(path, name, value)
2837
+ content = read_text_file(path)
2838
+ escaped_value = xml_escape(value)
2839
+ tag = %( <meta-data android:name="#{name}" android:value="#{escaped_value}" />)
2840
+ pattern = %r{\s*<meta-data\s+android:name=["']#{Regexp.escape(name)}["'][^>]*/>}m
2841
+ if content.match?(pattern)
2842
+ content.sub!(pattern, "\n#{tag}")
2843
+ else
2844
+ content.sub!(%r{(<application\b[^>]*>)}, "\\1\n#{tag}")
2845
+ end
2846
+ write_text_file(path, content)
2847
+ end
2848
+
2849
+ def upsert_plist_string(path, key, value)
2850
+ content = read_text_file(path)
2851
+ pair = "\t<key>#{key}</key>\n\t<string>#{xml_escape(value)}</string>"
2852
+ pattern = %r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m
2853
+ if content.match?(pattern)
2854
+ content.sub!(pattern, pair.strip)
2855
+ else
2856
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}\n</dict>\n</plist>")
2857
+ end
2858
+ write_text_file(path, content)
2859
+ end
2860
+
2861
+ def upsert_plist_boolean(path, key, value)
2862
+ content = read_text_file(path)
2863
+ pair = "\t<key>#{key}</key>\n\t<#{value ? 'true' : 'false'}/>"
2864
+ pattern = %r{<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m
2865
+ if content.match?(pattern)
2866
+ content.sub!(pattern, pair.strip)
2867
+ else
2868
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}\n</dict>\n</plist>")
2869
+ end
2870
+ write_text_file(path, content)
2871
+ end
2872
+
2873
+ def upsert_plist_dictionary_boolean(path, dictionary_key, key, value)
2874
+ remove_plist_dictionary_boolean(path, dictionary_key, key)
2875
+ content = read_text_file(path)
2876
+ entry = "<key>#{key}</key>\n\t\t<#{value ? 'true' : 'false'}/>"
2877
+ opening = %r{(<key>#{Regexp.escape(dictionary_key)}</key>\s*<dict>)}m
2878
+ if content.match?(opening)
2879
+ content.sub!(opening) { "#{Regexp.last_match(1)}\n\t\t#{entry}" }
2880
+ else
2881
+ pair = "\t<key>#{dictionary_key}</key>\n\t<dict>\n\t\t#{entry}\n\t</dict>\n"
2882
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2883
+ end
2884
+ write_text_file(path, content)
1928
2885
  end
1929
2886
 
1930
2887
  def write_pubspec_yaml(path, data)
@@ -1952,11 +2909,24 @@ module Ruflet
1952
2909
  # Flutter asset directory entries for every folder of the embedded project
1953
2910
  # that contains packaged files. Derived from the exact copy list so the
1954
2911
  # pubspec asset dirs match what sync_self_contained_project_assets writes.
1955
- def self_contained_project_asset_dirs
2912
+ def self_contained_project_asset_dirs(client_dir = nil)
1956
2913
  prefix = "assets/#{self_contained_project_name}"
1957
2914
  dirs = project_asset_relative_paths.map { |rel| File.dirname(rel) }.uniq
2915
+ if client_dir
2916
+ packaged_root = File.join(client_dir, prefix)
2917
+ if Dir.exist?(packaged_root)
2918
+ Find.find(packaged_root) do |path|
2919
+ next unless File.file?(path)
2920
+
2921
+ relative = Pathname.new(path).relative_path_from(Pathname.new(packaged_root)).to_s
2922
+ dirs << File.dirname(relative)
2923
+ end
2924
+ end
2925
+ end
1958
2926
  project_dirs = dirs.map { |dir| dir == "." ? "#{prefix}/" : "#{prefix}/#{dir}/" }
1959
- project_dirs.sort
2927
+ manifest = File.join(client_dir.to_s, prefix, ".ruflet-runtime.json")
2928
+ project_dirs << "#{prefix}/.ruflet-runtime.json" if client_dir && File.file?(manifest)
2929
+ project_dirs.uniq.sort
1960
2930
  end
1961
2931
 
1962
2932
  def sync_self_contained_project_assets(client_dir, verbose: false)
@@ -1977,7 +2947,329 @@ module Ruflet
1977
2947
  copied += 1
1978
2948
  end
1979
2949
 
2950
+ profile = embedded_runtime_profile
2951
+ if profile == :full
2952
+ return false unless package_full_runtime_gems(
2953
+ project_root, destination_root, client_dir, verbose: verbose)
2954
+ else
2955
+ return false unless precompile_lite_runtime_project(
2956
+ destination_root, verbose: verbose)
2957
+ end
2958
+ write_embedded_runtime_manifest(
2959
+ project_root, destination_root, profile: profile,
2960
+ platform: @ruflet_build_platform)
2961
+
1980
2962
  build_log(verbose, "copied #{copied} project file#{copied == 1 ? '' : 's'} to assets/#{self_contained_project_name}")
2963
+ true
2964
+ end
2965
+
2966
+ def precompile_lite_runtime_project(destination_root, verbose: false)
2967
+ compiler = embedded_mrbc_path
2968
+ unless compiler
2969
+ build_log(verbose, "mrbc unavailable; packaging lite Ruby source")
2970
+ return true
2971
+ end
2972
+
2973
+ sources = Dir.glob(File.join(destination_root, "**", "*.rb")).sort
2974
+ compiled = []
2975
+ sources.each do |source|
2976
+ output = source.sub(/\.rb\z/, ".mrb")
2977
+ ok = run_external_command(
2978
+ {}, compiler, "-g", "-o", output, source,
2979
+ chdir: destination_root, unbundled: true)
2980
+ unless ok
2981
+ warn "Could not precompile #{Pathname.new(source).relative_path_from(Pathname.new(destination_root))} for --lite"
2982
+ return false
2983
+ end
2984
+ compiled << [source, output]
2985
+ end
2986
+ compiled.each { |source, _output| FileUtils.rm_f(source) }
2987
+ build_log(verbose, "precompiled #{compiled.length} Ruby file#{compiled.length == 1 ? '' : 's'} for fast lite startup")
2988
+ true
2989
+ end
2990
+
2991
+ def embedded_mrbc_path
2992
+ explicit = ENV["RUFLET_MRBC"].to_s.strip
2993
+ return explicit if !explicit.empty? && File.executable?(explicit)
2994
+
2995
+ runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
2996
+ candidates = []
2997
+ if runtime_root
2998
+ candidates.concat(
2999
+ %w[host_vm host].map do |build|
3000
+ File.join(runtime_root, "third_party", "mruby", "build", build, "bin", "mrbc")
3001
+ end
3002
+ )
3003
+ candidates << File.join(runtime_root, "bin", "mrbc")
3004
+ end
3005
+ candidates.find { |path| File.executable?(path) }
3006
+ end
3007
+
3008
+ def embedded_runtime_profile
3009
+ profile = @ruflet_runtime_profile
3010
+ EMBEDDED_RUNTIME_PROFILES.include?(profile) ? profile : :lite
3011
+ end
3012
+
3013
+ # A full build carries the locked application bundle alongside the
3014
+ # project. Bundler is isolated under build/client so this never creates or
3015
+ # rewrites .bundle state in the user's project. The cache key makes repeat
3016
+ # builds cheap: unchanged locks reuse the installed bundle and only copy
3017
+ # it into the Flutter asset tree.
3018
+ def package_full_runtime_gems(project_root, destination_root, client_dir, verbose: false)
3019
+ gemfile = project_root.join("Gemfile")
3020
+ return true unless gemfile.file?
3021
+
3022
+ lockfile = project_root.join("Gemfile.lock")
3023
+ unless lockfile.file?
3024
+ warn "full runtime build requires Gemfile.lock"
3025
+ warn "Run `bundle install` once so Ruflet can package a reproducible gem set."
3026
+ return false
3027
+ end
3028
+
3029
+ cache_key = Digest::SHA256.hexdigest(
3030
+ [
3031
+ gemfile.read,
3032
+ lockfile.read,
3033
+ RUBY_ENGINE,
3034
+ RUBY_VERSION,
3035
+ @ruflet_build_platform.to_s,
3036
+ FULL_RUNTIME_BUNDLE_SCHEMA,
3037
+ JSON.generate(@ruflet_full_runtime_manifest || {})
3038
+ ].join("\0")
3039
+ )
3040
+ cache_root = File.join(client_dir, ".ruflet", "full-bundle", cache_key)
3041
+ bundle_path = File.join(cache_root, "vendor", "bundle")
3042
+ complete_marker = File.join(cache_root, ".complete")
3043
+
3044
+ unless File.file?(complete_marker) && Dir.exist?(bundle_path)
3045
+ FileUtils.rm_rf(cache_root)
3046
+ FileUtils.mkdir_p(cache_root)
3047
+ bundle_env = {
3048
+ "BUNDLE_GEMFILE" => gemfile.to_s,
3049
+ "BUNDLE_PATH" => bundle_path,
3050
+ "BUNDLE_APP_CONFIG" => File.join(cache_root, "config"),
3051
+ "BUNDLE_WITHOUT" => "development:test",
3052
+ "BUNDLE_FROZEN" => "true",
3053
+ "BUNDLE_DEPLOYMENT" => "true"
3054
+ }
3055
+ build_note("Packaging locked project gems for the full CRuby runtime")
3056
+ build_log(verbose, "bundle cache=#{cache_root}")
3057
+ ok = run_full_runtime_gem_packager(
3058
+ bundle_env, gemfile: gemfile, lockfile: lockfile,
3059
+ bundle_path: bundle_path, project_root: project_root)
3060
+ unless ok
3061
+ warn "Could not package the locked project gems for --full"
3062
+ return false
3063
+ end
3064
+ unless vendor_full_runtime_path_gems(
3065
+ bundle_env, bundle_path: bundle_path, project_root: project_root)
3066
+ return false
3067
+ end
3068
+ prune_full_runtime_bundle_cache(bundle_path)
3069
+ return false unless validate_full_runtime_in_process_transport(bundle_path)
3070
+ File.write(complete_marker, "#{cache_key}\n")
3071
+ else
3072
+ build_log(verbose, "reusing full runtime gem cache #{cache_key[0, 12]}")
3073
+ return false unless validate_full_runtime_in_process_transport(bundle_path)
3074
+ end
3075
+
3076
+ packaged_bundle = File.join(destination_root, "vendor", "bundle")
3077
+ FileUtils.mkdir_p(File.dirname(packaged_bundle))
3078
+ FileUtils.cp_r(bundle_path, packaged_bundle)
3079
+ FileUtils.cp(gemfile.to_s, File.join(destination_root, "Gemfile"))
3080
+ FileUtils.cp(lockfile.to_s, File.join(destination_root, "Gemfile.lock"))
3081
+ true
3082
+ rescue StandardError => e
3083
+ warn "Could not package project gems for --full: #{e.class}: #{e.message}"
3084
+ false
3085
+ end
3086
+
3087
+ def run_full_runtime_gem_packager(bundle_env, gemfile:, lockfile:, bundle_path:, project_root:)
3088
+ packager = @ruflet_full_runtime_manifest && @ruflet_full_runtime_manifest["gem_packager"]
3089
+ unless packager.to_s.strip.empty?
3090
+ executable = File.expand_path(packager.to_s, @ruflet_full_runtime_path)
3091
+ unless File.executable?(executable)
3092
+ warn "Full runtime gem packager is not executable: #{executable}"
3093
+ return false
3094
+ end
3095
+ return run_external_command(
3096
+ bundle_env,
3097
+ executable,
3098
+ "--gemfile", gemfile.to_s,
3099
+ "--lockfile", lockfile.to_s,
3100
+ "--path", bundle_path,
3101
+ "--platform", normalized_runtime_platform(@ruflet_build_platform),
3102
+ chdir: project_root.to_s,
3103
+ unbundled: true
3104
+ )
3105
+ end
3106
+
3107
+ run_external_command(
3108
+ bundle_env, RbConfig.ruby, "-S", "bundle", "install",
3109
+ "--jobs", "4", "--retry", "3",
3110
+ chdir: project_root.to_s, unbundled: true
3111
+ )
3112
+ end
3113
+
3114
+ # Bundler intentionally leaves `path:` gems at their source location,
3115
+ # even when BUNDLE_PATH is set. A self-contained app cannot retain those
3116
+ # workstation paths. Materialize every resolved spec that sits outside
3117
+ # vendor/bundle so the runtime sees the exact locked source tree.
3118
+ def vendor_full_runtime_path_gems(bundle_env, bundle_path:, project_root:)
3119
+ script = <<~'RUBY'
3120
+ require "bundler"
3121
+ require "json"
3122
+ puts JSON.generate(Bundler.load.specs.map { |spec|
3123
+ {
3124
+ "full_name" => spec.full_name,
3125
+ "full_gem_path" => spec.full_gem_path,
3126
+ "extension_dir" => spec.extension_dir,
3127
+ "source_type" => spec.source.class.name,
3128
+ "files" => spec.files,
3129
+ "gemspec" => spec.to_ruby
3130
+ }
3131
+ })
3132
+ RUBY
3133
+ stdout, stderr, status = Open3.capture3(
3134
+ bundle_env, RbConfig.ruby, "-e", script,
3135
+ chdir: project_root.to_s)
3136
+ unless status.success?
3137
+ warn "Could not inspect locked project gems for --full"
3138
+ warn stderr unless stderr.to_s.strip.empty?
3139
+ return false
3140
+ end
3141
+
3142
+ ruby_abi = (@ruflet_full_runtime_manifest || {})["ruby_abi"].to_s
3143
+ ruby_abi = RbConfig::CONFIG.fetch("ruby_version") if ruby_abi.empty?
3144
+ installed_root = File.expand_path(bundle_path)
3145
+ specs = JSON.parse(stdout)
3146
+ specs.each do |spec|
3147
+ next unless spec.fetch("source_type") == "Bundler::Source::Path"
3148
+
3149
+ source = File.expand_path(spec.fetch("full_gem_path"))
3150
+ next if source == installed_root || source.start_with?("#{installed_root}#{File::SEPARATOR}")
3151
+
3152
+ full_name = spec.fetch("full_name")
3153
+ destination = File.join(bundle_path, "ruby", ruby_abi, "gems", full_name)
3154
+ FileUtils.rm_rf(destination)
3155
+ FileUtils.mkdir_p(File.dirname(destination))
3156
+ unless copy_full_runtime_gem_files(
3157
+ source, destination, spec.fetch("files"), full_name: full_name)
3158
+ return false
3159
+ end
3160
+
3161
+ specifications = File.join(bundle_path, "ruby", ruby_abi, "specifications")
3162
+ FileUtils.mkdir_p(specifications)
3163
+ File.write(File.join(specifications, "#{full_name}.gemspec"), spec.fetch("gemspec"))
3164
+
3165
+ extension_source = spec["extension_dir"].to_s
3166
+ next unless !extension_source.empty? && Dir.exist?(extension_source)
3167
+
3168
+ extension_destination = File.join(
3169
+ bundle_path, "ruby", ruby_abi, "extensions", "ruflet", full_name)
3170
+ FileUtils.rm_rf(extension_destination)
3171
+ FileUtils.mkdir_p(File.dirname(extension_destination))
3172
+ FileUtils.cp_r(extension_source, extension_destination)
3173
+ end
3174
+ true
3175
+ rescue JSON::ParserError, KeyError, SystemCallError => e
3176
+ warn "Could not vendor path gems for --full: #{e.class}: #{e.message}"
3177
+ false
3178
+ end
3179
+
3180
+ # Downloaded .gem archives are installation inputs, not runtime inputs.
3181
+ # Keeping them would duplicate every registry gem in the application.
3182
+ def prune_full_runtime_bundle_cache(bundle_path)
3183
+ Dir.glob(File.join(bundle_path, "ruby", "*", "cache")).each do |cache_dir|
3184
+ FileUtils.rm_rf(cache_dir)
3185
+ end
3186
+ end
3187
+
3188
+ # Full self builds cannot fall back to a local socket: the embedded
3189
+ # renderer and CRuby VM communicate only through the native in-memory
3190
+ # bridge. Reject an older locked ruflet_server while the app is still
3191
+ # being built instead of shipping a bundle that fails on first launch.
3192
+ def validate_full_runtime_in_process_transport(bundle_path)
3193
+ server_roots = Dir.glob(
3194
+ File.join(bundle_path, "ruby", "*", "gems", "ruflet_server-*")
3195
+ ).select { |path| Dir.exist?(path) }
3196
+ return true if server_roots.empty?
3197
+
3198
+ supported = server_roots.any? do |root|
3199
+ File.file?(
3200
+ File.join(root, "lib", "ruflet", "server", "in_process_connection.rb")
3201
+ )
3202
+ end
3203
+ return true if supported
3204
+
3205
+ warn "The locked ruflet_server gem does not support port-free --self --full builds."
3206
+ warn "Update the application's Gemfile.lock to a Ruflet release with in-process transport."
3207
+ false
3208
+ end
3209
+
3210
+ # A path gem is a source checkout, not an installable payload. Copy only
3211
+ # the files declared by its gemspec so build products, tests, caches and
3212
+ # other workstation state cannot leak into a mobile application bundle.
3213
+ def copy_full_runtime_gem_files(source, destination, files, full_name:)
3214
+ source_root = File.expand_path(source)
3215
+ destination_root = File.expand_path(destination)
3216
+ declared_files = Array(files).map(&:to_s).reject(&:empty?).uniq.sort
3217
+ if declared_files.empty?
3218
+ warn "Could not vendor path gem #{full_name}: its gemspec declares no files"
3219
+ return false
3220
+ end
3221
+
3222
+ declared_files.each do |relative_path|
3223
+ pathname = Pathname.new(relative_path)
3224
+ clean_path = pathname.cleanpath
3225
+ if pathname.absolute? || clean_path.each_filename.include?("..")
3226
+ warn "Could not vendor path gem #{full_name}: unsafe file path #{relative_path.inspect}"
3227
+ return false
3228
+ end
3229
+
3230
+ source_file = File.expand_path(clean_path.to_s, source_root)
3231
+ unless source_file.start_with?("#{source_root}#{File::SEPARATOR}") && File.file?(source_file)
3232
+ warn "Could not vendor path gem #{full_name}: declared file is missing: #{relative_path}"
3233
+ return false
3234
+ end
3235
+
3236
+ destination_file = File.expand_path(clean_path.to_s, destination_root)
3237
+ FileUtils.mkdir_p(File.dirname(destination_file))
3238
+ FileUtils.cp(source_file, destination_file, preserve: true)
3239
+ end
3240
+ true
3241
+ rescue SystemCallError => e
3242
+ warn "Could not vendor path gem #{full_name}: #{e.class}: #{e.message}"
3243
+ false
3244
+ end
3245
+
3246
+ def write_embedded_runtime_manifest(project_root, destination_root, profile:, platform:)
3247
+ paths = Dir.glob(File.join(destination_root, "**", "*"), File::FNM_DOTMATCH)
3248
+ .select { |path| File.file?(path) }
3249
+ .reject { |path| File.basename(path) == ".ruflet-runtime.json" }
3250
+ .sort
3251
+ digest = Digest::SHA256.new
3252
+ paths.each do |path|
3253
+ relative = Pathname.new(path).relative_path_from(Pathname.new(destination_root)).to_s
3254
+ digest << relative << "\0" << File.binread(path) << "\0"
3255
+ end
3256
+
3257
+ manifest = {
3258
+ "schema" => 1,
3259
+ "profile" => profile.to_s,
3260
+ "engine" => profile == :full ? "cruby" : "mruby",
3261
+ "platform" => platform.to_s,
3262
+ "project" => project_root.basename.to_s,
3263
+ "entrypoint" => File.file?(File.join(destination_root, "main.mrb")) ? "main.mrb" : "main.rb",
3264
+ "bundle_gemfile" => profile == :full && File.file?(File.join(destination_root, "Gemfile")) ? "Gemfile" : nil,
3265
+ "bundle_path" => profile == :full && Dir.exist?(File.join(destination_root, "vendor", "bundle")) ? "vendor/bundle" : nil,
3266
+ "cache_key" => digest.hexdigest,
3267
+ "warm_launch" => true
3268
+ }.compact
3269
+ File.write(
3270
+ File.join(destination_root, ".ruflet-runtime.json"),
3271
+ "#{JSON.pretty_generate(manifest)}\n"
3272
+ )
1981
3273
  end
1982
3274
 
1983
3275
  def remove_self_contained_project_assets(client_dir, verbose: false)
@@ -2065,10 +3357,30 @@ module Ruflet
2065
3357
  false
2066
3358
  end
2067
3359
 
3360
+ # What a self-contained build actually needs from the project at runtime:
3361
+ # the entrypoint, the Ruby under lib/, and the assets that code loads by
3362
+ # path (image(src: "assets/logo.png"), lottie(...), fonts, audio).
3363
+ #
3364
+ # Everything else is already consumed by the time the app runs. Lite gems
3365
+ # are compiled into the VM; full-profile gems and lockfiles are staged by
3366
+ # package_full_runtime_gems instead of copied from the working tree.
3367
+ # ruflet.yaml and services.yaml have been turned into native configuration,
3368
+ # while tests, CI and store artwork were never runtime inputs. Listing
3369
+ # what belongs in rather than guessing what to leave out keeps unexpected
3370
+ # directories from being shipped --
3371
+ # release artwork once carried a lockfile that Apple read as an unsigned
3372
+ # code object and rejected the whole upload over.
3373
+ SELF_CONTAINED_PROJECT_ENTRYPOINT = "main.rb"
3374
+ SELF_CONTAINED_PROJECT_DIRECTORIES = %w[lib assets].freeze
3375
+
2068
3376
  def include_project_asset_file?(relative)
2069
3377
  basename = File.basename(relative)
2070
3378
  return false if basename == ".DS_Store"
2071
- return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename)
3379
+
3380
+ unless self_contained_project_path?(relative)
3381
+ return false
3382
+ end
3383
+
2072
3384
  if secret_project_asset?(relative)
2073
3385
  build_note("Excluded #{relative} from the embedded project; it looks like a credential")
2074
3386
  return false
@@ -2076,6 +3388,13 @@ module Ruflet
2076
3388
  true
2077
3389
  end
2078
3390
 
3391
+ def self_contained_project_path?(relative)
3392
+ return true if relative == SELF_CONTAINED_PROJECT_ENTRYPOINT
3393
+
3394
+ top = relative.split(File::SEPARATOR).first
3395
+ SELF_CONTAINED_PROJECT_DIRECTORIES.include?(top)
3396
+ end
3397
+
2079
3398
  def flutter_target_entrypoint(client_dir, self_contained:)
2080
3399
  candidate = File.join(
2081
3400
  client_dir,
@@ -2102,7 +3421,7 @@ module Ruflet
2102
3421
  return nil if key.empty?
2103
3422
 
2104
3423
  key.tr!("-", "_")
2105
- key.gsub!(/\A(flet_)+/, "")
3424
+ key.gsub!(/\A(flet_)+/, "") # Compatibility for existing application configuration.
2106
3425
  key.gsub!(/\A(ruflet_)+/, "")
2107
3426
  key.gsub!(/\Aservice_/, "")
2108
3427
  key
@@ -2124,15 +3443,49 @@ module Ruflet
2124
3443
  write_pubspec_yaml(path, data)
2125
3444
  end
2126
3445
 
2127
- def sync_client_extension_dependencies(path, selected_packages)
2128
- return if selected_packages.empty?
3446
+ def sync_client_package_directories(client_dir, selected_packages)
3447
+ packages_root = File.join(client_dir, "ruflet_packages")
2129
3448
 
3449
+ kept_packages = (["ruflet"] + selected_packages).uniq
3450
+ template_root =
3451
+ if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
3452
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
3453
+ end
3454
+ template_packages_root = template_root && File.join(template_root, "ruflet_packages")
3455
+
3456
+ # These are disposable client sources, not application files. Refresh
3457
+ # even existing packages so a rebuild cannot keep an older engine fork.
3458
+ kept_packages.each do |package_name|
3459
+ destination = File.join(packages_root, package_name)
3460
+ next unless template_packages_root
3461
+
3462
+ source = File.join(template_packages_root, package_name)
3463
+ next unless Dir.exist?(source)
3464
+ next if File.expand_path(source) == File.expand_path(destination)
3465
+
3466
+ FileUtils.rm_rf(destination) if File.exist?(destination)
3467
+ Ruflet::CLI.send(:copy_client_template_entry, template_packages_root, packages_root, package_name)
3468
+ end
3469
+
3470
+ return unless Dir.exist?(packages_root)
3471
+
3472
+ Dir.children(packages_root).each do |entry|
3473
+ path = File.join(packages_root, entry)
3474
+ next unless Dir.exist?(path)
3475
+ next if kept_packages.include?(entry)
3476
+
3477
+ FileUtils.rm_rf(path)
3478
+ end
3479
+ end
3480
+
3481
+ def sync_client_extension_dependencies(path, selected_packages)
2130
3482
  template_deps = template_client_pubspec_dependencies
2131
3483
  return if template_deps.empty?
2132
3484
 
2133
3485
  data = YAML.safe_load(read_text_file(path), aliases: true) || {}
2134
3486
  deps = (data["dependencies"] || {}).dup
2135
- selected_packages.each do |package_name|
3487
+ deps.delete_if { |name, _| name == "flet" || name.start_with?("flet_") }
3488
+ (["ruflet"] + selected_packages).each do |package_name|
2136
3489
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
2137
3490
  end
2138
3491
 
@@ -2140,6 +3493,49 @@ module Ruflet
2140
3493
  write_pubspec_yaml(path, data)
2141
3494
  end
2142
3495
 
3496
+ # Check Pub's actual result, including transitive local forks. Merely
3497
+ # declaring a path dependency is insufficient: an override can replace it.
3498
+ def validate_local_ruflet_packages(client_dir)
3499
+ config_path = File.join(client_dir, ".dart_tool", "package_config.json")
3500
+ resolved = JSON.parse(File.read(config_path)).fetch("packages")
3501
+ legacy = resolved.map { |entry| entry.fetch("name") }.select { |name| name == "flet" || name.start_with?("flet_") }
3502
+ raise "legacy Flet packages resolved: #{legacy.join(', ')}" unless legacy.empty?
3503
+
3504
+ expected = (["ruflet"] + Array(@ruflet_selected_flutter_extension_packages)).to_h do |name|
3505
+ [name, File.join(client_dir, "ruflet_packages", name)]
3506
+ end
3507
+ # The core/extension manifests also select local decoupled vendor forks.
3508
+ queue = expected.values.dup
3509
+ until queue.empty?
3510
+ root = queue.shift
3511
+ manifest = YAML.safe_load(File.read(File.join(root, "pubspec.yaml")), aliases: true)
3512
+ (manifest["dependencies"] || {}).each do |name, dependency|
3513
+ next unless dependency.is_a?(Hash) && dependency["path"]
3514
+ next if expected.key?(name)
3515
+
3516
+ local = File.expand_path(dependency.fetch("path"), root)
3517
+ expected[name] = local
3518
+ queue << local
3519
+ end
3520
+ end
3521
+ base = "file://#{URI::DEFAULT_PARSER.escape(File.expand_path(config_path))}"
3522
+ expected.each do |name, root|
3523
+ entry = resolved.find { |package| package["name"] == name }
3524
+ raise "missing local package #{name}" unless entry
3525
+
3526
+ uri = URI.join(base, entry.fetch("rootUri"))
3527
+ actual = URI::DEFAULT_PARSER.unescape(uri.path)
3528
+ unless uri.scheme == "file" && File.realpath(actual) == File.realpath(root)
3529
+ raise "#{name} must resolve to the bundled Ruflet source at #{root}, got #{uri}"
3530
+ end
3531
+ end
3532
+ build_note("Verified #{expected.length} local Ruflet engine, extension and vendor packages")
3533
+ true
3534
+ rescue StandardError => e
3535
+ warn "build config error: local Ruflet package verification failed: #{e.message}"
3536
+ false
3537
+ end
3538
+
2143
3539
  def template_client_pubspec_dependencies
2144
3540
  template_root =
2145
3541
  if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
@@ -2207,7 +3603,7 @@ module Ruflet
2207
3603
  if marker_index
2208
3604
  lines.insert(marker_index, extension_line)
2209
3605
  else
2210
- list_index = lines.index { |line| line.include?("final extensions = <FletExtension>[") }
3606
+ list_index = lines.index { |line| line.include?("final extensions = <RufletExtension>[") }
2211
3607
  lines.insert(list_index ? list_index + 1 : lines.length, extension_line)
2212
3608
  end
2213
3609
  lines.join