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.
- checksums.yaml +4 -4
- data/README.md +20 -1
- data/lib/ruflet/cli/build_command.rb +1337 -87
- data/lib/ruflet/cli/new_command.rb +65 -17
- data/lib/ruflet/cli/run_command.rb +65 -33
- data/lib/ruflet/cli.rb +6 -1
- data/lib/ruflet/hot_reload.rb +23 -10
- data/lib/ruflet/version.rb +1 -1
- metadata +1 -1
|
@@ -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
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
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: "
|
|
31
|
-
"secure_storage" => { package: "
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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" =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
#
|
|
130
|
-
#
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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(
|
|
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 #{
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2128
|
-
|
|
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
|
-
|
|
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 = <
|
|
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
|