ruflet 0.0.20 → 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 +1689 -86
- data/lib/ruflet/cli/new_command.rb +89 -19
- data/lib/ruflet/cli/run_command.rb +300 -49
- data/lib/ruflet/cli/update_command.rb +6 -6
- 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|ios|
|
|
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
|
|
|
@@ -83,11 +173,19 @@ module Ruflet
|
|
|
83
173
|
return 1
|
|
84
174
|
end
|
|
85
175
|
|
|
176
|
+
# `ipa` produces the uploadable archive; every other step — pods,
|
|
177
|
+
# signing, icons, package name — is the same as a plain iOS build.
|
|
178
|
+
requested_platform = platform
|
|
179
|
+
platform = "ios" if platform == "ipa"
|
|
180
|
+
@ruflet_build_platform = platform
|
|
181
|
+
@ruflet_experimental_native_renderer = !!experimental
|
|
182
|
+
|
|
86
183
|
# The embedded Ruby VM is a native plugin with no browser
|
|
87
184
|
# implementation, so a self-contained web build produces an app that
|
|
88
185
|
# cannot start. Say so rather than shipping one that hangs.
|
|
89
186
|
if self_contained && platform == "web"
|
|
90
|
-
|
|
187
|
+
profile_flag = @ruflet_runtime_profile_explicit ? "--#{runtime_profile}" : "--self"
|
|
188
|
+
warn "build config error: #{profile_flag} is not supported for web"
|
|
91
189
|
warn "A web client runs no embedded Ruby; build it with `ruflet build web`."
|
|
92
190
|
return 1
|
|
93
191
|
end
|
|
@@ -100,8 +198,13 @@ module Ruflet
|
|
|
100
198
|
return 1
|
|
101
199
|
end
|
|
102
200
|
|
|
103
|
-
|
|
201
|
+
renderer = experimental ? ", experimental native renderer" : ""
|
|
202
|
+
mode = self_contained ? "self-contained #{runtime_profile}" : "server-driven"
|
|
203
|
+
build_note("Preparing #{platform} build (#{mode}#{renderer})")
|
|
104
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
|
|
105
208
|
tools = ensure_flutter!("build", client_dir: client_dir)
|
|
106
209
|
command_env = build_tool_env(tools[:env], platform, client_dir)
|
|
107
210
|
ok = prepare_flutter_client(
|
|
@@ -115,16 +218,18 @@ module Ruflet
|
|
|
115
218
|
return 1 unless ok
|
|
116
219
|
|
|
117
220
|
build_args = [*flutter_cmd, *args]
|
|
118
|
-
build_args << "--codesign" if ios_device_build_needs_codesign_flag?(
|
|
221
|
+
build_args << "--codesign" if ios_device_build_needs_codesign_flag?(requested_platform, build_args)
|
|
119
222
|
target_entrypoint = flutter_target_entrypoint(client_dir, self_contained: !!self_contained)
|
|
120
223
|
build_args += ["--target", target_entrypoint] if target_entrypoint
|
|
121
224
|
backend_url = configured_backend_url(config)
|
|
122
225
|
if self_contained
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
# 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.
|
|
127
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
|
|
128
233
|
elsif backend_url
|
|
129
234
|
build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
|
|
130
235
|
elsif RUNTIME_RESOLVED_BACKEND_PLATFORMS.include?(platform)
|
|
@@ -138,10 +243,18 @@ module Ruflet
|
|
|
138
243
|
warn "Set app.backend_url or backend_url in ruflet.yaml"
|
|
139
244
|
return 1
|
|
140
245
|
end
|
|
246
|
+
if experimental
|
|
247
|
+
build_args += ["--dart-define", "RUFLET_EXPERIMENTAL_NATIVE_RENDERER=true"]
|
|
248
|
+
end
|
|
141
249
|
build_args << "-v" if verbose
|
|
142
250
|
stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
|
|
143
251
|
|
|
144
|
-
|
|
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}")
|
|
145
258
|
build_log(verbose, "client_dir=#{client_dir}")
|
|
146
259
|
build_log(verbose, "flutter=#{tools[:flutter]}")
|
|
147
260
|
build_log(verbose, "dart=#{tools[:dart]}")
|
|
@@ -150,6 +263,26 @@ module Ruflet
|
|
|
150
263
|
|
|
151
264
|
build_note("Running Flutter #{build_args.join(' ')}")
|
|
152
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
|
+
|
|
153
286
|
export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok
|
|
154
287
|
ok ? 0 : 1
|
|
155
288
|
end
|
|
@@ -193,6 +326,10 @@ module Ruflet
|
|
|
193
326
|
|
|
194
327
|
private
|
|
195
328
|
|
|
329
|
+
def experimental_native_renderer?
|
|
330
|
+
@ruflet_experimental_native_renderer == true
|
|
331
|
+
end
|
|
332
|
+
|
|
196
333
|
def extract_option_value!(args, *flags)
|
|
197
334
|
flags.each do |flag|
|
|
198
335
|
index = args.index(flag)
|
|
@@ -208,6 +345,15 @@ module Ruflet
|
|
|
208
345
|
def ensure_flutter_client_dir(verbose: false)
|
|
209
346
|
client_dir = detect_flutter_client_dir
|
|
210
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
|
|
211
357
|
refresh_hidden_flutter_client_template(client_dir, verbose: verbose)
|
|
212
358
|
return client_dir
|
|
213
359
|
end
|
|
@@ -217,6 +363,11 @@ module Ruflet
|
|
|
217
363
|
bootstrapped
|
|
218
364
|
end
|
|
219
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
|
+
|
|
220
371
|
def refresh_hidden_flutter_client_template(client_dir, verbose: false)
|
|
221
372
|
return unless File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir)
|
|
222
373
|
return unless File.file?(File.join(client_dir, ".metadata"))
|
|
@@ -432,15 +583,42 @@ module Ruflet
|
|
|
432
583
|
end
|
|
433
584
|
|
|
434
585
|
def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
|
|
435
|
-
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
|
|
436
598
|
metadata = sync_client_metadata(client_dir, config, verbose: verbose)
|
|
437
599
|
return false unless validate_mobile_app_identity(metadata, platform: platform)
|
|
438
600
|
|
|
439
601
|
apply_native_service_permissions(client_dir, config)
|
|
602
|
+
apply_android_signing_config(client_dir, platform, verbose: !!verbose)
|
|
603
|
+
apply_ios_signing_team(client_dir, config) if %w[ios ipa macos].include?(platform.to_s)
|
|
440
604
|
configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
|
|
441
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)
|
|
442
612
|
@ruflet_self_contained_build = self_contained
|
|
443
|
-
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
|
|
444
622
|
asset_flags = apply_build_config(client_dir, config)
|
|
445
623
|
if asset_flags[:error]
|
|
446
624
|
warn asset_flags[:error]
|
|
@@ -458,6 +636,9 @@ module Ruflet
|
|
|
458
636
|
warn "flutter pub get failed"
|
|
459
637
|
return false
|
|
460
638
|
end
|
|
639
|
+
if @ruflet_extension_selection_applied && !validate_local_ruflet_packages(client_dir)
|
|
640
|
+
return false
|
|
641
|
+
end
|
|
461
642
|
|
|
462
643
|
unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
|
|
463
644
|
return false
|
|
@@ -594,7 +775,11 @@ module Ruflet
|
|
|
594
775
|
return true unless build_args.include?("ios")
|
|
595
776
|
return true unless build_args.include?("--simulator")
|
|
596
777
|
|
|
597
|
-
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
|
|
598
783
|
return true unless runtime_root
|
|
599
784
|
|
|
600
785
|
source = File.join(
|
|
@@ -740,8 +925,11 @@ module Ruflet
|
|
|
740
925
|
if File.file?(services_path)
|
|
741
926
|
service_config = YAML.safe_load(read_text_file(services_path), aliases: true) || {}
|
|
742
927
|
if service_config["app"].is_a?(Hash)
|
|
743
|
-
|
|
744
|
-
|
|
928
|
+
# ruflet.yaml declares the app; services.yaml may still carry an
|
|
929
|
+
# identity from older projects, so it fills gaps rather than
|
|
930
|
+
# overriding what the project states.
|
|
931
|
+
declared = config["app"].is_a?(Hash) ? config["app"] : {}
|
|
932
|
+
config["app"] = service_config["app"].merge(declared)
|
|
745
933
|
end
|
|
746
934
|
config["services"] = service_config["services"] if service_config.key?("services")
|
|
747
935
|
end
|
|
@@ -1200,10 +1388,10 @@ module Ruflet
|
|
|
1200
1388
|
first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
|
|
1201
1389
|
)
|
|
1202
1390
|
identity_errors = []
|
|
1203
|
-
|
|
1391
|
+
named = first_present(app["name"], app["app_name"], app["display_name"])
|
|
1392
|
+
identity_errors << "app.name" if named.nil?
|
|
1204
1393
|
identity_errors << "app.package_name" if app["package_name"].to_s.strip.empty?
|
|
1205
|
-
identity_errors << "app.organization" if app["organization"].
|
|
1206
|
-
identity_errors << "services.yaml app section" unless config["_app_identity_source"] == "services.yaml"
|
|
1394
|
+
identity_errors << "app.organization" if first_present(app["organization"], app["org"]).nil?
|
|
1207
1395
|
|
|
1208
1396
|
{
|
|
1209
1397
|
package_name: package_name,
|
|
@@ -1216,10 +1404,10 @@ module Ruflet
|
|
|
1216
1404
|
android_application_id: normalize_bundle_identifier(
|
|
1217
1405
|
first_present(app["android_application_id"], config["android_application_id"], bundle_identifier)
|
|
1218
1406
|
),
|
|
1219
|
-
ios_bundle_identifier:
|
|
1407
|
+
ios_bundle_identifier: normalize_apple_bundle_identifier(
|
|
1220
1408
|
first_present(app["ios_bundle_identifier"], config["ios_bundle_identifier"], bundle_identifier)
|
|
1221
1409
|
),
|
|
1222
|
-
macos_bundle_identifier:
|
|
1410
|
+
macos_bundle_identifier: normalize_apple_bundle_identifier(
|
|
1223
1411
|
first_present(app["macos_bundle_identifier"], config["macos_bundle_identifier"], bundle_identifier)
|
|
1224
1412
|
),
|
|
1225
1413
|
linux_application_id: normalize_bundle_identifier(
|
|
@@ -1237,8 +1425,8 @@ module Ruflet
|
|
|
1237
1425
|
errors = Array(metadata[:mobile_identity_errors])
|
|
1238
1426
|
return true if errors.empty?
|
|
1239
1427
|
|
|
1240
|
-
warn "build config error:
|
|
1241
|
-
warn "
|
|
1428
|
+
warn "build config error: ruflet.yaml must define #{errors.join(', ')}"
|
|
1429
|
+
warn "A mobile build needs app.name, app.package_name and app.organization."
|
|
1242
1430
|
false
|
|
1243
1431
|
end
|
|
1244
1432
|
|
|
@@ -1271,6 +1459,33 @@ module Ruflet
|
|
|
1271
1459
|
)
|
|
1272
1460
|
end
|
|
1273
1461
|
|
|
1462
|
+
# The signing team belongs to whoever ships the app, so it comes from the
|
|
1463
|
+
# project rather than being baked into the client.
|
|
1464
|
+
def apply_ios_signing_team(client_dir, config)
|
|
1465
|
+
team = ios_signing_team(config)
|
|
1466
|
+
pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
|
|
1467
|
+
return unless File.file?(pbxproj_path)
|
|
1468
|
+
|
|
1469
|
+
if team.to_s.strip.empty?
|
|
1470
|
+
build_note("No ios.team_id configured; Xcode will pick the signing team")
|
|
1471
|
+
replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = \"\";")
|
|
1472
|
+
return
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = #{team};")
|
|
1476
|
+
build_note("iOS signing team set to #{team}")
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
def ios_signing_team(config)
|
|
1480
|
+
app = config["app"].is_a?(Hash) ? config["app"] : {}
|
|
1481
|
+
first_present(
|
|
1482
|
+
platform_build_config(config, "ios")["team_id"],
|
|
1483
|
+
app["ios_team_id"],
|
|
1484
|
+
app["team_id"],
|
|
1485
|
+
ENV["RUFLET_IOS_TEAM_ID"]
|
|
1486
|
+
)
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1274
1489
|
def apply_ios_metadata(client_dir, metadata)
|
|
1275
1490
|
info_plist_path = File.join(client_dir, "ios", "Runner", "Info.plist")
|
|
1276
1491
|
replace_plist_value(info_plist_path, "CFBundleDisplayName", metadata[:display_name])
|
|
@@ -1471,6 +1686,20 @@ module Ruflet
|
|
|
1471
1686
|
segments.join(".")
|
|
1472
1687
|
end
|
|
1473
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
|
+
|
|
1474
1703
|
def humanize_name(name)
|
|
1475
1704
|
name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
|
|
1476
1705
|
end
|
|
@@ -1501,22 +1730,218 @@ module Ruflet
|
|
|
1501
1730
|
end
|
|
1502
1731
|
|
|
1503
1732
|
def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
|
1510
1748
|
|
|
1511
1749
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
1512
1750
|
if File.file?(pubspec_path)
|
|
1513
1751
|
sync_client_extension_dependencies(pubspec_path, extension_packages)
|
|
1514
1752
|
prune_client_pubspec(pubspec_path, extension_packages)
|
|
1753
|
+
sync_external_extension_dependencies(
|
|
1754
|
+
pubspec_path,
|
|
1755
|
+
external,
|
|
1756
|
+
managed_names: removable_external_names
|
|
1757
|
+
)
|
|
1515
1758
|
end
|
|
1759
|
+
sync_client_package_directories(client_dir, extension_packages)
|
|
1516
1760
|
client_entrypoint_paths(client_dir).each do |entrypoint|
|
|
1517
|
-
|
|
1518
|
-
|
|
1761
|
+
next unless File.file?(entrypoint)
|
|
1762
|
+
|
|
1763
|
+
sync_client_main_extensions(entrypoint, extension_aliases)
|
|
1764
|
+
prune_client_main(entrypoint, extension_aliases)
|
|
1765
|
+
prune_external_extension_registrations(
|
|
1766
|
+
entrypoint,
|
|
1767
|
+
removable_external_names - external.map { |entry| entry[:name] }
|
|
1768
|
+
)
|
|
1769
|
+
sync_external_extension_registrations(entrypoint, external)
|
|
1519
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
|
|
1780
|
+
end
|
|
1781
|
+
|
|
1782
|
+
# An extension may name a package the template does not bundle, declared
|
|
1783
|
+
# with the source to fetch it from:
|
|
1784
|
+
#
|
|
1785
|
+
# extensions:
|
|
1786
|
+
# - charts
|
|
1787
|
+
# - my_package:
|
|
1788
|
+
# git:
|
|
1789
|
+
# url: https://github.com/owner/my_package
|
|
1790
|
+
# ref: main
|
|
1791
|
+
#
|
|
1792
|
+
# `branch` is accepted for `ref`, and `path` for a local checkout.
|
|
1793
|
+
def external_extension_entries(config)
|
|
1794
|
+
Array(config["extensions"]).filter_map do |entry|
|
|
1795
|
+
next unless entry.is_a?(Hash)
|
|
1796
|
+
next unless entry.size == 1
|
|
1797
|
+
|
|
1798
|
+
name, source = entry.first
|
|
1799
|
+
package = name.to_s.strip
|
|
1800
|
+
next if package.empty?
|
|
1801
|
+
|
|
1802
|
+
dependency = external_extension_dependency(source)
|
|
1803
|
+
next unless dependency
|
|
1804
|
+
|
|
1805
|
+
{ name: package, dependency: dependency }
|
|
1806
|
+
end
|
|
1807
|
+
end
|
|
1808
|
+
|
|
1809
|
+
def external_extension_dependency(source)
|
|
1810
|
+
return nil unless source.is_a?(Hash)
|
|
1811
|
+
|
|
1812
|
+
normalized = source.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
1813
|
+
git = normalized["git"] || normalized["github"] || normalized["repository"]
|
|
1814
|
+
git = normalized if git.nil? && normalized.key?("url")
|
|
1815
|
+
|
|
1816
|
+
if git.is_a?(String)
|
|
1817
|
+
return { "git" => git }
|
|
1818
|
+
elsif git.is_a?(Hash)
|
|
1819
|
+
git = git.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
1820
|
+
url = git["url"].to_s.strip
|
|
1821
|
+
return nil if url.empty?
|
|
1822
|
+
|
|
1823
|
+
spec = { "url" => url }
|
|
1824
|
+
ref = (git["ref"] || git["branch"] || git["tag"]).to_s.strip
|
|
1825
|
+
spec["ref"] = ref unless ref.empty?
|
|
1826
|
+
path = git["path"].to_s.strip
|
|
1827
|
+
spec["path"] = path unless path.empty?
|
|
1828
|
+
return { "git" => spec }
|
|
1829
|
+
end
|
|
1830
|
+
|
|
1831
|
+
local = normalized["path"]
|
|
1832
|
+
return { "path" => local.to_s } if local.is_a?(String) && !local.to_s.strip.empty?
|
|
1833
|
+
|
|
1834
|
+
nil
|
|
1835
|
+
end
|
|
1836
|
+
|
|
1837
|
+
def sync_external_extension_dependencies(pubspec_path, entries, managed_names: [])
|
|
1838
|
+
data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
|
|
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
|
|
1844
|
+
entries.each { |entry| dependencies[entry[:name]] = entry[:dependency] }
|
|
1845
|
+
data["dependencies"] = dependencies
|
|
1846
|
+
write_pubspec_yaml(pubspec_path, data)
|
|
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)
|
|
1852
|
+
end
|
|
1853
|
+
|
|
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
|
|
1928
|
+
# after the package, so the import and registration can be derived.
|
|
1929
|
+
def sync_external_extension_registrations(path, entries)
|
|
1930
|
+
return if entries.empty?
|
|
1931
|
+
|
|
1932
|
+
content = read_text_file(path)
|
|
1933
|
+
original = content.dup
|
|
1934
|
+
|
|
1935
|
+
entries.each do |entry|
|
|
1936
|
+
name = entry[:name]
|
|
1937
|
+
import_line = %(import 'package:#{name}/#{name}.dart' as #{name};\n)
|
|
1938
|
+
extension_line = " #{name}.Extension(),\n"
|
|
1939
|
+
|
|
1940
|
+
content = insert_missing_import(content, import_line) unless content.include?("package:#{name}/#{name}.dart")
|
|
1941
|
+
content = insert_missing_extension(content, extension_line) unless content.match?(/^\s*#{Regexp.escape(name)}\.Extension\(\),/)
|
|
1942
|
+
end
|
|
1943
|
+
|
|
1944
|
+
write_text_file(path, content) unless content == original
|
|
1520
1945
|
end
|
|
1521
1946
|
|
|
1522
1947
|
def configured_service_entries(config)
|
|
@@ -1536,19 +1961,106 @@ module Ruflet
|
|
|
1536
1961
|
end
|
|
1537
1962
|
end
|
|
1538
1963
|
|
|
1539
|
-
def
|
|
1964
|
+
def configured_service_entries_with_requirements(config)
|
|
1540
1965
|
entries = configured_service_entries(config)
|
|
1541
|
-
configured_extensions = Array(config["extensions"]).filter_map
|
|
1542
|
-
|
|
1543
|
-
EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
|
|
1966
|
+
configured_extensions = Array(config["extensions"]).filter_map do |entry|
|
|
1967
|
+
normalize_extension_key(entry) unless entry.is_a?(Hash)
|
|
1544
1968
|
end
|
|
1545
|
-
|
|
1969
|
+
configured_extensions.flat_map do |extension|
|
|
1970
|
+
EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
|
|
1971
|
+
end.each do |service|
|
|
1546
1972
|
entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
|
|
1547
1973
|
end
|
|
1548
|
-
|
|
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
|
+
|
|
1986
|
+
ANDROID_SIGNING_KEYS = {
|
|
1987
|
+
"storeFile" => "RUFLET_ANDROID_KEYSTORE",
|
|
1988
|
+
"storePassword" => "RUFLET_ANDROID_KEYSTORE_PASSWORD",
|
|
1989
|
+
"keyAlias" => "RUFLET_ANDROID_KEY_ALIAS",
|
|
1990
|
+
"keyPassword" => "RUFLET_ANDROID_KEY_PASSWORD"
|
|
1991
|
+
}.freeze
|
|
1992
|
+
|
|
1993
|
+
# Release signing is read from the project, never from the managed client,
|
|
1994
|
+
# so nobody has to edit build/client by hand. Either keep an
|
|
1995
|
+
# android/key.properties beside the app, or set the RUFLET_ANDROID_*
|
|
1996
|
+
# variables. Without one of those, Gradle falls back to the debug key.
|
|
1997
|
+
def apply_android_signing_config(client_dir, platform, verbose: false)
|
|
1998
|
+
return unless %w[apk android aab appbundle].include?(platform.to_s)
|
|
1999
|
+
|
|
2000
|
+
android_dir = File.join(client_dir, "android")
|
|
2001
|
+
return unless Dir.exist?(android_dir)
|
|
2002
|
+
|
|
2003
|
+
destination = File.join(android_dir, "key.properties")
|
|
2004
|
+
properties = android_signing_properties
|
|
2005
|
+
if properties.empty?
|
|
2006
|
+
File.delete(destination) if File.file?(destination)
|
|
2007
|
+
build_note("No Android signing configured; the release build will use the debug key")
|
|
2008
|
+
return
|
|
2009
|
+
end
|
|
2010
|
+
|
|
2011
|
+
missing = ANDROID_SIGNING_KEYS.keys - properties.keys
|
|
2012
|
+
unless missing.empty?
|
|
2013
|
+
warn "build config error: Android signing is missing #{missing.join(', ')}"
|
|
2014
|
+
warn "Set them in android/key.properties or the matching RUFLET_ANDROID_* variables."
|
|
2015
|
+
return
|
|
2016
|
+
end
|
|
2017
|
+
|
|
2018
|
+
body = ANDROID_SIGNING_KEYS.keys.map { |key| "#{key}=#{properties.fetch(key)}" }.join("\n")
|
|
2019
|
+
write_text_file(destination, "#{body}\n")
|
|
2020
|
+
build_log(verbose, "wrote #{destination}")
|
|
2021
|
+
build_note("Android release signing configured from #{properties.fetch("_source")}")
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
def android_signing_properties
|
|
2025
|
+
from_env = ANDROID_SIGNING_KEYS.each_with_object({}) do |(key, variable), out|
|
|
2026
|
+
value = ENV[variable].to_s.strip
|
|
2027
|
+
out[key] = value unless value.empty?
|
|
2028
|
+
end
|
|
2029
|
+
unless from_env.empty?
|
|
2030
|
+
from_env["storeFile"] = File.expand_path(from_env["storeFile"]) if from_env["storeFile"]
|
|
2031
|
+
return from_env.merge("_source" => "the RUFLET_ANDROID_* environment")
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
source = project_android_key_properties_path
|
|
2035
|
+
return {} unless source
|
|
2036
|
+
|
|
2037
|
+
parsed = read_text_file(source).each_line.with_object({}) do |line, out|
|
|
2038
|
+
next if line.strip.empty? || line.strip.start_with?("#")
|
|
2039
|
+
|
|
2040
|
+
key, value = line.split("=", 2)
|
|
2041
|
+
out[key.to_s.strip] = value.to_s.strip unless value.nil?
|
|
2042
|
+
end
|
|
2043
|
+
return {} if parsed.empty?
|
|
2044
|
+
|
|
2045
|
+
# storeFile is written relative to the project; the client sits deeper.
|
|
2046
|
+
if parsed["storeFile"] && !Pathname.new(parsed["storeFile"]).absolute?
|
|
2047
|
+
parsed["storeFile"] = File.expand_path(parsed["storeFile"], File.dirname(source))
|
|
2048
|
+
end
|
|
2049
|
+
parsed.merge("_source" => source)
|
|
2050
|
+
end
|
|
2051
|
+
|
|
2052
|
+
def project_android_key_properties_path
|
|
2053
|
+
[
|
|
2054
|
+
File.join(Dir.pwd, "android", "key.properties"),
|
|
2055
|
+
File.join(Dir.pwd, "key.properties")
|
|
2056
|
+
].find { |path| File.file?(path) }
|
|
2057
|
+
end
|
|
1549
2058
|
|
|
2059
|
+
def apply_native_service_permissions(client_dir, config)
|
|
2060
|
+
entries = configured_service_entries_with_requirements(config)
|
|
1550
2061
|
apply_android_service_permissions(client_dir, entries)
|
|
1551
2062
|
apply_ios_service_usage_descriptions(client_dir, entries)
|
|
2063
|
+
apply_macos_service_permissions(client_dir, entries)
|
|
1552
2064
|
end
|
|
1553
2065
|
|
|
1554
2066
|
def apply_android_service_permissions(client_dir, entries)
|
|
@@ -1556,6 +2068,12 @@ module Ruflet
|
|
|
1556
2068
|
return unless File.file?(path)
|
|
1557
2069
|
|
|
1558
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
|
|
1559
2077
|
entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
|
|
1560
2078
|
next if content.include?(%(android:name="#{permission}"))
|
|
1561
2079
|
|
|
@@ -1566,22 +2084,56 @@ module Ruflet
|
|
|
1566
2084
|
|
|
1567
2085
|
def apply_ios_service_usage_descriptions(client_dir, entries)
|
|
1568
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)
|
|
1569
2116
|
return unless File.file?(path)
|
|
1570
2117
|
|
|
1571
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
|
|
1572
2125
|
entries.each do |entry|
|
|
1573
|
-
key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
|
|
1574
|
-
next unless key
|
|
1575
|
-
|
|
1576
2126
|
description = entry[:description]
|
|
1577
2127
|
description = "This app uses #{entry[:name]} access for its Ruflet features." if description.empty?
|
|
1578
2128
|
escaped_description = xml_escape(description)
|
|
1579
|
-
|
|
2129
|
+
Array(usage_keys[entry[:name]]).each do |key|
|
|
2130
|
+
pair = "\t<key>#{key}</key>\n\t<string>#{escaped_description}</string>\n"
|
|
1580
2131
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
|
1585
2137
|
end
|
|
1586
2138
|
end
|
|
1587
2139
|
write_text_file(path, content)
|
|
@@ -1596,12 +2148,25 @@ module Ruflet
|
|
|
1596
2148
|
end
|
|
1597
2149
|
|
|
1598
2150
|
def clear_stale_platform_outputs(client_dir, platform, verbose: false)
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
|
1605
2170
|
|
|
1606
2171
|
stale_paths.each do |relative_path|
|
|
1607
2172
|
path = File.join(client_dir, relative_path)
|
|
@@ -1610,6 +2175,23 @@ module Ruflet
|
|
|
1610
2175
|
FileUtils.rm_rf(path)
|
|
1611
2176
|
build_log(verbose, "cleared stale #{relative_path}")
|
|
1612
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
|
|
1613
2195
|
end
|
|
1614
2196
|
|
|
1615
2197
|
def client_entrypoint_paths(client_dir)
|
|
@@ -1620,14 +2202,14 @@ module Ruflet
|
|
|
1620
2202
|
|
|
1621
2203
|
def configure_client_runtime_mode(client_dir, self_contained:, verbose: false)
|
|
1622
2204
|
build_log(verbose, "configuring #{self_contained ? 'self-contained' : 'server-driven'} runtime")
|
|
1623
|
-
sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
|
|
1624
2205
|
if self_contained
|
|
1625
|
-
sync_self_contained_project_assets(client_dir, verbose: verbose)
|
|
2206
|
+
return false unless sync_self_contained_project_assets(client_dir, verbose: verbose)
|
|
1626
2207
|
remove_local_ruby_runtime_override(client_dir, verbose: verbose)
|
|
1627
2208
|
else
|
|
1628
2209
|
remove_self_contained_project_assets(client_dir, verbose: verbose)
|
|
1629
2210
|
remove_local_ruby_runtime_override(client_dir, verbose: verbose)
|
|
1630
2211
|
end
|
|
2212
|
+
sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
|
|
1631
2213
|
true
|
|
1632
2214
|
end
|
|
1633
2215
|
|
|
@@ -1638,24 +2220,25 @@ module Ruflet
|
|
|
1638
2220
|
data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
|
|
1639
2221
|
dependencies = data["dependencies"]
|
|
1640
2222
|
dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
|
|
1641
|
-
spinkit_dependency = template_client_pubspec_dependencies["flet_spinkit"]
|
|
1642
|
-
dependencies["flet_spinkit"] = spinkit_dependency if spinkit_dependency
|
|
1643
2223
|
flutter = data["flutter"]
|
|
1644
2224
|
flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
|
|
1645
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) }
|
|
1646
2231
|
|
|
1647
2232
|
if self_contained
|
|
1648
2233
|
dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
|
|
1649
2234
|
# Flutter does not recurse into asset directories, so every subdirectory
|
|
1650
2235
|
# of the embedded project (e.g. standalone_apps/<slug>/) must be listed
|
|
1651
2236
|
# explicitly or its files never reach the bundle/manifest on device.
|
|
1652
|
-
self_contained_project_asset_dirs.each do |dir_entry|
|
|
2237
|
+
self_contained_project_asset_dirs(client_dir).each do |dir_entry|
|
|
1653
2238
|
assets << dir_entry unless assets.include?(dir_entry)
|
|
1654
2239
|
end
|
|
1655
2240
|
else
|
|
1656
2241
|
dependencies.delete("ruby_runtime")
|
|
1657
|
-
project_prefix = "assets/#{self_contained_project_name}/"
|
|
1658
|
-
assets.reject! { |a| a.to_s == project_prefix || a.to_s.start_with?(project_prefix) }
|
|
1659
2242
|
end
|
|
1660
2243
|
|
|
1661
2244
|
flutter["assets"] = assets unless assets.empty?
|
|
@@ -1669,12 +2252,69 @@ module Ruflet
|
|
|
1669
2252
|
PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.9"
|
|
1670
2253
|
|
|
1671
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
|
+
|
|
1672
2259
|
local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
|
|
1673
2260
|
return { "path" => local_path } if local_path
|
|
1674
2261
|
|
|
1675
2262
|
current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
|
|
1676
2263
|
end
|
|
1677
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
|
+
|
|
1678
2318
|
def explicit_local_ruby_runtime_path
|
|
1679
2319
|
env_path = ENV["RUFLET_RUBY_RUNTIME_PATH"].to_s.strip
|
|
1680
2320
|
return nil if env_path.empty?
|
|
@@ -1692,25 +2332,59 @@ module Ruflet
|
|
|
1692
2332
|
nil
|
|
1693
2333
|
end
|
|
1694
2334
|
|
|
1695
|
-
def refresh_managed_client_template_files(client_dir, verbose: false)
|
|
2335
|
+
def refresh_managed_client_template_files(client_dir, platform: nil, verbose: false)
|
|
1696
2336
|
template_root =
|
|
1697
2337
|
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1698
2338
|
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1699
2339
|
end
|
|
1700
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
|
|
1701
2348
|
|
|
1702
2349
|
managed_files = [
|
|
1703
2350
|
"lib/main.dart",
|
|
1704
2351
|
"lib/main.self.dart",
|
|
1705
2352
|
"lib/main.server.dart",
|
|
1706
|
-
"lib/
|
|
2353
|
+
"lib/native_renderer.dart",
|
|
1707
2354
|
"lib/connection_probe.dart",
|
|
1708
2355
|
"lib/connection_probe_io.dart",
|
|
1709
2356
|
"lib/connection_probe_stub.dart",
|
|
1710
2357
|
"ios/Podfile",
|
|
1711
|
-
|
|
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",
|
|
2363
|
+
"windows/CMakeLists.txt",
|
|
2364
|
+
# Release signing lives here; an existing client would otherwise keep
|
|
2365
|
+
# signing release builds with the debug key.
|
|
2366
|
+
"android/app/build.gradle.kts"
|
|
1712
2367
|
]
|
|
1713
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
|
+
|
|
1714
2388
|
managed_files.each do |relative_path|
|
|
1715
2389
|
source = File.join(template_root, relative_path)
|
|
1716
2390
|
next unless File.file?(source)
|
|
@@ -1720,6 +2394,494 @@ module Ruflet
|
|
|
1720
2394
|
FileUtils.cp(source, destination)
|
|
1721
2395
|
build_log(verbose, "refreshed template file #{relative_path}")
|
|
1722
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)
|
|
1723
2885
|
end
|
|
1724
2886
|
|
|
1725
2887
|
def write_pubspec_yaml(path, data)
|
|
@@ -1747,11 +2909,24 @@ module Ruflet
|
|
|
1747
2909
|
# Flutter asset directory entries for every folder of the embedded project
|
|
1748
2910
|
# that contains packaged files. Derived from the exact copy list so the
|
|
1749
2911
|
# pubspec asset dirs match what sync_self_contained_project_assets writes.
|
|
1750
|
-
def self_contained_project_asset_dirs
|
|
2912
|
+
def self_contained_project_asset_dirs(client_dir = nil)
|
|
1751
2913
|
prefix = "assets/#{self_contained_project_name}"
|
|
1752
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
|
|
1753
2926
|
project_dirs = dirs.map { |dir| dir == "." ? "#{prefix}/" : "#{prefix}/#{dir}/" }
|
|
1754
|
-
|
|
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
|
|
1755
2930
|
end
|
|
1756
2931
|
|
|
1757
2932
|
def sync_self_contained_project_assets(client_dir, verbose: false)
|
|
@@ -1772,7 +2947,329 @@ module Ruflet
|
|
|
1772
2947
|
copied += 1
|
|
1773
2948
|
end
|
|
1774
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
|
+
|
|
1775
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
|
+
)
|
|
1776
3273
|
end
|
|
1777
3274
|
|
|
1778
3275
|
def remove_self_contained_project_assets(client_dir, verbose: false)
|
|
@@ -1848,7 +3345,7 @@ module Ruflet
|
|
|
1848
3345
|
# app, so signing keys and local environment files must never be copied
|
|
1849
3346
|
# in even when a project keeps them beside its source.
|
|
1850
3347
|
SECRET_ASSET_EXTENSIONS = %w[.p8 .p12 .pem .key .jks .keystore .mobileprovision].freeze
|
|
1851
|
-
SECRET_ASSET_BASENAMES = %w[.env .netrc].freeze
|
|
3348
|
+
SECRET_ASSET_BASENAMES = %w[.env .netrc key.properties].freeze
|
|
1852
3349
|
|
|
1853
3350
|
def secret_project_asset?(relative)
|
|
1854
3351
|
basename = File.basename(relative)
|
|
@@ -1860,10 +3357,30 @@ module Ruflet
|
|
|
1860
3357
|
false
|
|
1861
3358
|
end
|
|
1862
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
|
+
|
|
1863
3376
|
def include_project_asset_file?(relative)
|
|
1864
3377
|
basename = File.basename(relative)
|
|
1865
3378
|
return false if basename == ".DS_Store"
|
|
1866
|
-
|
|
3379
|
+
|
|
3380
|
+
unless self_contained_project_path?(relative)
|
|
3381
|
+
return false
|
|
3382
|
+
end
|
|
3383
|
+
|
|
1867
3384
|
if secret_project_asset?(relative)
|
|
1868
3385
|
build_note("Excluded #{relative} from the embedded project; it looks like a credential")
|
|
1869
3386
|
return false
|
|
@@ -1871,6 +3388,13 @@ module Ruflet
|
|
|
1871
3388
|
true
|
|
1872
3389
|
end
|
|
1873
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
|
+
|
|
1874
3398
|
def flutter_target_entrypoint(client_dir, self_contained:)
|
|
1875
3399
|
candidate = File.join(
|
|
1876
3400
|
client_dir,
|
|
@@ -1897,7 +3421,7 @@ module Ruflet
|
|
|
1897
3421
|
return nil if key.empty?
|
|
1898
3422
|
|
|
1899
3423
|
key.tr!("-", "_")
|
|
1900
|
-
key.gsub!(/\A(flet_)+/, "")
|
|
3424
|
+
key.gsub!(/\A(flet_)+/, "") # Compatibility for existing application configuration.
|
|
1901
3425
|
key.gsub!(/\A(ruflet_)+/, "")
|
|
1902
3426
|
key.gsub!(/\Aservice_/, "")
|
|
1903
3427
|
key
|
|
@@ -1919,15 +3443,49 @@ module Ruflet
|
|
|
1919
3443
|
write_pubspec_yaml(path, data)
|
|
1920
3444
|
end
|
|
1921
3445
|
|
|
1922
|
-
def
|
|
1923
|
-
|
|
3446
|
+
def sync_client_package_directories(client_dir, selected_packages)
|
|
3447
|
+
packages_root = File.join(client_dir, "ruflet_packages")
|
|
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)
|
|
1924
3476
|
|
|
3477
|
+
FileUtils.rm_rf(path)
|
|
3478
|
+
end
|
|
3479
|
+
end
|
|
3480
|
+
|
|
3481
|
+
def sync_client_extension_dependencies(path, selected_packages)
|
|
1925
3482
|
template_deps = template_client_pubspec_dependencies
|
|
1926
3483
|
return if template_deps.empty?
|
|
1927
3484
|
|
|
1928
3485
|
data = YAML.safe_load(read_text_file(path), aliases: true) || {}
|
|
1929
3486
|
deps = (data["dependencies"] || {}).dup
|
|
1930
|
-
|
|
3487
|
+
deps.delete_if { |name, _| name == "flet" || name.start_with?("flet_") }
|
|
3488
|
+
(["ruflet"] + selected_packages).each do |package_name|
|
|
1931
3489
|
deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
|
|
1932
3490
|
end
|
|
1933
3491
|
|
|
@@ -1935,6 +3493,49 @@ module Ruflet
|
|
|
1935
3493
|
write_pubspec_yaml(path, data)
|
|
1936
3494
|
end
|
|
1937
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
|
+
|
|
1938
3539
|
def template_client_pubspec_dependencies
|
|
1939
3540
|
template_root =
|
|
1940
3541
|
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
@@ -2002,7 +3603,7 @@ module Ruflet
|
|
|
2002
3603
|
if marker_index
|
|
2003
3604
|
lines.insert(marker_index, extension_line)
|
|
2004
3605
|
else
|
|
2005
|
-
list_index = lines.index { |line| line.include?("final extensions = <
|
|
3606
|
+
list_index = lines.index { |line| line.include?("final extensions = <RufletExtension>[") }
|
|
2006
3607
|
lines.insert(list_index ? list_index + 1 : lines.length, extension_line)
|
|
2007
3608
|
end
|
|
2008
3609
|
lines.join
|
|
@@ -2145,6 +3746,8 @@ module Ruflet
|
|
|
2145
3746
|
["build", "appbundle"]
|
|
2146
3747
|
when "ios"
|
|
2147
3748
|
["build", "ios"]
|
|
3749
|
+
when "ipa"
|
|
3750
|
+
["build", "ipa"]
|
|
2148
3751
|
when "web"
|
|
2149
3752
|
["build", "web"]
|
|
2150
3753
|
when "macos"
|