ruflet 0.0.21 → 0.0.23

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,25 @@ 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" }
34
37
  }.freeze
35
38
  PROTECTED_SERVICE_EXTENSIONS = {
36
39
  "camera" => %w[camera permission_handler],
@@ -39,8 +42,22 @@ module Ruflet
39
42
  "motion" => %w[permission_handler]
40
43
  }.freeze
41
44
  EXTENSION_REQUIRED_SERVICES = {
45
+ "audio_recorder" => %w[microphone],
46
+ "camera" => %w[camera],
47
+ "flashlight" => %w[camera],
48
+ "geolocator" => %w[location],
42
49
  "qrcode_scanner" => %w[camera]
43
50
  }.freeze
51
+ MANAGED_EXTENSION_STATE_PATH = File.join(".ruflet", "extension_dependencies.json")
52
+ RUFLET_SOURCE_INTEGRITY_PATH = File.join(
53
+ "tool", "conformance", "ruflet_source_integrity.json"
54
+ ).freeze
55
+ RUFLET_SOURCE_IGNORED_DIRECTORIES = %w[
56
+ .git .dart_tool .cache build .pub-cache .idea .vscode coverage
57
+ ].freeze
58
+ RUFLET_SOURCE_IGNORED_FILES = %w[
59
+ pubspec.lock .DS_Store .flutter-plugins .flutter-plugins-dependencies
60
+ ].freeze
44
61
  ANDROID_SERVICE_PERMISSIONS = {
45
62
  "camera" => %w[android.permission.CAMERA],
46
63
  "microphone" => %w[android.permission.RECORD_AUDIO],
@@ -50,9 +67,34 @@ module Ruflet
50
67
  IOS_SERVICE_USAGE_KEYS = {
51
68
  "camera" => "NSCameraUsageDescription",
52
69
  "microphone" => "NSMicrophoneUsageDescription",
53
- "location" => "NSLocationWhenInUseUsageDescription",
70
+ "location" => %w[
71
+ NSLocationWhenInUseUsageDescription
72
+ NSLocationAlwaysAndWhenInUseUsageDescription
73
+ ],
74
+ "motion" => "NSMotionUsageDescription",
75
+ "photo_library" => "NSPhotoLibraryUsageDescription"
76
+ }.freeze
77
+ MACOS_SERVICE_USAGE_KEYS = {
78
+ "camera" => "NSCameraUsageDescription",
79
+ "microphone" => "NSMicrophoneUsageDescription",
80
+ "location" => "NSLocationUsageDescription",
54
81
  "motion" => "NSMotionUsageDescription"
55
82
  }.freeze
83
+ MACOS_SERVICE_ENTITLEMENTS = {
84
+ "camera" => "com.apple.security.device.camera",
85
+ "microphone" => "com.apple.security.device.audio-input",
86
+ "location" => "com.apple.security.personal-information.location"
87
+ }.freeze
88
+ MANAGED_ANDROID_PERMISSIONS = (
89
+ ANDROID_SERVICE_PERMISSIONS.values.flatten +
90
+ %w[android.permission.FLASHLIGHT android.permission.MODIFY_AUDIO_SETTINGS]
91
+ ).uniq.freeze
92
+ MANAGED_IOS_USAGE_KEYS = (
93
+ IOS_SERVICE_USAGE_KEYS.values.flatten +
94
+ %w[NSLocationAlwaysAndWhenInUseUsageDescription NSPhotoLibraryUsageDescription]
95
+ ).uniq.freeze
96
+ MANAGED_MACOS_USAGE_KEYS = MACOS_SERVICE_USAGE_KEYS.values.uniq.freeze
97
+ MANAGED_MACOS_SERVICE_ENTITLEMENTS = MACOS_SERVICE_ENTITLEMENTS.values.uniq.freeze
56
98
  # Clients that are told which server to use at launch rather than at build
57
99
  # time, so they may be built without a configured backend_url.
58
100
  RUNTIME_RESOLVED_BACKEND_PLATFORMS = %w[web macos windows linux].freeze
@@ -68,12 +110,28 @@ module Ruflet
68
110
  "linux" => { splash: false, icon: false }
69
111
  }.freeze
70
112
 
113
+ EMBEDDED_RUNTIME_PROFILES = %i[lite full].freeze
114
+ FULL_RUNTIME_MANIFEST = "ruflet-full-runtime.json"
115
+ FULL_RUNTIME_BUNDLE_SCHEMA = 3
116
+
71
117
  def command_build(args)
72
- self_contained = args.delete("--self")
118
+ self_contained_flag = args.delete("--self")
119
+ lite = args.delete("--lite")
120
+ full = args.delete("--full")
121
+ if lite && full
122
+ warn "build config error: --lite and --full are mutually exclusive"
123
+ return 1
124
+ end
125
+ # --self enables the embedded runtime and defaults its profile to lite.
126
+ # An explicit profile still wins, so --self --full selects full CRuby.
127
+ self_contained = self_contained_flag || lite || full
128
+ runtime_profile = full ? :full : :lite
129
+ @ruflet_runtime_profile = self_contained ? runtime_profile : :server
130
+ @ruflet_runtime_profile_explicit = !!(lite || full)
73
131
  verbose = args.delete("--verbose") || args.delete("-v")
74
132
  platform = (args.shift || "").downcase
75
133
  if platform.empty?
76
- warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--self] [--verbose]"
134
+ warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--lite|--full|--self] [--verbose]"
77
135
  return 1
78
136
  end
79
137
 
@@ -87,12 +145,14 @@ module Ruflet
87
145
  # signing, icons, package name — is the same as a plain iOS build.
88
146
  requested_platform = platform
89
147
  platform = "ios" if platform == "ipa"
148
+ @ruflet_build_platform = platform
90
149
 
91
150
  # The embedded Ruby VM is a native plugin with no browser
92
151
  # implementation, so a self-contained web build produces an app that
93
152
  # cannot start. Say so rather than shipping one that hangs.
94
153
  if self_contained && platform == "web"
95
- warn "build config error: --self is not supported for web"
154
+ profile_flag = @ruflet_runtime_profile_explicit ? "--#{runtime_profile}" : "--self"
155
+ warn "build config error: #{profile_flag} is not supported for web"
96
156
  warn "A web client runs no embedded Ruby; build it with `ruflet build web`."
97
157
  return 1
98
158
  end
@@ -105,8 +165,12 @@ module Ruflet
105
165
  return 1
106
166
  end
107
167
 
108
- build_note("Preparing #{platform} build (#{self_contained ? 'self-contained' : 'server-driven'})")
168
+ mode = self_contained ? "self-contained #{runtime_profile}" : "server-driven"
169
+ build_note("Preparing #{platform} build (#{mode})")
109
170
  config = load_ruflet_config
171
+ if runtime_profile == :full && self_contained
172
+ return 1 unless configure_full_runtime_distribution(config, platform, verbose: !!verbose)
173
+ end
110
174
  tools = ensure_flutter!("build", client_dir: client_dir)
111
175
  command_env = build_tool_env(tools[:env], platform, client_dir)
112
176
  ok = prepare_flutter_client(
@@ -125,11 +189,16 @@ module Ruflet
125
189
  build_args += ["--target", target_entrypoint] if target_entrypoint
126
190
  backend_url = configured_backend_url(config)
127
191
  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).
192
+ # Pin the embedded project for platform autostart instead of asking
193
+ # the native layer to infer it from every main.rb/main.mrb in the
194
+ # bundled asset tree.
132
195
  build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
196
+ if @ruflet_runtime_profile_explicit
197
+ build_args += ["--dart-define", "RUFLET_RUNTIME_PROFILE=#{runtime_profile}"]
198
+ end
199
+ # The platform layer starts the VM before Flutter exists, so the app
200
+ # bundle -- not a Dart define -- has to carry the same decision.
201
+ configure_platform_autostart(client_dir, platform, verbose: !!verbose)
133
202
  elsif backend_url
134
203
  build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
135
204
  elsif RUNTIME_RESOLVED_BACKEND_PLATFORMS.include?(platform)
@@ -146,7 +215,12 @@ module Ruflet
146
215
  build_args << "-v" if verbose
147
216
  stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
148
217
 
149
- build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
218
+ logged_mode = if self_contained
219
+ @ruflet_runtime_profile_explicit ? runtime_profile : "self"
220
+ else
221
+ "server"
222
+ end
223
+ build_log(verbose, "mode=#{logged_mode}")
150
224
  build_log(verbose, "client_dir=#{client_dir}")
151
225
  build_log(verbose, "flutter=#{tools[:flutter]}")
152
226
  build_log(verbose, "dart=#{tools[:dart]}")
@@ -155,6 +229,26 @@ module Ruflet
155
229
 
156
230
  build_note("Running Flutter #{build_args.join(' ')}")
157
231
  ok = run_external_command(command_env, tools[:flutter], *build_args, chdir: client_dir, unbundled: true)
232
+
233
+ # A physical iPhone app and a simulator app are different Apple
234
+ # products, but Ruflet users should not have to learn that distinction.
235
+ # Keep both outputs behind the ordinary self-contained iOS build so the
236
+ # following `ruflet install` can target whichever mobile device is
237
+ # connected. An explicit --simulator remains a simulator-only build.
238
+ if ok && self_contained && requested_platform == "ios" &&
239
+ !build_args.include?("--simulator") && !full_runtime_device_only?
240
+ simulator_args = build_args.dup
241
+ simulator_args.delete("--codesign")
242
+ simulator_args.insert(simulator_args.index("ios") + 1, "--simulator")
243
+ stage_ios_simulator_ruby_runtime(client_dir, simulator_args, verbose: !!verbose)
244
+ build_log(verbose, "command=#{([tools[:flutter]] + simulator_args).join(' ')}")
245
+ build_note("Running Flutter #{simulator_args.join(' ')}")
246
+ ok = run_external_command(
247
+ command_env, tools[:flutter], *simulator_args,
248
+ chdir: client_dir, unbundled: true
249
+ )
250
+ end
251
+
158
252
  export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok
159
253
  ok ? 0 : 1
160
254
  end
@@ -213,6 +307,15 @@ module Ruflet
213
307
  def ensure_flutter_client_dir(verbose: false)
214
308
  client_dir = detect_flutter_client_dir
215
309
  if client_dir
310
+ if File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir) &&
311
+ !valid_flutter_client_root?(client_dir) &&
312
+ !File.file?(File.join(client_dir, ".metadata"))
313
+ if Ruflet::CLI.respond_to?(:copy_ruflet_client_template, true)
314
+ Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd)
315
+ client_dir = hidden_flutter_client_dir
316
+ build_log(verbose, "repaired invalid managed Flutter client root")
317
+ end
318
+ end
216
319
  refresh_hidden_flutter_client_template(client_dir, verbose: verbose)
