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