217
320
  return client_dir
218
321
  end
@@ -222,6 +325,11 @@ module Ruflet
222
325
  bootstrapped
223
326
  end
224
327
 
328
+ def valid_flutter_client_root?(path)
329
+ File.file?(File.join(path, "pubspec.yaml")) &&
330
+ File.file?(File.join(path, "lib", "main.dart"))
331
+ end
332
+
225
333
  def refresh_hidden_flutter_client_template(client_dir, verbose: false)
226
334
  return unless File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir)
227
335
  return unless File.file?(File.join(client_dir, ".metadata"))
@@ -437,7 +545,13 @@ module Ruflet
437
545
  end
438
546
 
439
547
  def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
440
- refresh_managed_client_template_files(client_dir, verbose: verbose)
548
+ refreshed = refresh_managed_client_template_files(
549
+ client_dir, platform: platform, verbose: verbose)
550
+ return false if refreshed == false
551
+ unless remove_native_apple_renderer_integration(
552
+ client_dir, platform: platform, verbose: verbose)
553
+ return false
554
+ end
441
555
  metadata = sync_client_metadata(client_dir, config, verbose: verbose)
442
556
  return false unless validate_mobile_app_identity(metadata, platform: platform)
443
557
 
@@ -446,8 +560,17 @@ module Ruflet
446
560
  apply_ios_signing_team(client_dir, config) if %w[ios ipa macos].include?(platform.to_s)
447
561
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
448
562
  return false if configured == false
563
+ configure_native_apple_runtime(
564
+ client_dir, platform: platform, self_contained: self_contained,
565
+ config: config, verbose: verbose)
566
+ configure_android_runtime(
567
+ client_dir, platform: platform, self_contained: self_contained,
568
+ verbose: verbose)
449
569
  @ruflet_self_contained_build = self_contained
450
- apply_service_extension_config(client_dir, config)
570
+ extension_keys = apply_service_extension_config(client_dir, config)
571
+ if @ruflet_extension_selection_applied && !validate_flutter_extension_selection(client_dir)
572
+ return false
573
+ end
451
574
  asset_flags = apply_build_config(client_dir, config)
452
575
  if asset_flags[:error]
453
576
  warn asset_flags[:error]
@@ -465,6 +588,9 @@ module Ruflet
465
588
  warn "flutter pub get failed"
466
589
  return false
467
590
  end
591
+ if @ruflet_extension_selection_applied && !validate_local_ruflet_packages(client_dir)
592
+ return false
593
+ end
468
594
 
469
595
  unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
470
596
  return false
@@ -601,7 +727,11 @@ module Ruflet
601
727
  return true unless build_args.include?("ios")
602
728
  return true unless build_args.include?("--simulator")
603
729
 
604
- runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
730
+ runtime_root = if embedded_runtime_profile == :full
731
+ @ruflet_full_runtime_path
732
+ else
733
+ explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
734
+ end
605
735
  return true unless runtime_root
606
736
 
607
737
  source = File.join(
@@ -1226,10 +1356,10 @@ module Ruflet
1226
1356
  android_application_id: normalize_bundle_identifier(
1227
1357
  first_present(app["android_application_id"], config["android_application_id"], bundle_identifier)
1228
1358
  ),
1229
- ios_bundle_identifier: normalize_bundle_identifier(
1359
+ ios_bundle_identifier: normalize_apple_bundle_identifier(
1230
1360
  first_present(app["ios_bundle_identifier"], config["ios_bundle_identifier"], bundle_identifier)
1231
1361
  ),
1232
- macos_bundle_identifier: normalize_bundle_identifier(
1362
+ macos_bundle_identifier: normalize_apple_bundle_identifier(
1233
1363
  first_present(app["macos_bundle_identifier"], config["macos_bundle_identifier"], bundle_identifier)
1234
1364
  ),
1235
1365
  linux_application_id: normalize_bundle_identifier(
@@ -1508,6 +1638,20 @@ module Ruflet
1508
1638
  segments.join(".")
1509
1639
  end
1510
1640
 
1641
+ def normalize_apple_bundle_identifier(value)
1642
+ segments = value.to_s.strip.downcase.split(".").map do |segment|
1643
+ normalized = segment.gsub(/[^a-z0-9-]+/, "-")
1644
+ normalized.gsub!(/\A-+|-+\z/, "")
1645
+ normalized.gsub!(/-+/, "-")
1646
+ normalized = "app" if normalized.empty?
1647
+ normalized = "app-#{normalized}" if normalized.match?(/\A\d/)
1648
+ normalized
1649
+ end
1650
+ segments.reject!(&:empty?)
1651
+ segments = %w[com example app] if segments.empty?
1652
+ segments.join(".")
1653
+ end
1654
+
1511
1655
  def humanize_name(name)
1512
1656
  name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
1513
1657
  end
@@ -1538,28 +1682,47 @@ module Ruflet
1538
1682
  end
1539
1683
 
1540
1684
  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)
1685
+ extension_keys = configured_extension_keys(config)
1686
+ flutter_extension_keys = extension_keys
1687
+ extension_packages = flutter_extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1688
+ extension_aliases = flutter_extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1689
+
1690
+ configured_external = external_extension_entries(config)
1691
+ external = configured_external
1692
+ previous_external_names = managed_external_extension_names(client_dir)
1693
+ discovered_external_names = discover_registered_external_extension_names(client_dir)
1694
+ removable_external_names = (
1695
+ previous_external_names + discovered_external_names + configured_external.map { |entry| entry[:name] }
1696
+ ).uniq
1549
1697
 
1550
1698
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1551
1699
  if File.file?(pubspec_path)
1552
1700
  sync_client_extension_dependencies(pubspec_path, extension_packages)
1553
1701
  prune_client_pubspec(pubspec_path, extension_packages)
1554
- sync_external_extension_dependencies(pubspec_path, external)
1702
+ sync_external_extension_dependencies(
1703
+ pubspec_path,
1704
+ external,
1705
+ managed_names: removable_external_names
1706
+ )
1555
1707
  end
1708
+ sync_client_package_directories(client_dir, extension_packages)
1556
1709
  client_entrypoint_paths(client_dir).each do |entrypoint|
1557
1710
  next unless File.file?(entrypoint)
1558
1711
 
1559
1712
  sync_client_main_extensions(entrypoint, extension_aliases)
1560
1713
  prune_client_main(entrypoint, extension_aliases)
1714
+ prune_external_extension_registrations(
1715
+ entrypoint,
1716
+ removable_external_names - external.map { |entry| entry[:name] }
1717
+ )
1561
1718
  sync_external_extension_registrations(entrypoint, external)
1562
1719
  end
1720
+ write_managed_external_extension_names(client_dir, external.map { |entry| entry[:name] })
1721
+ @ruflet_selected_flutter_extension_packages = extension_packages
1722
+ @ruflet_selected_external_extension_packages = external.map { |entry| entry[:name] }
1723
+ @ruflet_managed_external_extension_packages = removable_external_names
1724
+ @ruflet_extension_selection_applied = true
1725
+ extension_keys
1563
1726
  end
1564
1727
 
1565
1728
  # An extension may name a package the template does not bundle, declared
@@ -1617,18 +1780,97 @@ module Ruflet
1617
1780
  nil
1618
1781
  end
1619
1782
 
1620
- def sync_external_extension_dependencies(pubspec_path, entries)
1621
- return if entries.empty?
1622
-
1783
+ def sync_external_extension_dependencies(pubspec_path, entries, managed_names: [])
1623
1784
  data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1624
1785
  dependencies = (data["dependencies"] || {}).dup
1786
+ selected_names = entries.map { |entry| entry[:name] }
1787
+ Array(managed_names).each do |name|
1788
+ dependencies.delete(name) unless selected_names.include?(name)
1789
+ end
1625
1790
  entries.each { |entry| dependencies[entry[:name]] = entry[:dependency] }
1626
1791
  data["dependencies"] = dependencies
1627
1792
  write_pubspec_yaml(pubspec_path, data)
1628
- build_note("Added #{entries.map { |e| e[:name] }.join(', ')} from the extension configuration")
1793
+ build_note("Added #{selected_names.join(', ')} from the extension configuration") unless selected_names.empty?
1794
+ end
1795
+
1796
+ def managed_extension_state_path(client_dir)
1797
+ File.join(client_dir, MANAGED_EXTENSION_STATE_PATH)
1798
+ end
1799
+
1800
+ def managed_external_extension_names(client_dir)
1801
+ path = managed_extension_state_path(client_dir)
1802
+ return [] unless File.file?(path)
1803
+
1804
+ data = JSON.parse(read_text_file(path))
1805
+ Array(data["external_packages"]).map(&:to_s).reject(&:empty?).uniq
1806
+ rescue StandardError
1807
+ []
1808
+ end
1809
+
1810
+ def write_managed_external_extension_names(client_dir, names)
1811
+ path = managed_extension_state_path(client_dir)
1812
+ FileUtils.mkdir_p(File.dirname(path))
1813
+ write_text_file(path, JSON.pretty_generate("external_packages" => Array(names).map(&:to_s).uniq.sort))
1629
1814
  end
1630
1815
 
1631
- # Flet extension packages expose an Extension class from a library named
1816
+ def discover_registered_external_extension_names(client_dir)
1817
+ known_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1818
+ client_entrypoint_paths(client_dir).flat_map do |path|
1819
+ next [] unless File.file?(path)
1820
+
1821
+ content = read_text_file(path)
1822
+ content.scan(%r{import 'package:([^/]+)/[^']+'\s+as\s+([A-Za-z0-9_]+);}).filter_map do |package, import_alias|
1823
+ next if known_packages.include?(package)
1824
+ next unless content.match?(/^\s*#{Regexp.escape(import_alias)}\.Extension\(\),\s*$/)
1825
+
1826
+ package
1827
+ end
1828
+ end.uniq
1829
+ end
1830
+
1831
+ def prune_external_extension_registrations(path, package_names)
1832
+ return if package_names.empty?
1833
+
1834
+ content = read_text_file(path)
1835
+ original = content.dup
1836
+ package_names.each do |package|
1837
+ escaped = Regexp.escape(package)
1838
+ aliases = content.scan(%r{^import 'package:#{escaped}/[^']+'\s+as\s+([A-Za-z0-9_]+);\s*$}).flatten
1839
+ content.gsub!(%r{^import 'package:#{escaped}/[^']+'\s+as\s+[A-Za-z0-9_]+;\s*\n}, "")
1840
+ aliases.each do |import_alias|
1841
+ content.gsub!(/^\s*#{Regexp.escape(import_alias)}\.Extension\(\),\s*\n/, "")
1842
+ end
1843
+ end
1844
+ write_text_file(path, content) unless content == original
1845
+ end
1846
+
1847
+ def validate_flutter_extension_selection(client_dir)
1848
+ selected = Array(@ruflet_selected_flutter_extension_packages)
1849
+ selected_external = Array(@ruflet_selected_external_extension_packages)
1850
+ pubspec_path = File.join(client_dir, "pubspec.yaml")
1851
+ return true unless File.file?(pubspec_path)
1852
+
1853
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1854
+ dependencies = data["dependencies"].is_a?(Hash) ? data["dependencies"].keys.map(&:to_s) : []
1855
+ optional = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1856
+ unexpected = (dependencies & optional) - selected
1857
+ missing = selected - dependencies
1858
+ managed_external = Array(@ruflet_managed_external_extension_packages)
1859
+ unexpected_external = (dependencies & managed_external) - selected_external
1860
+ errors = []
1861
+ errors << "unused Flutter extensions remain: #{unexpected.join(', ')}" unless unexpected.empty?
1862
+ errors << "selected Flutter extensions are missing: #{missing.join(', ')}" unless missing.empty?
1863
+ errors << "unused external Flutter extensions remain: #{unexpected_external.join(', ')}" unless unexpected_external.empty?
1864
+ return true if errors.empty?
1865
+
1866
+ errors.each { |message| warn "build config error: #{message}" }
1867
+ false
1868
+ rescue StandardError => e
1869
+ warn "build config error: could not verify Flutter extension pruning: #{e.message}"
1870
+ false
1871
+ end
1872
+
1873
+ # Ruflet extension packages expose an Extension class from a library named
1632
1874
  # after the package, so the import and registration can be derived.
1633
1875
  def sync_external_extension_registrations(path, entries)
1634
1876
  return if entries.empty?
@@ -1665,6 +1907,28 @@ module Ruflet
1665
1907
  end
1666
1908
  end
1667
1909
 
1910
+ def configured_service_entries_with_requirements(config)
1911
+ entries = configured_service_entries(config)
1912
+ configured_extensions = Array(config["extensions"]).filter_map do |entry|
1913
+ normalize_extension_key(entry) unless entry.is_a?(Hash)
1914
+ end
1915
+ configured_extensions.flat_map do |extension|
1916
+ EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
1917
+ end.each do |service|
1918
+ entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
1919
+ end
1920
+ entries
1921
+ end
1922
+
1923
+ def configured_extension_keys(config)
1924
+ services = configured_service_entries(config).map { |entry| entry[:name] }
1925
+ requested_extensions = Array(config["extensions"]).filter_map do |value|
1926
+ normalize_extension_key(value) unless value.is_a?(Hash)
1927
+ end
1928
+ protected_extensions = services.flat_map { |name| PROTECTED_SERVICE_EXTENSIONS.fetch(name, []) }
1929
+ (requested_extensions + protected_extensions + services).uniq
1930
+ end
1931
+
1668
1932
  ANDROID_SIGNING_KEYS = {
1669
1933
  "storeFile" => "RUFLET_ANDROID_KEYSTORE",
1670
1934
  "storePassword" => "RUFLET_ANDROID_KEYSTORE_PASSWORD",
@@ -1739,18 +2003,10 @@ module Ruflet
1739
2003
  end
1740
2004
 
1741
2005
  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
-
2006
+ entries = configured_service_entries_with_requirements(config)
1752
2007
  apply_android_service_permissions(client_dir, entries)
1753
2008
  apply_ios_service_usage_descriptions(client_dir, entries)
2009
+ apply_macos_service_permissions(client_dir, entries)
1754
2010
  end
1755
2011
 
1756
2012
  def apply_android_service_permissions(client_dir, entries)
@@ -1758,6 +2014,12 @@ module Ruflet
1758
2014
  return unless File.file?(path)
1759
2015
 
1760
2016
  content = read_text_file(path)
2017
+ MANAGED_ANDROID_PERMISSIONS.each do |permission|
2018
+ content.gsub!(
2019
+ %r{^\s*<uses-permission\b[^>]*android:name="#{Regexp.escape(permission)}"[^>]*/>\s*\n?},
2020
+ ""
2021
+ )
2022
+ end
1761
2023
  entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
1762
2024
  next if content.include?(%(android:name="#{permission}"))
1763
2025
 
@@ -1768,22 +2030,56 @@ module Ruflet
1768
2030
 
1769
2031
  def apply_ios_service_usage_descriptions(client_dir, entries)
1770
2032
  path = File.join(client_dir, "ios", "Runner", "Info.plist")
2033
+ apply_apple_service_usage_descriptions(
2034
+ path, entries, IOS_SERVICE_USAGE_KEYS, MANAGED_IOS_USAGE_KEYS)
2035
+ end
2036
+
2037
+ def apply_macos_service_permissions(client_dir, entries)
2038
+ plist = File.join(client_dir, "macos", "Runner", "Info.plist")
2039
+ apply_apple_service_usage_descriptions(
2040
+ plist, entries, MACOS_SERVICE_USAGE_KEYS, MANAGED_MACOS_USAGE_KEYS)
2041
+
2042
+ %w[DebugProfile.entitlements Release.entitlements].each do |name|
2043
+ path = File.join(client_dir, "macos", "Runner", name)
2044
+ next unless File.file?(path)
2045
+
2046
+ content = read_text_file(path)
2047
+ MANAGED_MACOS_SERVICE_ENTITLEMENTS.each do |key|
2048
+ content.gsub!(
2049
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m,
2050
+ ""
2051
+ )
2052
+ end
2053
+ entries.filter_map { |entry| MACOS_SERVICE_ENTITLEMENTS[entry[:name]] }.uniq.each do |key|
2054
+ pair = "\t<key>#{key}</key>\n\t<true/>\n"
2055
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2056
+ end
2057
+ write_text_file(path, content)
2058
+ end
2059
+ end
2060
+
2061
+ def apply_apple_service_usage_descriptions(path, entries, usage_keys, managed_keys)
1771
2062
  return unless File.file?(path)
1772
2063
 
1773
2064
  content = read_text_file(path)
2065
+ managed_keys.each do |key|
2066
+ content.gsub!(
2067
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m,
2068
+ ""
2069
+ )
2070
+ end
1774
2071
  entries.each do |entry|
1775
- key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
1776
- next unless key
1777
-
1778
2072
  description = entry[:description]
1779
2073
  description = "This app uses #{entry[:name]} access for its Ruflet features." if description.empty?
1780
2074
  escaped_description = xml_escape(description)
1781
- pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
2075
+ Array(usage_keys[entry[:name]]).each do |key|
2076
+ pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
1782
2077
 
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>")
2078
+ if content.match?(%r{<key>#{Regexp.escape(key)}</key>})
2079
+ content.sub!(%r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m, "<key>#{key}</key>\n\t<string>#{escaped_description}</string>")
2080
+ else
2081
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2082
+ end
1787
2083
  end
1788
2084
  end
1789
2085
  write_text_file(path, content)
@@ -1798,12 +2094,25 @@ module Ruflet
1798
2094
  end
1799
2095
 
1800
2096
  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
- ]
2097
+ stale_paths = case platform
2098
+ when "ios"
2099
+ %w[
2100
+ build/ios/Debug-iphonesimulator
2101
+ build/ios/iphonesimulator
2102
+ ]
2103
+ when "android", "apk", "aab"
2104
+ # Flutter and Gradle merge assets incrementally. When a build switches
2105
+ # from full to lite, removed project gems can otherwise survive in the
2106
+ # next APK even though pubspec.yaml and the source asset tree are clean.
2107
+ %w[
2108
+ build/app/intermediates/flutter
2109
+ build/app/intermediates/assets
2110
+ build/app/intermediates/compressed_assets
2111
+ build/app/intermediates/incremental/mergeReleaseAssets
2112
+ ]
2113
+ else
2114
+ []
2115
+ end
1807
2116
 
1808
2117
  stale_paths.each do |relative_path|
1809
2118
  path = File.join(client_dir, relative_path)
@@ -1812,6 +2121,23 @@ module Ruflet
1812
2121
  FileUtils.rm_rf(path)
1813
2122
  build_log(verbose, "cleared stale #{relative_path}")
1814
2123
  end
2124
+
2125
+ return unless platform == "ios"
2126
+
2127
+ # Xcode's incremental App.framework copy does not remove files that
2128
+ # disappeared from a Flutter asset directory. Clear only the generated
2129
+ # embedded project tree so a removed gem/archive cannot survive the
2130
+ # next signed build; native compilation outputs remain reusable.
2131
+ asset_pattern = File.join(
2132
+ client_dir, "build", "ios", "**", "flutter_assets", "assets",
2133
+ self_contained_project_name)
2134
+ Dir.glob(asset_pattern).each do |asset_root|
2135
+ next unless Dir.exist?(asset_root)
2136
+
2137
+ FileUtils.rm_rf(asset_root)
2138
+ relative_path = Pathname.new(asset_root).relative_path_from(Pathname.new(client_dir))
2139
+ build_log(verbose, "cleared stale #{relative_path}")
2140
+ end
1815
2141
  end
1816
2142
 
1817
2143
  def client_entrypoint_paths(client_dir)
@@ -1822,14 +2148,14 @@ module Ruflet
1822
2148
 
1823
2149
  def configure_client_runtime_mode(client_dir, self_contained:, verbose: false)
1824
2150
  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
2151
  if self_contained
1827
- sync_self_contained_project_assets(client_dir, verbose: verbose)
2152
+ return false unless sync_self_contained_project_assets(client_dir, verbose: verbose)
1828
2153
  remove_local_ruby_runtime_override(client_dir, verbose: verbose)
1829
2154
  else
1830
2155
  remove_self_contained_project_assets(client_dir, verbose: verbose)
1831
2156
  remove_local_ruby_runtime_override(client_dir, verbose: verbose)
1832
2157
  end
2158
+ sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
1833
2159
  true
1834
2160
  end
1835
2161
 
@@ -1840,24 +2166,25 @@ module Ruflet
1840
2166
  data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1841
2167
  dependencies = data["dependencies"]
1842
2168
  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
2169
  flutter = data["flutter"]
1846
2170
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1847
2171
  assets = Array(flutter["assets"]).map(&:to_s)
2172
+ project_prefix = "assets/#{self_contained_project_name}/"
2173
+ # The exact directory list changes between lite and full (most notably
2174
+ # vendor/bundle). Remove the previous profile's generated entries
2175
+ # before adding the directories that exist in this build.
2176
+ assets.reject! { |asset| asset == project_prefix || asset.start_with?(project_prefix) }
1848
2177
 
1849
2178
  if self_contained
1850
2179
  dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
1851
2180
  # Flutter does not recurse into asset directories, so every subdirectory
1852
2181
  # of the embedded project (e.g. standalone_apps/<slug>/) must be listed
1853
2182
  # explicitly or its files never reach the bundle/manifest on device.
1854
- self_contained_project_asset_dirs.each do |dir_entry|
2183
+ self_contained_project_asset_dirs(client_dir).each do |dir_entry|
1855
2184
  assets << dir_entry unless assets.include?(dir_entry)
1856
2185
  end
1857
2186
  else
1858
2187
  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
2188
  end
1862
2189
 
1863
2190
  flutter["assets"] = assets unless assets.empty?
@@ -1868,15 +2195,75 @@ module Ruflet
1868
2195
  # A self-contained build needs the embedded VM. Prefer a local checkout so
1869
2196
  # the runtime under development is the one packaged, and otherwise resolve
1870
2197
  # the published package.
1871
- PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.9"
2198
+ # 0.0.13 is the first release where the platform layer starts the VM and
2199
+ # exposes serverUrl(); the client entrypoint calls it, so an older runtime
2200
+ # would not build.
2201
+ PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.14"
1872
2202
 
1873
2203
  def ruby_runtime_dependency(current_dependency = nil)
2204
+ if embedded_runtime_profile == :full && @ruflet_full_runtime_path
2205
+ return { "path" => @ruflet_full_runtime_path }
2206
+ end
2207
+
1874
2208
  local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
1875
2209
  return { "path" => local_path } if local_path
1876
2210
 
1877
2211
  current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
1878
2212
  end
1879
2213
 
2214
+ def configure_full_runtime_distribution(config, platform, verbose: false)
2215
+ configured = ENV["RUFLET_FULL_RUNTIME_PATH"].to_s.strip
2216
+ build_config = config["build"].is_a?(Hash) ? config["build"] : {}
2217
+ configured = build_config["full_runtime_path"].to_s.strip if configured.empty?
2218
+ candidates = []
2219
+ candidates << File.expand_path(configured, Dir.pwd) unless configured.empty?
2220
+ local_runtime = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
2221
+ candidates << local_runtime if local_runtime
2222
+
2223
+ candidates.compact.uniq.each do |root|
2224
+ manifest_path = File.join(root, FULL_RUNTIME_MANIFEST)
2225
+ pubspec_path = File.join(root, "pubspec.yaml")
2226
+ next unless File.file?(manifest_path) && File.file?(pubspec_path)
2227
+
2228
+ manifest = JSON.parse(read_text_file(manifest_path))
2229
+ next unless manifest["engine"] == "cruby"
2230
+ platforms = Array(manifest["platforms"]).map(&:to_s)
2231
+ target = normalized_runtime_platform(platform)
2232
+ next unless platforms.include?(target)
2233
+
2234
+ pubspec = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
2235
+ next unless pubspec["name"] == "ruby_runtime"
2236
+
2237
+ @ruflet_full_runtime_path = File.expand_path(root)
2238
+ @ruflet_full_runtime_manifest = manifest
2239
+ build_log(
2240
+ verbose,
2241
+ "full CRuby runtime=#{@ruflet_full_runtime_path} ruby=#{manifest['ruby_version']}"
2242
+ )
2243
+ return true
2244
+ rescue JSON::ParserError, Psych::SyntaxError => e
2245
+ build_log(verbose, "ignored invalid full runtime at #{root}: #{e.message}")
2246
+ end
2247
+
2248
+ warn "build config error: --full needs a CRuby runtime distribution for #{normalized_runtime_platform(platform)}"
2249
+ warn "Set RUFLET_FULL_RUNTIME_PATH to a ruby_runtime Flutter package containing #{FULL_RUNTIME_MANIFEST}."
2250
+ warn "The bundled ruby_runtime is the lite mruby engine and will not be mislabeled as CRuby."
2251
+ false
2252
+ end
2253
+
2254
+ def full_runtime_device_only?
2255
+ embedded_runtime_profile == :full &&
2256
+ @ruflet_full_runtime_manifest.is_a?(Hash) &&
2257
+ @ruflet_full_runtime_manifest["device_only"] == true
2258
+ end
2259
+
2260
+ def normalized_runtime_platform(platform)
2261
+ return "android" if %w[apk android aab appbundle].include?(platform.to_s)
2262
+ return "ios" if %w[ios ipa].include?(platform.to_s)
2263
+
2264
+ platform.to_s
2265
+ end
2266
+
1880
2267
  def explicit_local_ruby_runtime_path
1881
2268
  env_path = ENV["RUFLET_RUBY_RUNTIME_PATH"].to_s.strip
1882
2269
  return nil if env_path.empty?
@@ -1894,28 +2281,51 @@ module Ruflet
1894
2281
  nil
1895
2282
  end
1896
2283
 
1897
- def refresh_managed_client_template_files(client_dir, verbose: false)
2284
+ def refresh_managed_client_template_files(client_dir, platform: nil, verbose: false)
1898
2285
  template_root =
1899
2286
  if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
1900
2287
  Ruflet::CLI.send(:resolve_ruflet_client_template_root)
1901
2288
  end
1902
2289
  return unless template_root && Dir.exist?(template_root)
2290
+ return false unless validate_template_ruflet_source_integrity(
2291
+ template_root, verbose: verbose)
1903
2292
 
1904
2293
  managed_files = [
1905
2294
  "lib/main.dart",
1906
2295
  "lib/main.self.dart",
1907
2296
  "lib/main.server.dart",
1908
- "lib/ruflet_file_picker_service.dart",
1909
2297
  "lib/connection_probe.dart",
1910
2298
  "lib/connection_probe_io.dart",
1911
2299
  "lib/connection_probe_stub.dart",
1912
2300
  "ios/Podfile",
2301
+ # Carries the usage descriptions and the local-network/ATS policy a
2302
+ # preview client needs. Without refreshing it, a client generated
2303
+ # before those keys existed keeps a plist that silently blocks
2304
+ # connections to a development server on the local network.
2305
+ "ios/Runner/Info.plist",
1913
2306
  "windows/CMakeLists.txt",
1914
2307
  # Release signing lives here; an existing client would otherwise keep
1915
2308
  # signing release builds with the debug key.
1916
2309
  "android/app/build.gradle.kts"
1917
2310
  ]
1918
2311
 
2312
+ case platform.to_s
2313
+ when "ios", "ipa"
2314
+ managed_files.concat(
2315
+ [
2316
+ "ios/Runner/AppDelegate.swift",
2317
+ "ios/Runner.xcodeproj/project.pbxproj"
2318
+ ]
2319
+ )
2320
+ when "macos"
2321
+ managed_files.concat(
2322
+ [
2323
+ "macos/Runner/MainFlutterWindow.swift",
2324
+ "macos/Runner.xcodeproj/project.pbxproj"
2325
+ ]
2326
+ )
2327
+ end
2328
+
1919
2329
  managed_files.each do |relative_path|
1920
2330
  source = File.join(template_root, relative_path)
1921
2331
  next unless File.file?(source)
@@ -1925,6 +2335,336 @@ module Ruflet
1925
2335
  FileUtils.cp(source, destination)
1926
2336
  build_log(verbose, "refreshed template file #{relative_path}")
1927
2337
  end
2338
+
2339
+ # Older managed clients carried a macOS-only FilePicker override. Ruflet's
2340
+ # core service owns FilePicker now, so this duplicate must not survive a
2341
+ # template refresh.
2342
+ FileUtils.rm_f(File.join(client_dir, "lib", "ruflet_file_picker_service.dart"))
2343
+ true
2344
+ end
2345
+
2346
+ # Every application build copies its engine and extension packages out of
2347
+ # the template. Validate the pinned inventory before that copy so a stale,
2348
+ # partially updated, or locally contaminated template can never become an
2349
+ # application dependency by accident.
2350
+ def validate_template_ruflet_source_integrity(template_root, verbose: false)
2351
+ packages_root = File.join(template_root, "ruflet_packages")
2352
+ return true unless Dir.exist?(packages_root)
2353
+
2354
+ manifest_path = File.join(template_root, RUFLET_SOURCE_INTEGRITY_PATH)
2355
+ raise "missing #{RUFLET_SOURCE_INTEGRITY_PATH}" unless File.file?(manifest_path)
2356
+
2357
+ manifest = JSON.parse(read_text_file(manifest_path))
2358
+ raise "unsupported source manifest version" unless manifest["manifest_version"] == 5
2359
+ raise "unexpected source package" unless manifest["package_name"] == "ruflet-engine"
2360
+ raise "invalid source revision" unless manifest["source_ref"].to_s.match?(/\A[0-9a-f]{40}\z/)
2361
+
2362
+ files = manifest.fetch("files")
2363
+ raise "empty source inventory" unless files.is_a?(Hash) && !files.empty?
2364
+
2365
+ actual = []
2366
+ Find.find(packages_root) do |path|
2367
+ next if path == packages_root
2368
+
2369
+ relative = path.delete_prefix("#{packages_root}#{File::SEPARATOR}")
2370
+ if RUFLET_SOURCE_IGNORED_DIRECTORIES.include?(File.basename(path))
2371
+ Find.prune if File.directory?(path)
2372
+ next
2373
+ end
2374
+ next if RUFLET_SOURCE_IGNORED_FILES.include?(File.basename(path))
2375
+ next if File.directory?(path)
2376
+ raise "symbolic link in engine source: #{path}" if File.symlink?(path)
2377
+
2378
+ actual << relative
2379
+ end
2380
+ expected = files.keys.sort
2381
+ unless actual.sort == expected
2382
+ missing = expected - actual
2383
+ unexpected = actual - expected
2384
+ details = []
2385
+ details << "missing: #{missing.first(5).join(', ')}" unless missing.empty?
2386
+ details << "unexpected: #{unexpected.first(5).join(', ')}" unless unexpected.empty?
2387
+ raise "source inventory mismatch (#{details.join('; ')})"
2388
+ end
2389
+
2390
+ files.each do |relative, entry|
2391
+ path = File.expand_path(relative, packages_root)
2392
+ prefix = "#{File.expand_path(packages_root)}#{File::SEPARATOR}"
2393
+ raise "unsafe source path: #{relative}" unless path.start_with?(prefix)
2394
+ raise "source path mismatch: #{relative}" unless entry["source_path"] == relative
2395
+ raise "non-exact source entry: #{relative}" unless entry["classification"] == "exact_source"
2396
+
2397
+ expected_sha = entry["vendored_sha256"].to_s
2398
+ raise "invalid source digest: #{relative}" unless expected_sha.match?(/\A[0-9a-f]{64}\z/)
2399
+ actual_sha = Digest::SHA256.file(path).hexdigest
2400
+ raise "source content drift: #{relative}" unless actual_sha == expected_sha
2401
+ end
2402
+
2403
+ build_note(
2404
+ "Verified #{files.length} pinned Ruflet engine and extension source files " \
2405
+ "at #{manifest.fetch('source_ref')}"
2406
+ )
2407
+ true
2408
+ rescue StandardError => e
2409
+ warn "build config error: Ruflet template source verification failed: #{e.message}"
2410
+ build_log(verbose, "template source integrity failure at #{template_root}")
2411
+ false
2412
+ end
2413
+
2414
+ # Ruflet had a second, Swift-rendered Apple host. It is gone: every
2415
+ # platform renders through the Ruflet Flutter engine. A client generated
2416
+ # while it existed still carries the Swift package, the engine-choice
2417
+ # shim and the Xcode references, so a rebuild strips them.
2418
+ def remove_native_apple_renderer_integration(client_dir, platform:, verbose: false)
2419
+ apple_platform = case platform.to_s
2420
+ when "ios", "ipa" then "ios"
2421
+ when "macos" then "macos"
2422
+ end
2423
+ return true unless apple_platform
2424
+
2425
+ unless restore_flutter_apple_host(client_dir, apple_platform, verbose: verbose)
2426
+ return false
2427
+ end
2428
+ strip_native_apple_xcode_project(client_dir, apple_platform)
2429
+ FileUtils.rm_f(File.join(client_dir, apple_platform, "Runner", "RufletEngineChoice.swift"))
2430
+ FileUtils.rm_rf(File.join(client_dir, "apple_packages"))
2431
+ FileUtils.rm_rf(File.join(client_dir, "apple_extensions"))
2432
+ configure_ios_scene_delegate(client_dir) if apple_platform == "ios"
2433
+ apple_info_plist_paths(client_dir).each do |path|
2434
+ remove_plist_value(path, "RufletExperimentalNativeRenderer")
2435
+ remove_plist_value(path, "GADApplicationIdentifier")
2436
+ end
2437
+ build_log(verbose, "removed native Apple renderer integration")
2438
+ true
2439
+ end
2440
+
2441
+ def restore_flutter_apple_host(client_dir, platform, verbose: false)
2442
+ template_root = if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
2443
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
2444
+ end
2445
+ filename = platform == "ios" ? "AppDelegate.swift" : "MainFlutterWindow.swift"
2446
+ source = template_root && File.join(template_root, "flutter_hosts", platform, "Runner", filename)
2447
+ unless source && File.file?(source)
2448
+ warn "build config error: the Ruflet client template is missing the Flutter-only #{platform} host"
2449
+ return false
2450
+ end
2451
+
2452
+ destination = File.join(client_dir, platform, "Runner", filename)
2453
+ FileUtils.mkdir_p(File.dirname(destination))
2454
+ FileUtils.cp(source, destination)
2455
+ build_log(verbose, "restored Flutter-only #{platform} host")
2456
+ true
2457
+ end
2458
+
2459
+ def strip_native_apple_xcode_project(client_dir, platform)
2460
+ path = File.join(client_dir, platform, "Runner.xcodeproj", "project.pbxproj")
2461
+ return unless File.file?(path)
2462
+
2463
+ content = read_text_file(path)
2464
+ content.gsub!(
2465
+ %r{^\s*[A-F0-9]+ /\* XCLocalSwiftPackageReference "\.\./apple_(?:extensions|packages/ruflet_apple)" \*/ = \{.*?^\s*\};\n}m,
2466
+ ""
2467
+ )
2468
+ content = content.lines.reject do |line|
2469
+ line.include?("Ruflet") ||
2470
+ line.include?("../apple_extensions") ||
2471
+ line.include?("../apple_packages/ruflet_apple")
2472
+ end.join
2473
+ write_text_file(path, content)
2474
+ end
2475
+
2476
+ # Only the Flutter host remains, so the scene delegate is Flutter's own.
2477
+ # A client built against the Swift renderer names RufletSceneDelegate,
2478
+ # which lived in the deleted package -- launching it would trap on a
2479
+ # missing class. FlutterSceneDelegate comes from the Flutter framework,
2480
+ # so it carries no $(PRODUCT_MODULE_NAME) prefix.
2481
+ def configure_ios_scene_delegate(client_dir)
2482
+ path = File.join(client_dir, "ios", "Runner", "Info.plist")
2483
+ return unless File.file?(path)
2484
+
2485
+ content = read_text_file(path)
2486
+ content.gsub!(
2487
+ %r{(<key>UISceneDelegateClassName</key>\s*<string>)(?:\$\(PRODUCT_MODULE_NAME\)\.)?(?:RufletSceneDelegate|FlutterSceneDelegate|SceneDelegate)(</string>)}m,
2488
+ "\\1FlutterSceneDelegate\\2"
2489
+ )
2490
+ write_text_file(path, content)
2491
+ end
2492
+
2493
+ def apple_info_plist_paths(client_dir)
2494
+ [
2495
+ File.join(client_dir, "ios", "Runner", "Info.plist"),
2496
+ File.join(client_dir, "macos", "Runner", "Info.plist")
2497
+ ].select { |path| File.file?(path) }
2498
+ end
2499
+
2500
+ def remove_plist_value(path, key)
2501
+ content = read_text_file(path)
2502
+ content.gsub!(
2503
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*(?:<string>.*?</string>|<(?:true|false)\s*/>)}m,
2504
+ ""
2505
+ )
2506
+ write_text_file(path, content)
2507
+ end
2508
+
2509
+ def remove_plist_dictionary_boolean(path, dictionary_key, key)
2510
+ content = read_text_file(path)
2511
+ dictionary = content.match(
2512
+ %r{<key>#{Regexp.escape(dictionary_key)}</key>\s*<dict>}m)
2513
+ return unless dictionary
2514
+
2515
+ opening = content.index("<dict>", dictionary.begin(0))
2516
+ return unless opening
2517
+
2518
+ depth = 0
2519
+ closing = nil
2520
+ content.to_enum(:scan, %r{</?dict>}).each do
2521
+ token = Regexp.last_match
2522
+ next if token.begin(0) < opening
2523
+
2524
+ depth += token[0] == "<dict>" ? 1 : -1
2525
+ if depth.zero?
2526
+ closing = token
2527
+ break
2528
+ end
2529
+ end
2530
+ return unless closing
2531
+
2532
+ body_start = opening + "<dict>".length
2533
+ body = content[body_start...closing.begin(0)]
2534
+ updated_body = body.gsub(
2535
+ %r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m,
2536
+ ""
2537
+ )
2538
+ return if updated_body == body
2539
+
2540
+ if updated_body.strip.empty?
2541
+ pair_start = dictionary.begin(0)
2542
+ line_start = content.rindex("\n", pair_start - 1)
2543
+ if line_start && content[(line_start + 1)...pair_start].strip.empty?
2544
+ pair_start = line_start + 1
2545
+ end
2546
+ content[pair_start...closing.end(0)] = ""
2547
+ else
2548
+ content[body_start...closing.begin(0)] = updated_body
2549
+ end
2550
+ write_text_file(path, content)
2551
+ end
2552
+
2553
+ def configure_native_apple_runtime(
2554
+ client_dir, platform:, self_contained:, config: {}, verbose: false
2555
+ )
2556
+ plist_paths = case platform.to_s
2557
+ when "ios", "ipa"
2558
+ [File.join(client_dir, "ios", "Runner", "Info.plist")]
2559
+ when "macos"
2560
+ [File.join(client_dir, "macos", "Runner", "Info.plist")]
2561
+ else
2562
+ []
2563
+ end
2564
+
2565
+ plist_paths.each do |path|
2566
+ next unless File.file?(path)
2567
+
2568
+ upsert_plist_string(
2569
+ path, "RufletEmbeddedProject",
2570
+ self_contained ? self_contained_project_name : "")
2571
+ upsert_plist_boolean(path, "RufletRuntimeAutostart", self_contained)
2572
+ upsert_plist_string(
2573
+ path, "RufletRuntimeProfile",
2574
+ self_contained ? embedded_runtime_profile.to_s : "")
2575
+ remove_plist_value(path, "RufletExperimentalNativeRenderer")
2576
+ apple_platform = platform.to_s == "ipa" ? "ios" : platform.to_s
2577
+ platform_config = platform_build_config(config, apple_platform)
2578
+ if platform_config["local_network"] == true
2579
+ # The embedded VM is in-process, but the application can still
2580
+ # explicitly connect to LAN services (for example RufletApp).
2581
+ description = platform_config["local_network_usage_description"].to_s.strip
2582
+ description = "Connect to Ruflet applications and services on your local network." if description.empty?
2583
+ upsert_plist_string(path, "NSLocalNetworkUsageDescription", description)
2584
+ upsert_plist_dictionary_boolean(
2585
+ path, "NSAppTransportSecurity", "NSAllowsLocalNetworking", true)
2586
+ elsif self_contained
2587
+ remove_plist_value(path, "NSLocalNetworkUsageDescription")
2588
+ remove_plist_dictionary_boolean(
2589
+ path, "NSAppTransportSecurity", "NSAllowsLocalNetworking")
2590
+ end
2591
+ message = if self_contained
2592
+ "native Apple runtime autostarts #{self_contained_project_name}"
2593
+ else
2594
+ "native Apple runtime uses the server URL resolved by Dart"
2595
+ end
2596
+ build_log(verbose, message)
2597
+ end
2598
+ end
2599
+
2600
+ def configure_android_runtime(client_dir, platform:, self_contained:, verbose: false)
2601
+ return unless %w[apk android aab appbundle].include?(platform.to_s)
2602
+
2603
+ path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
2604
+ return unless File.file?(path)
2605
+
2606
+ upsert_android_metadata(path, "ruflet.runtime.autostart", self_contained.to_s)
2607
+ upsert_android_metadata(
2608
+ path, "ruflet.runtime.project",
2609
+ self_contained ? self_contained_project_name : "")
2610
+ upsert_android_metadata(
2611
+ path, "ruflet.runtime.profile",
2612
+ self_contained ? embedded_runtime_profile.to_s : "")
2613
+ build_log(
2614
+ verbose,
2615
+ self_contained ? "android runtime autostarts #{self_contained_project_name} (#{embedded_runtime_profile})" : "android embedded runtime autostart disabled"
2616
+ )
2617
+ end
2618
+
2619
+ def upsert_android_metadata(path, name, value)
2620
+ content = read_text_file(path)
2621
+ escaped_value = xml_escape(value)
2622
+ tag = %( <meta-data android:name="#{name}" android:value="#{escaped_value}" />)
2623
+ pattern = %r{\s*<meta-data\s+android:name=["']#{Regexp.escape(name)}["'][^>]*/>}m
2624
+ if content.match?(pattern)
2625
+ content.sub!(pattern, "\n#{tag}")
2626
+ else
2627
+ content.sub!(%r{(<application\b[^>]*>)}, "\\1\n#{tag}")
2628
+ end
2629
+ write_text_file(path, content)
2630
+ end
2631
+
2632
+ def upsert_plist_string(path, key, value)
2633
+ content = read_text_file(path)
2634
+ pair = "\t<key>#{key}</key>\n\t<string>#{xml_escape(value)}</string>"
2635
+ pattern = %r{<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m
2636
+ if content.match?(pattern)
2637
+ content.sub!(pattern, pair.strip)
2638
+ else
2639
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}\n</dict>\n</plist>")
2640
+ end
2641
+ write_text_file(path, content)
2642
+ end
2643
+
2644
+ def upsert_plist_boolean(path, key, value)
2645
+ content = read_text_file(path)
2646
+ pair = "\t<key>#{key}</key>\n\t<#{value ? 'true' : 'false'}/>"
2647
+ pattern = %r{<key>#{Regexp.escape(key)}</key>\s*<(?:true|false)\s*/>}m
2648
+ if content.match?(pattern)
2649
+ content.sub!(pattern, pair.strip)
2650
+ else
2651
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}\n</dict>\n</plist>")
2652
+ end
2653
+ write_text_file(path, content)
2654
+ end
2655
+
2656
+ def upsert_plist_dictionary_boolean(path, dictionary_key, key, value)
2657
+ remove_plist_dictionary_boolean(path, dictionary_key, key)
2658
+ content = read_text_file(path)
2659
+ entry = "<key>#{key}</key>\n\t\t<#{value ? 'true' : 'false'}/>"
2660
+ opening = %r{(<key>#{Regexp.escape(dictionary_key)}</key>\s*<dict>)}m
2661
+ if content.match?(opening)
2662
+ content.sub!(opening) { "#{Regexp.last_match(1)}\n\t\t#{entry}" }
2663
+ else
2664
+ pair = "\t<key>#{dictionary_key}</key>\n\t<dict>\n\t\t#{entry}\n\t</dict>\n"
2665
+ content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
2666
+ end
2667
+ write_text_file(path, content)
1928
2668
  end
1929
2669
 
1930
2670
  def write_pubspec_yaml(path, data)
@@ -1952,11 +2692,24 @@ module Ruflet
1952
2692
  # Flutter asset directory entries for every folder of the embedded project
1953
2693
  # that contains packaged files. Derived from the exact copy list so the
1954
2694
  # pubspec asset dirs match what sync_self_contained_project_assets writes.
1955
- def self_contained_project_asset_dirs
2695
+ def self_contained_project_asset_dirs(client_dir = nil)
1956
2696
  prefix = "assets/#{self_contained_project_name}"
1957
2697
  dirs = project_asset_relative_paths.map { |rel| File.dirname(rel) }.uniq
2698
+ if client_dir
2699
+ packaged_root = File.join(client_dir, prefix)
2700
+ if Dir.exist?(packaged_root)
2701
+ Find.find(packaged_root) do |path|
2702
+ next unless File.file?(path)
2703
+
2704
+ relative = Pathname.new(path).relative_path_from(Pathname.new(packaged_root)).to_s
2705
+ dirs << File.dirname(relative)
2706
+ end
2707
+ end
2708
+ end
1958
2709
  project_dirs = dirs.map { |dir| dir == "." ? "#{prefix}/" : "#{prefix}/#{dir}/" }
1959
- project_dirs.sort
2710
+ manifest = File.join(client_dir.to_s, prefix, ".ruflet-runtime.json")
2711
+ project_dirs << "#{prefix}/.ruflet-runtime.json" if client_dir && File.file?(manifest)
2712
+ project_dirs.uniq.sort
1960
2713
  end
1961
2714
 
1962
2715
  def sync_self_contained_project_assets(client_dir, verbose: false)
@@ -1977,7 +2730,329 @@ module Ruflet
1977
2730
  copied += 1
1978
2731
  end
1979
2732
 
2733
+ profile = embedded_runtime_profile
2734
+ if profile == :full
2735
+ return false unless package_full_runtime_gems(
2736
+ project_root, destination_root, client_dir, verbose: verbose)
2737
+ else
2738
+ return false unless precompile_lite_runtime_project(
2739
+ destination_root, verbose: verbose)
2740
+ end
2741
+ write_embedded_runtime_manifest(
2742
+ project_root, destination_root, profile: profile,
2743
+ platform: @ruflet_build_platform)
2744
+
1980
2745
  build_log(verbose, "copied #{copied} project file#{copied == 1 ? '' : 's'} to assets/#{self_contained_project_name}")
2746
+ true
2747
+ end
2748
+
2749
+ def precompile_lite_runtime_project(destination_root, verbose: false)
2750
+ compiler = embedded_mrbc_path
2751
+ unless compiler
2752
+ build_log(verbose, "mrbc unavailable; packaging lite Ruby source")
2753
+ return true
2754
+ end
2755
+
2756
+ sources = Dir.glob(File.join(destination_root, "**", "*.rb")).sort
2757
+ compiled = []
2758
+ sources.each do |source|
2759
+ output = source.sub(/\.rb\z/, ".mrb")
2760
+ ok = run_external_command(
2761
+ {}, compiler, "-g", "-o", output, source,
2762
+ chdir: destination_root, unbundled: true)
2763
+ unless ok
2764
+ warn "Could not precompile #{Pathname.new(source).relative_path_from(Pathname.new(destination_root))} for --lite"
2765
+ return false
2766
+ end
2767
+ compiled << [source, output]
2768
+ end
2769
+ compiled.each { |source, _output| FileUtils.rm_f(source) }
2770
+ build_log(verbose, "precompiled #{compiled.length} Ruby file#{compiled.length == 1 ? '' : 's'} for fast lite startup")
2771
+ true
2772
+ end
2773
+
2774
+ def embedded_mrbc_path
2775
+ explicit = ENV["RUFLET_MRBC"].to_s.strip
2776
+ return explicit if !explicit.empty? && File.executable?(explicit)
2777
+
2778
+ runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
2779
+ candidates = []
2780
+ if runtime_root
2781
+ candidates.concat(
2782
+ %w[host_vm host].map do |build|
2783
+ File.join(runtime_root, "third_party", "mruby", "build", build, "bin", "mrbc")
2784
+ end
2785
+ )
2786
+ candidates << File.join(runtime_root, "bin", "mrbc")
2787
+ end
2788
+ candidates.find { |path| File.executable?(path) }
2789
+ end
2790
+
2791
+ def embedded_runtime_profile
2792
+ profile = @ruflet_runtime_profile
2793
+ EMBEDDED_RUNTIME_PROFILES.include?(profile) ? profile : :lite
2794
+ end
2795
+
2796
+ # A full build carries the locked application bundle alongside the
2797
+ # project. Bundler is isolated under build/client so this never creates or
2798
+ # rewrites .bundle state in the user's project. The cache key makes repeat
2799
+ # builds cheap: unchanged locks reuse the installed bundle and only copy
2800
+ # it into the Flutter asset tree.
2801
+ def package_full_runtime_gems(project_root, destination_root, client_dir, verbose: false)
2802
+ gemfile = project_root.join("Gemfile")
2803
+ return true unless gemfile.file?
2804
+
2805
+ lockfile = project_root.join("Gemfile.lock")
2806
+ unless lockfile.file?
2807
+ warn "full runtime build requires Gemfile.lock"
2808
+ warn "Run `bundle install` once so Ruflet can package a reproducible gem set."
2809
+ return false
2810
+ end
2811
+
2812
+ cache_key = Digest::SHA256.hexdigest(
2813
+ [
2814
+ gemfile.read,
2815
+ lockfile.read,
2816
+ RUBY_ENGINE,
2817
+ RUBY_VERSION,
2818
+ @ruflet_build_platform.to_s,
2819
+ FULL_RUNTIME_BUNDLE_SCHEMA,
2820
+ JSON.generate(@ruflet_full_runtime_manifest || {})
2821
+ ].join("\0")
2822
+ )
2823
+ cache_root = File.join(client_dir, ".ruflet", "full-bundle", cache_key)
2824
+ bundle_path = File.join(cache_root, "vendor", "bundle")
2825
+ complete_marker = File.join(cache_root, ".complete")
2826
+
2827
+ unless File.file?(complete_marker) && Dir.exist?(bundle_path)
2828
+ FileUtils.rm_rf(cache_root)
2829
+ FileUtils.mkdir_p(cache_root)
2830
+ bundle_env = {
2831
+ "BUNDLE_GEMFILE" => gemfile.to_s,
2832
+ "BUNDLE_PATH" => bundle_path,
2833
+ "BUNDLE_APP_CONFIG" => File.join(cache_root, "config"),
2834
+ "BUNDLE_WITHOUT" => "development:test",
2835
+ "BUNDLE_FROZEN" => "true",
2836
+ "BUNDLE_DEPLOYMENT" => "true"
2837
+ }
2838
+ build_note("Packaging locked project gems for the full CRuby runtime")
2839
+ build_log(verbose, "bundle cache=#{cache_root}")
2840
+ ok = run_full_runtime_gem_packager(
2841
+ bundle_env, gemfile: gemfile, lockfile: lockfile,
2842
+ bundle_path: bundle_path, project_root: project_root)
2843
+ unless ok
2844
+ warn "Could not package the locked project gems for --full"
2845
+ return false
2846
+ end
2847
+ unless vendor_full_runtime_path_gems(
2848
+ bundle_env, bundle_path: bundle_path, project_root: project_root)
2849
+ return false
2850
+ end
2851
+ prune_full_runtime_bundle_cache(bundle_path)
2852
+ return false unless validate_full_runtime_in_process_transport(bundle_path)
2853
+ File.write(complete_marker, "#{cache_key}\n")
2854
+ else
2855
+ build_log(verbose, "reusing full runtime gem cache #{cache_key[0, 12]}")
2856
+ return false unless validate_full_runtime_in_process_transport(bundle_path)
2857
+ end
2858
+
2859
+ packaged_bundle = File.join(destination_root, "vendor", "bundle")
2860
+ FileUtils.mkdir_p(File.dirname(packaged_bundle))
2861
+ FileUtils.cp_r(bundle_path, packaged_bundle)
2862
+ FileUtils.cp(gemfile.to_s, File.join(destination_root, "Gemfile"))
2863
+ FileUtils.cp(lockfile.to_s, File.join(destination_root, "Gemfile.lock"))
2864
+ true
2865
+ rescue StandardError => e
2866
+ warn "Could not package project gems for --full: #{e.class}: #{e.message}"
2867
+ false
2868
+ end
2869
+
2870
+ def run_full_runtime_gem_packager(bundle_env, gemfile:, lockfile:, bundle_path:, project_root:)
2871
+ packager = @ruflet_full_runtime_manifest && @ruflet_full_runtime_manifest["gem_packager"]
2872
+ unless packager.to_s.strip.empty?
2873
+ executable = File.expand_path(packager.to_s, @ruflet_full_runtime_path)
2874
+ unless File.executable?(executable)
2875
+ warn "Full runtime gem packager is not executable: #{executable}"
2876
+ return false
2877
+ end
2878
+ return run_external_command(
2879
+ bundle_env,
2880
+ executable,
2881
+ "--gemfile", gemfile.to_s,
2882
+ "--lockfile", lockfile.to_s,
2883
+ "--path", bundle_path,
2884
+ "--platform", normalized_runtime_platform(@ruflet_build_platform),
2885
+ chdir: project_root.to_s,
2886
+ unbundled: true
2887
+ )
2888
+ end
2889
+
2890
+ run_external_command(
2891
+ bundle_env, RbConfig.ruby, "-S", "bundle", "install",
2892
+ "--jobs", "4", "--retry", "3",
2893
+ chdir: project_root.to_s, unbundled: true
2894
+ )
2895
+ end
2896
+
2897
+ # Bundler intentionally leaves `path:` gems at their source location,
2898
+ # even when BUNDLE_PATH is set. A self-contained app cannot retain those
2899
+ # workstation paths. Materialize every resolved spec that sits outside
2900
+ # vendor/bundle so the runtime sees the exact locked source tree.
2901
+ def vendor_full_runtime_path_gems(bundle_env, bundle_path:, project_root:)
2902
+ script = <<~'RUBY'
2903
+ require "bundler"
2904
+ require "json"
2905
+ puts JSON.generate(Bundler.load.specs.map { |spec|
2906
+ {
2907
+ "full_name" => spec.full_name,
2908
+ "full_gem_path" => spec.full_gem_path,
2909
+ "extension_dir" => spec.extension_dir,
2910
+ "source_type" => spec.source.class.name,
2911
+ "files" => spec.files,
2912
+ "gemspec" => spec.to_ruby
2913
+ }
2914
+ })
2915
+ RUBY
2916
+ stdout, stderr, status = Open3.capture3(
2917
+ bundle_env, RbConfig.ruby, "-e", script,
2918
+ chdir: project_root.to_s)
2919
+ unless status.success?
2920
+ warn "Could not inspect locked project gems for --full"
2921
+ warn stderr unless stderr.to_s.strip.empty?
2922
+ return false
2923
+ end
2924
+
2925
+ ruby_abi = (@ruflet_full_runtime_manifest || {})["ruby_abi"].to_s
2926
+ ruby_abi = RbConfig::CONFIG.fetch("ruby_version") if ruby_abi.empty?
2927
+ installed_root = File.expand_path(bundle_path)
2928
+ specs = JSON.parse(stdout)
2929
+ specs.each do |spec|
2930
+ next unless spec.fetch("source_type") == "Bundler::Source::Path"
2931
+
2932
+ source = File.expand_path(spec.fetch("full_gem_path"))
2933
+ next if source == installed_root || source.start_with?("#{installed_root}#{File::SEPARATOR}")
2934
+
2935
+ full_name = spec.fetch("full_name")
2936
+ destination = File.join(bundle_path, "ruby", ruby_abi, "gems", full_name)
2937
+ FileUtils.rm_rf(destination)
2938
+ FileUtils.mkdir_p(File.dirname(destination))
2939
+ unless copy_full_runtime_gem_files(
2940
+ source, destination, spec.fetch("files"), full_name: full_name)
2941
+ return false
2942
+ end
2943
+
2944
+ specifications = File.join(bundle_path, "ruby", ruby_abi, "specifications")
2945
+ FileUtils.mkdir_p(specifications)
2946
+ File.write(File.join(specifications, "#{full_name}.gemspec"), spec.fetch("gemspec"))
2947
+
2948
+ extension_source = spec["extension_dir"].to_s
2949
+ next unless !extension_source.empty? && Dir.exist?(extension_source)
2950
+
2951
+ extension_destination = File.join(
2952
+ bundle_path, "ruby", ruby_abi, "extensions", "ruflet", full_name)
2953
+ FileUtils.rm_rf(extension_destination)
2954
+ FileUtils.mkdir_p(File.dirname(extension_destination))
2955
+ FileUtils.cp_r(extension_source, extension_destination)
2956
+ end
2957
+ true
2958
+ rescue JSON::ParserError, KeyError, SystemCallError => e
2959
+ warn "Could not vendor path gems for --full: #{e.class}: #{e.message}"
2960
+ false
2961
+ end
2962
+
2963
+ # Downloaded .gem archives are installation inputs, not runtime inputs.
2964
+ # Keeping them would duplicate every registry gem in the application.
2965
+ def prune_full_runtime_bundle_cache(bundle_path)
2966
+ Dir.glob(File.join(bundle_path, "ruby", "*", "cache")).each do |cache_dir|
2967
+ FileUtils.rm_rf(cache_dir)
2968
+ end
2969
+ end
2970
+
2971
+ # Full self builds cannot fall back to a local socket: the embedded
2972
+ # renderer and CRuby VM communicate only through the native in-memory
2973
+ # bridge. Reject an older locked ruflet_server while the app is still
2974
+ # being built instead of shipping a bundle that fails on first launch.
2975
+ def validate_full_runtime_in_process_transport(bundle_path)
2976
+ server_roots = Dir.glob(
2977
+ File.join(bundle_path, "ruby", "*", "gems", "ruflet_server-*")
2978
+ ).select { |path| Dir.exist?(path) }
2979
+ return true if server_roots.empty?
2980
+
2981
+ supported = server_roots.any? do |root|
2982
+ File.file?(
2983
+ File.join(root, "lib", "ruflet", "server", "in_process_connection.rb")
2984
+ )
2985
+ end
2986
+ return true if supported
2987
+
2988
+ warn "The locked ruflet_server gem does not support port-free --self --full builds."
2989
+ warn "Update the application's Gemfile.lock to a Ruflet release with in-process transport."
2990
+ false
2991
+ end
2992
+
2993
+ # A path gem is a source checkout, not an installable payload. Copy only
2994
+ # the files declared by its gemspec so build products, tests, caches and
2995
+ # other workstation state cannot leak into a mobile application bundle.
2996
+ def copy_full_runtime_gem_files(source, destination, files, full_name:)
2997
+ source_root = File.expand_path(source)
2998
+ destination_root = File.expand_path(destination)
2999
+ declared_files = Array(files).map(&:to_s).reject(&:empty?).uniq.sort
3000
+ if declared_files.empty?
3001
+ warn "Could not vendor path gem #{full_name}: its gemspec declares no files"
3002
+ return false
3003
+ end
3004
+
3005
+ declared_files.each do |relative_path|
3006
+ pathname = Pathname.new(relative_path)
3007
+ clean_path = pathname.cleanpath
3008
+ if pathname.absolute? || clean_path.each_filename.include?("..")
3009
+ warn "Could not vendor path gem #{full_name}: unsafe file path #{relative_path.inspect}"
3010
+ return false
3011
+ end
3012
+
3013
+ source_file = File.expand_path(clean_path.to_s, source_root)
3014
+ unless source_file.start_with?("#{source_root}#{File::SEPARATOR}") && File.file?(source_file)
3015
+ warn "Could not vendor path gem #{full_name}: declared file is missing: #{relative_path}"
3016
+ return false
3017
+ end
3018
+
3019
+ destination_file = File.expand_path(clean_path.to_s, destination_root)
3020
+ FileUtils.mkdir_p(File.dirname(destination_file))
3021
+ FileUtils.cp(source_file, destination_file, preserve: true)
3022
+ end
3023
+ true
3024
+ rescue SystemCallError => e
3025
+ warn "Could not vendor path gem #{full_name}: #{e.class}: #{e.message}"
3026
+ false
3027
+ end
3028
+
3029
+ def write_embedded_runtime_manifest(project_root, destination_root, profile:, platform:)
3030
+ paths = Dir.glob(File.join(destination_root, "**", "*"), File::FNM_DOTMATCH)
3031
+ .select { |path| File.file?(path) }
3032
+ .reject { |path| File.basename(path) == ".ruflet-runtime.json" }
3033
+ .sort
3034
+ digest = Digest::SHA256.new
3035
+ paths.each do |path|
3036
+ relative = Pathname.new(path).relative_path_from(Pathname.new(destination_root)).to_s
3037
+ digest << relative << "\0" << File.binread(path) << "\0"
3038
+ end
3039
+
3040
+ manifest = {
3041
+ "schema" => 1,
3042
+ "profile" => profile.to_s,
3043
+ "engine" => profile == :full ? "cruby" : "mruby",
3044
+ "platform" => platform.to_s,
3045
+ "project" => project_root.basename.to_s,
3046
+ "entrypoint" => File.file?(File.join(destination_root, "main.mrb")) ? "main.mrb" : "main.rb",
3047
+ "bundle_gemfile" => profile == :full && File.file?(File.join(destination_root, "Gemfile")) ? "Gemfile" : nil,
3048
+ "bundle_path" => profile == :full && Dir.exist?(File.join(destination_root, "vendor", "bundle")) ? "vendor/bundle" : nil,
3049
+ "cache_key" => digest.hexdigest,
3050
+ "warm_launch" => true
3051
+ }.compact
3052
+ File.write(
3053
+ File.join(destination_root, ".ruflet-runtime.json"),
3054
+ "#{JSON.pretty_generate(manifest)}\n"
3055
+ )
1981
3056
  end
1982
3057
 
1983
3058
  def remove_self_contained_project_assets(client_dir, verbose: false)
@@ -2025,6 +3100,62 @@ module Ruflet
2025
3100
  name
2026
3101
  end
2027
3102
 
3103
+ # Tells the platform layer to start the embedded runtime, and which
3104
+ # packaged project to run.
3105
+ #
3106
+ # This is platform configuration rather than a dart-define because the VM
3107
+ # starts before Dart does: on Apple platforms from the plugin's +load, on
3108
+ # Android from an androidx.startup provider. Neither can read a value that
3109
+ # only exists inside the Dart isolate.
3110
+ #
3111
+ # Desktop needs nothing here -- it has no manifest to carry a flag, and
3112
+ # treats the presence of a packaged project as the opt-in, which is the
3113
+ # same distinction this flag draws on the other platforms.
3114
+ def configure_platform_autostart(client_dir, platform, verbose: false)
3115
+ project = self_contained_project_name
3116
+ case platform
3117
+ when "ios", "macos"
3118
+ plist = File.join(client_dir, platform, "Runner", "Info.plist")
3119
+ return unless File.file?(plist)
3120
+
3121
+ set_plist_value(plist, "RufletRuntimeAutostart", "bool", "true")
3122
+ set_plist_value(plist, "RufletEmbeddedProject", "string", project)
3123
+ build_log(verbose, "enabled runtime autostart in #{platform}/Runner/Info.plist")
3124
+ when "apk", "appbundle", "android"
3125
+ manifest = File.join(
3126
+ client_dir, "android", "app", "src", "main", "AndroidManifest.xml"
3127
+ )
3128
+ return unless File.file?(manifest)
3129
+
3130
+ set_android_meta_data(manifest, "ruflet.runtime.autostart", "true")
3131
+ set_android_meta_data(manifest, "ruflet.runtime.project", project)
3132
+ build_log(verbose, "enabled runtime autostart in AndroidManifest.xml")
3133
+ end
3134
+ end
3135
+
3136
+ def set_plist_value(plist, key, type, value)
3137
+ # Delete first: PlistBuddy's Add fails on an existing key, and a rebuild
3138
+ # after renaming the project would otherwise keep the stale name.
3139
+ system("/usr/libexec/PlistBuddy", "-c", "Delete :#{key}", plist,
3140
+ out: File::NULL, err: File::NULL)
3141
+ system("/usr/libexec/PlistBuddy", "-c", "Add :#{key} #{type} #{value}",
3142
+ plist, out: File::NULL, err: File::NULL)
3143
+ end
3144
+
3145
+ def set_android_meta_data(manifest, name, value)
3146
+ contents = File.read(manifest)
3147
+ entry = %(<meta-data android:name="#{name}" android:value="#{value}" />)
3148
+ existing = /<meta-data\s+android:name="#{Regexp.escape(name)}"[^>]*\/>/
3149
+
3150
+ contents =
3151
+ if contents.match?(existing)
3152
+ contents.sub(existing, entry)
3153
+ else
3154
+ contents.sub(%r{(\n(\s*)</application>)}) { "\n#{$2} #{entry}#{$1}" }
3155
+ end
3156
+ File.write(manifest, contents)
3157
+ end
3158
+
2028
3159
  def skip_project_asset_directory?(relative)
2029
3160
  excluded_directories = %w[
2030
3161
  .git
@@ -2065,10 +3196,30 @@ module Ruflet
2065
3196
  false
2066
3197
  end
2067
3198
 
3199
+ # What a self-contained build actually needs from the project at runtime:
3200
+ # the entrypoint, the Ruby under lib/, and the assets that code loads by
3201
+ # path (image(src: "assets/logo.png"), lottie(...), fonts, audio).
3202
+ #
3203
+ # Everything else is already consumed by the time the app runs. Lite gems
3204
+ # are compiled into the VM; full-profile gems and lockfiles are staged by
3205
+ # package_full_runtime_gems instead of copied from the working tree.
3206
+ # ruflet.yaml and services.yaml have been turned into native configuration,
3207
+ # while tests, CI and store artwork were never runtime inputs. Listing
3208
+ # what belongs in rather than guessing what to leave out keeps unexpected
3209
+ # directories from being shipped --
3210
+ # release artwork once carried a lockfile that Apple read as an unsigned
3211
+ # code object and rejected the whole upload over.
3212
+ SELF_CONTAINED_PROJECT_ENTRYPOINT = "main.rb"
3213
+ SELF_CONTAINED_PROJECT_DIRECTORIES = %w[lib assets].freeze
3214
+
2068
3215
  def include_project_asset_file?(relative)
2069
3216
  basename = File.basename(relative)
2070
3217
  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)
3218
+
3219
+ unless self_contained_project_path?(relative)
3220
+ return false
3221
+ end
3222
+
2072
3223
  if secret_project_asset?(relative)
2073
3224
  build_note("Excluded #{relative} from the embedded project; it looks like a credential")
2074
3225
  return false
@@ -2076,6 +3227,13 @@ module Ruflet
2076
3227
  true
2077
3228
  end
2078
3229
 
3230
+ def self_contained_project_path?(relative)
3231
+ return true if relative == SELF_CONTAINED_PROJECT_ENTRYPOINT
3232
+
3233
+ top = relative.split(File::SEPARATOR).first
3234
+ SELF_CONTAINED_PROJECT_DIRECTORIES.include?(top)
3235
+ end
3236
+
2079
3237
  def flutter_target_entrypoint(client_dir, self_contained:)
2080
3238
  candidate = File.join(
2081
3239
  client_dir,
@@ -2102,7 +3260,7 @@ module Ruflet
2102
3260
  return nil if key.empty?
2103
3261
 
2104
3262
  key.tr!("-", "_")
2105
- key.gsub!(/\A(flet_)+/, "")
3263
+ key.gsub!(/\A(flet_)+/, "") # Compatibility for existing application configuration.
2106
3264
  key.gsub!(/\A(ruflet_)+/, "")
2107
3265
  key.gsub!(/\Aservice_/, "")
2108
3266
  key
@@ -2124,15 +3282,49 @@ module Ruflet
2124
3282
  write_pubspec_yaml(path, data)
2125
3283
  end
2126
3284
 
2127
- def sync_client_extension_dependencies(path, selected_packages)
2128
- return if selected_packages.empty?
3285
+ def sync_client_package_directories(client_dir, selected_packages)
3286
+ packages_root = File.join(client_dir, "ruflet_packages")
3287
+
3288
+ kept_packages = (["ruflet"] + selected_packages).uniq
3289
+ template_root =
3290
+ if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
3291
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
3292
+ end
3293
+ template_packages_root = template_root && File.join(template_root, "ruflet_packages")
3294
+
3295
+ # These are disposable client sources, not application files. Refresh
3296
+ # even existing packages so a rebuild cannot keep an older engine fork.
3297
+ kept_packages.each do |package_name|
3298
+ destination = File.join(packages_root, package_name)
3299
+ next unless template_packages_root
2129
3300
 
3301
+ source = File.join(template_packages_root, package_name)
3302
+ next unless Dir.exist?(source)
3303
+ next if File.expand_path(source) == File.expand_path(destination)
3304
+
3305
+ FileUtils.rm_rf(destination) if File.exist?(destination)
3306
+ Ruflet::CLI.send(:copy_client_template_entry, template_packages_root, packages_root, package_name)
3307
+ end
3308
+
3309
+ return unless Dir.exist?(packages_root)
3310
+
3311
+ Dir.children(packages_root).each do |entry|
3312
+ path = File.join(packages_root, entry)
3313
+ next unless Dir.exist?(path)
3314
+ next if kept_packages.include?(entry)
3315
+
3316
+ FileUtils.rm_rf(path)
3317
+ end
3318
+ end
3319
+
3320
+ def sync_client_extension_dependencies(path, selected_packages)
2130
3321
  template_deps = template_client_pubspec_dependencies
2131
3322
  return if template_deps.empty?
2132
3323
 
2133
3324
  data = YAML.safe_load(read_text_file(path), aliases: true) || {}
2134
3325
  deps = (data["dependencies"] || {}).dup
2135
- selected_packages.each do |package_name|
3326
+ deps.delete_if { |name, _| name == "flet" || name.start_with?("flet_") }
3327
+ (["ruflet"] + selected_packages).each do |package_name|
2136
3328
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
2137
3329
  end
2138
3330
 
@@ -2140,6 +3332,64 @@ module Ruflet
2140
3332
  write_pubspec_yaml(path, data)
2141
3333
  end
2142
3334
 
3335
+ # Check Pub's actual result, including transitive local forks. Merely
3336
+ # declaring a path dependency is insufficient: an override can replace it.
3337
+ def validate_local_ruflet_packages(client_dir)
3338
+ config_path = File.join(client_dir, ".dart_tool", "package_config.json")
3339
+ resolved = JSON.parse(File.read(config_path)).fetch("packages")
3340
+ legacy = resolved.map { |entry| entry.fetch("name") }.select { |name| name == "flet" || name.start_with?("flet_") }
3341
+ raise "legacy Flet packages resolved: #{legacy.join(', ')}" unless legacy.empty?
3342
+
3343
+ expected = (["ruflet"] + Array(@ruflet_selected_flutter_extension_packages)).to_h do |name|
3344
+ [name, File.join(client_dir, "ruflet_packages", name)]
3345
+ end
3346
+ # The core/extension manifests also select local decoupled vendor forks.
3347
+ queue = expected.values.dup
3348
+ until queue.empty?
3349
+ root = queue.shift
3350
+ manifest = YAML.safe_load(File.read(File.join(root, "pubspec.yaml")), aliases: true)
3351
+ (manifest["dependencies"] || {}).each do |name, dependency|
3352
+ next unless dependency.is_a?(Hash) && dependency["path"]
3353
+ next if expected.key?(name)
3354
+
3355
+ local = File.expand_path(dependency.fetch("path"), root)
3356
+ expected[name] = local
3357
+ queue << local
3358
+ end
3359
+ end
3360
+ base = file_uri_for(File.expand_path(config_path))
3361
+ expected.each do |name, root|
3362
+ entry = resolved.find { |package| package["name"] == name }
3363
+ raise "missing local package #{name}" unless entry
3364
+
3365
+ uri = URI.join(base, entry.fetch("rootUri"))
3366
+ actual = path_from_file_uri(uri)
3367
+ unless uri.scheme == "file" && File.realpath(actual) == File.realpath(root)
3368
+ raise "#{name} must resolve to the bundled Ruflet source at #{root}, got #{uri}"
3369
+ end
3370
+ end
3371
+ build_note("Verified #{expected.length} local Ruflet engine, extension and vendor packages")
3372
+ true
3373
+ rescue StandardError => e
3374
+ warn "build config error: local Ruflet package verification failed: #{e.message}"
3375
+ false
3376
+ end
3377
+
3378
+ # A POSIX path already starts with "/", so "file://" + path gives the
3379
+ # three slashes a file URI needs. A Windows path starts with the drive
3380
+ # ("D:/..."), and without the extra slash "D:" is parsed as the URI's
3381
+ # host -- which is why this read every bundled package as missing there.
3382
+ def file_uri_for(path)
3383
+ escaped = URI::DEFAULT_PARSER.escape(path.tr("\\", "/"))
3384
+ escaped.start_with?("/") ? "file://#{escaped}" : "file:///#{escaped}"
3385
+ end
3386
+
3387
+ # And back: URI#path keeps that leading slash, so a Windows path returns
3388
+ # as "/D:/..." which no file API accepts.
3389
+ def path_from_file_uri(uri)
3390
+ URI::DEFAULT_PARSER.unescape(uri.path).sub(%r{\A/(?=[A-Za-z]:)}, "")
3391
+ end
3392
+
2143
3393
  def template_client_pubspec_dependencies
2144
3394
  template_root =
2145
3395
  if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
@@ -2207,7 +3457,7 @@ module Ruflet
2207
3457
  if marker_index
2208
3458
  lines.insert(marker_index, extension_line)
2209
3459
  else
2210
- list_index = lines.index { |line| line.include?("final extensions = <FletExtension>[") }
3460
+ list_index = lines.index { |line| line.include?("final extensions = <RufletExtension>[") }
2211
3461
  lines.insert(list_index ? list_index + 1 : lines.length, extension_line)
2212
3462
  end
2213
3463
  lines.join