ruflet 0.0.9 → 0.0.11
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/assets/icon.png +0 -0
- data/assets/splash.png +0 -0
- data/lib/ruflet/cli/build_command.rb +1038 -45
- data/lib/ruflet/cli/extra_command.rb +20 -1
- data/lib/ruflet/cli/flutter_sdk.rb +37 -10
- data/lib/ruflet/cli/new_command.rb +93 -101
- data/lib/ruflet/cli/run_command.rb +19 -0
- data/lib/ruflet/cli/templates.rb +4 -4
- data/lib/ruflet/cli.rb +4 -1
- data/lib/ruflet/version.rb +1 -1
- metadata +3 -29
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "find"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "rbconfig"
|
|
4
8
|
require "uri"
|
|
5
9
|
require "yaml"
|
|
6
10
|
|
|
@@ -28,9 +32,11 @@ module Ruflet
|
|
|
28
32
|
}.freeze
|
|
29
33
|
|
|
30
34
|
def command_build(args)
|
|
35
|
+
self_contained = args.delete("--self")
|
|
36
|
+
verbose = args.delete("--verbose") || args.delete("-v")
|
|
31
37
|
platform = (args.shift || "").downcase
|
|
32
38
|
if platform.empty?
|
|
33
|
-
warn "Usage: ruflet build <apk|android|ios|aab|web|macos|windows|linux>"
|
|
39
|
+
warn "Usage: ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]"
|
|
34
40
|
return 1
|
|
35
41
|
end
|
|
36
42
|
|
|
@@ -40,34 +46,278 @@ module Ruflet
|
|
|
40
46
|
return 1
|
|
41
47
|
end
|
|
42
48
|
|
|
43
|
-
client_dir =
|
|
49
|
+
client_dir = ensure_flutter_client_dir(verbose: !!verbose)
|
|
44
50
|
unless client_dir
|
|
45
51
|
warn "Could not find Flutter client directory."
|
|
46
|
-
warn "Set RUFLET_CLIENT_DIR or
|
|
52
|
+
warn "Set RUFLET_CLIENT_DIR or let Ruflet manage the client under ./build/client"
|
|
47
53
|
return 1
|
|
48
54
|
end
|
|
49
55
|
|
|
56
|
+
build_note("Preparing #{platform} build (#{self_contained ? 'self-contained' : 'server-driven'})")
|
|
50
57
|
config = load_ruflet_config
|
|
51
58
|
tools = ensure_flutter!("build", client_dir: client_dir)
|
|
52
|
-
|
|
59
|
+
command_env = build_tool_env(tools[:env], platform, client_dir)
|
|
60
|
+
ok = prepare_flutter_client(
|
|
61
|
+
client_dir,
|
|
62
|
+
platform: platform,
|
|
63
|
+
tools: tools.merge(env: command_env),
|
|
64
|
+
config: config,
|
|
65
|
+
self_contained: !!self_contained,
|
|
66
|
+
verbose: !!verbose
|
|
67
|
+
)
|
|
53
68
|
return 1 unless ok
|
|
54
69
|
|
|
55
70
|
build_args = [*flutter_cmd, *args]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
build_args << "--codesign" if ios_device_build_needs_codesign_flag?(platform, build_args)
|
|
72
|
+
target_entrypoint = flutter_target_entrypoint(client_dir, self_contained: !!self_contained)
|
|
73
|
+
build_args += ["--target", target_entrypoint] if target_entrypoint
|
|
74
|
+
backend_url = configured_backend_url(config)
|
|
75
|
+
if self_contained
|
|
76
|
+
build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url
|
|
77
|
+
else
|
|
78
|
+
unless backend_url
|
|
79
|
+
warn "build config error: backend_url is required for server-driven builds"
|
|
80
|
+
warn "Set app.backend_url or backend_url in ruflet.yaml"
|
|
81
|
+
return 1
|
|
82
|
+
end
|
|
83
|
+
build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
|
|
84
|
+
end
|
|
85
|
+
build_args << "-v" if verbose
|
|
86
|
+
|
|
87
|
+
build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
|
|
88
|
+
build_log(verbose, "client_dir=#{client_dir}")
|
|
89
|
+
build_log(verbose, "flutter=#{tools[:flutter]}")
|
|
90
|
+
build_log(verbose, "dart=#{tools[:dart]}")
|
|
91
|
+
build_log(verbose, "target=#{target_entrypoint}") if target_entrypoint
|
|
92
|
+
build_log(verbose, "command=#{([tools[:flutter]] + build_args).join(' ')}")
|
|
93
|
+
|
|
94
|
+
build_note("Running Flutter #{build_args.join(' ')}")
|
|
95
|
+
ok = run_external_command(command_env, tools[:flutter], *build_args, chdir: client_dir, unbundled: true)
|
|
96
|
+
export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok
|
|
97
|
+
ok ? 0 : 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def command_install(args)
|
|
101
|
+
verbose = args.delete("--verbose") || args.delete("-v")
|
|
102
|
+
device_id = extract_option_value!(args, "--device", "-d")
|
|
103
|
+
|
|
104
|
+
client_dir = ensure_flutter_client_dir(verbose: !!verbose)
|
|
105
|
+
unless client_dir
|
|
106
|
+
warn "Could not find Flutter client directory."
|
|
107
|
+
warn "Set RUFLET_CLIENT_DIR or let Ruflet manage the client under ./build/client"
|
|
108
|
+
return 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
tools = ensure_flutter!("install", client_dir: client_dir)
|
|
112
|
+
command_env = install_tool_env(tools[:env], client_dir)
|
|
113
|
+
install_platform = install_platform_for_device(device_id)
|
|
114
|
+
unless sync_built_outputs_for_install(client_dir, platform: install_platform, verbose: !!verbose)
|
|
115
|
+
warn "Could not find built app outputs under ./build"
|
|
116
|
+
warn "Run `ruflet build ...` first, then `ruflet install`."
|
|
117
|
+
return 1
|
|
118
|
+
end
|
|
119
|
+
unless validate_install_artifacts(client_dir, platform: install_platform, device_id: device_id)
|
|
120
|
+
return 1
|
|
59
121
|
end
|
|
60
122
|
|
|
61
|
-
|
|
123
|
+
install_args = ["install"]
|
|
124
|
+
install_args += ["-d", device_id] if device_id
|
|
125
|
+
install_args << "-v" if verbose
|
|
126
|
+
|
|
127
|
+
build_log(verbose, "client_dir=#{client_dir}")
|
|
128
|
+
build_log(verbose, "flutter=#{tools[:flutter]}")
|
|
129
|
+
build_log(verbose, "dart=#{tools[:dart]}")
|
|
130
|
+
build_log(verbose, "install_command=#{([tools[:flutter]] + install_args).join(' ')}")
|
|
131
|
+
build_note("Installing app#{device_id ? " to device #{device_id}" : ""}")
|
|
132
|
+
|
|
133
|
+
ok = run_external_command(command_env, tools[:flutter], *install_args, chdir: client_dir, unbundled: true)
|
|
62
134
|
ok ? 0 : 1
|
|
63
135
|
end
|
|
64
136
|
|
|
65
137
|
private
|
|
66
138
|
|
|
139
|
+
def extract_option_value!(args, *flags)
|
|
140
|
+
flags.each do |flag|
|
|
141
|
+
index = args.index(flag)
|
|
142
|
+
next unless index
|
|
143
|
+
|
|
144
|
+
value = args[index + 1]
|
|
145
|
+
args.slice!(index, 2)
|
|
146
|
+
return value
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ensure_flutter_client_dir(verbose: false)
|
|
152
|
+
client_dir = detect_flutter_client_dir
|
|
153
|
+
return client_dir if client_dir
|
|
154
|
+
|
|
155
|
+
bootstrapped = bootstrap_flutter_client_template
|
|
156
|
+
build_log(verbose, "bootstrapped client template at #{bootstrapped}") if bootstrapped
|
|
157
|
+
bootstrapped
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_tool_env(env, platform, client_dir = nil)
|
|
161
|
+
return env unless %w[ios macos].include?(platform)
|
|
162
|
+
|
|
163
|
+
apple_env = unbundled_command_env(env)
|
|
164
|
+
apple_env["PATH"] = apple_build_path(apple_env["PATH"])
|
|
165
|
+
install_apple_pod_shim(client_dir, apple_env) if client_dir
|
|
166
|
+
apple_env
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def install_tool_env(env, client_dir)
|
|
170
|
+
return build_tool_env(env, inferred_install_platform, client_dir) if inferred_install_platform
|
|
171
|
+
|
|
172
|
+
command_env = unbundled_command_env(env)
|
|
173
|
+
command_env["PATH"] = apple_build_path(command_env["PATH"])
|
|
174
|
+
install_apple_pod_shim(client_dir, command_env)
|
|
175
|
+
command_env
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def inferred_install_platform
|
|
179
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
180
|
+
return "ios" if host_os.match?(/darwin/i)
|
|
181
|
+
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def export_platform_build_outputs(client_dir, platform, verbose: false)
|
|
186
|
+
exports_for(platform).each do |relative_source, relative_target|
|
|
187
|
+
source = File.join(client_dir, "build", relative_source)
|
|
188
|
+
next unless File.exist?(source)
|
|
189
|
+
|
|
190
|
+
target = File.join(user_build_root, relative_target)
|
|
191
|
+
FileUtils.rm_rf(target)
|
|
192
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
193
|
+
FileUtils.cp_r(source, target)
|
|
194
|
+
build_log(verbose, "exported #{source} -> #{target}")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def sync_built_outputs_for_install(client_dir, platform: nil, verbose: false)
|
|
199
|
+
synced = false
|
|
200
|
+
|
|
201
|
+
platforms =
|
|
202
|
+
if platform
|
|
203
|
+
install_sync_platforms(platform)
|
|
204
|
+
else
|
|
205
|
+
%w[android ios macos windows linux web apk aab appbundle]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
platforms.each do |target_platform|
|
|
209
|
+
exports_for(target_platform).each do |relative_source, relative_target|
|
|
210
|
+
source = File.join(user_build_root, relative_target)
|
|
211
|
+
next unless File.exist?(source)
|
|
212
|
+
|
|
213
|
+
target = File.join(client_dir, "build", relative_source)
|
|
214
|
+
FileUtils.rm_rf(target)
|
|
215
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
216
|
+
FileUtils.cp_r(source, target)
|
|
217
|
+
build_log(verbose, "synced #{source} -> #{target}")
|
|
218
|
+
synced = true
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
synced
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def install_sync_platforms(platform)
|
|
226
|
+
case platform
|
|
227
|
+
when "ios"
|
|
228
|
+
%w[ios]
|
|
229
|
+
when "android"
|
|
230
|
+
%w[android apk aab appbundle]
|
|
231
|
+
when "macos"
|
|
232
|
+
%w[macos]
|
|
233
|
+
when "windows"
|
|
234
|
+
%w[windows]
|
|
235
|
+
when "linux"
|
|
236
|
+
%w[linux]
|
|
237
|
+
when "web"
|
|
238
|
+
%w[web]
|
|
239
|
+
else
|
|
240
|
+
[]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def install_platform_for_device(device_id)
|
|
245
|
+
return inferred_install_platform unless device_id
|
|
246
|
+
|
|
247
|
+
return "android" if device_id.include?("emulator-") || device_id.match?(/\A[a-z0-9._:-]+\z/i) && device_id != "macos" && device_id != "chrome" && !device_id.include?("-")
|
|
248
|
+
return "ios" if device_id.match?(/\A[0-9A-F-]{8,}\z/i)
|
|
249
|
+
return "macos" if device_id == "macos"
|
|
250
|
+
return "web" if device_id == "chrome"
|
|
251
|
+
|
|
252
|
+
inferred_install_platform
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_install_artifacts(client_dir, platform:, device_id:)
|
|
256
|
+
return true unless platform == "ios"
|
|
257
|
+
|
|
258
|
+
return validate_ios_simulator_install_artifacts(client_dir) if ios_simulator_device_id?(device_id)
|
|
259
|
+
|
|
260
|
+
device_app = File.join(client_dir, "build", "ios", "iphoneos", "Runner.app")
|
|
261
|
+
return true unless Dir.exist?(device_app)
|
|
262
|
+
return true if ios_app_signed?(device_app)
|
|
263
|
+
|
|
264
|
+
warn "install config error: iOS device app bundle is not code signed"
|
|
265
|
+
warn "Rebuild for a device with: ruflet build ios --self"
|
|
266
|
+
warn "If you intentionally built without signing, install from Xcode or rebuild without --no-codesign."
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def validate_ios_simulator_install_artifacts(client_dir)
|
|
271
|
+
simulator_app = File.join(client_dir, "build", "ios", "iphonesimulator", "Runner.app")
|
|
272
|
+
return true if Dir.exist?(simulator_app)
|
|
273
|
+
|
|
274
|
+
device_app = File.join(client_dir, "build", "ios", "iphoneos", "Runner.app")
|
|
275
|
+
if Dir.exist?(device_app)
|
|
276
|
+
warn "install config error: selected device is an iOS simulator, but the latest build is for iphoneos"
|
|
277
|
+
warn "Rebuild for the simulator with: ruflet build ios --self --simulator"
|
|
278
|
+
else
|
|
279
|
+
warn "install config error: no iOS simulator app bundle was found"
|
|
280
|
+
warn "Build the simulator target first with: ruflet build ios --self --simulator"
|
|
281
|
+
end
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def ios_simulator_device_id?(device_id)
|
|
286
|
+
return false if device_id.to_s.strip.empty?
|
|
287
|
+
|
|
288
|
+
device_id.match?(/\A[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\z/i)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def ios_app_signed?(app_path)
|
|
292
|
+
system("/usr/bin/codesign", "-vv", app_path, out: File::NULL, err: File::NULL)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def exports_for(platform)
|
|
296
|
+
case platform
|
|
297
|
+
when "apk", "android", "aab", "appbundle"
|
|
298
|
+
{ File.join("app", "outputs") => "android" }
|
|
299
|
+
when "ios"
|
|
300
|
+
{ "ios" => "ios" }
|
|
301
|
+
when "macos"
|
|
302
|
+
{ "macos" => "macos" }
|
|
303
|
+
when "windows"
|
|
304
|
+
{ "windows" => "windows" }
|
|
305
|
+
when "linux"
|
|
306
|
+
{ "linux" => "linux" }
|
|
307
|
+
when "web"
|
|
308
|
+
{ "web" => "web" }
|
|
309
|
+
else
|
|
310
|
+
{}
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
67
314
|
def detect_flutter_client_dir
|
|
68
315
|
env_dir = ENV["RUFLET_CLIENT_DIR"]
|
|
69
316
|
return env_dir if env_dir && Dir.exist?(env_dir)
|
|
70
317
|
|
|
318
|
+
hidden = hidden_flutter_client_dir
|
|
319
|
+
return hidden if Dir.exist?(hidden)
|
|
320
|
+
|
|
71
321
|
local = File.expand_path("ruflet_client", Dir.pwd)
|
|
72
322
|
return local if Dir.exist?(local)
|
|
73
323
|
|
|
@@ -77,27 +327,64 @@ module Ruflet
|
|
|
77
327
|
nil
|
|
78
328
|
end
|
|
79
329
|
|
|
80
|
-
def
|
|
330
|
+
def bootstrap_flutter_client_template
|
|
331
|
+
return nil if ENV["RUFLET_CLIENT_DIR"]
|
|
332
|
+
|
|
333
|
+
target = hidden_flutter_client_dir
|
|
334
|
+
return target if Dir.exist?(target)
|
|
335
|
+
|
|
336
|
+
if Ruflet::CLI.respond_to?(:copy_ruflet_client_template, true)
|
|
337
|
+
Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
Dir.exist?(target) ? target : nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def hidden_flutter_client_dir(root = Dir.pwd)
|
|
344
|
+
File.join(root, "build", "client")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def user_build_root(root = Dir.pwd)
|
|
348
|
+
File.join(root, "build")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
|
|
352
|
+
refresh_managed_client_template_files(client_dir, verbose: verbose)
|
|
353
|
+
sync_client_metadata(client_dir, config, verbose: verbose)
|
|
354
|
+
configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
|
|
81
355
|
apply_service_extension_config(client_dir, config)
|
|
82
356
|
asset_flags = apply_build_config(client_dir, config)
|
|
83
357
|
if asset_flags[:error]
|
|
84
358
|
warn asset_flags[:error]
|
|
85
359
|
return false
|
|
86
360
|
end
|
|
87
|
-
|
|
361
|
+
announce_asset_configuration(asset_flags)
|
|
362
|
+
clear_flutter_build_state(client_dir, verbose: verbose)
|
|
363
|
+
clear_stale_platform_outputs(client_dir, platform, verbose: verbose)
|
|
364
|
+
build_note("Resolving Flutter packages")
|
|
365
|
+
build_log(verbose, "running flutter pub get")
|
|
366
|
+
unless run_external_command(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir, unbundled: true)
|
|
88
367
|
warn "flutter pub get failed"
|
|
89
368
|
return false
|
|
90
369
|
end
|
|
91
370
|
|
|
371
|
+
unless ensure_native_build_dependencies(client_dir, platform, tools[:env], verbose: verbose)
|
|
372
|
+
return false
|
|
373
|
+
end
|
|
374
|
+
|
|
92
375
|
if asset_flags[:has_splash]
|
|
93
|
-
|
|
376
|
+
build_note("Generating splash screen with flutter_native_splash")
|
|
377
|
+
build_log(verbose, "running flutter_native_splash:create")
|
|
378
|
+
unless run_external_command(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir, unbundled: true)
|
|
94
379
|
warn "flutter_native_splash failed"
|
|
95
380
|
return false
|
|
96
381
|
end
|
|
97
382
|
end
|
|
98
383
|
|
|
99
384
|
if asset_flags[:has_icon]
|
|
100
|
-
|
|
385
|
+
build_note("Generating launcher icons with flutter_launcher_icons")
|
|
386
|
+
build_log(verbose, "running flutter_launcher_icons")
|
|
387
|
+
unless run_external_command(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir, unbundled: true)
|
|
101
388
|
warn "flutter_launcher_icons failed"
|
|
102
389
|
return false
|
|
103
390
|
end
|
|
@@ -106,9 +393,111 @@ module Ruflet
|
|
|
106
393
|
true
|
|
107
394
|
end
|
|
108
395
|
|
|
109
|
-
def
|
|
396
|
+
def ensure_native_build_dependencies(client_dir, platform, env, verbose: false)
|
|
397
|
+
case platform
|
|
398
|
+
when "ios"
|
|
399
|
+
ensure_cocoapods_install(client_dir, "ios", env, verbose: verbose)
|
|
400
|
+
when "macos"
|
|
401
|
+
ok = true
|
|
402
|
+
ok &&= ensure_cocoapods_install(client_dir, "ios", env, verbose: verbose)
|
|
403
|
+
ok &&= ensure_cocoapods_install(client_dir, "macos", env, verbose: verbose)
|
|
404
|
+
ok
|
|
405
|
+
else
|
|
406
|
+
true
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def ensure_cocoapods_install(client_dir, platform_dir, env, verbose: false)
|
|
411
|
+
pod_dir = File.join(client_dir, platform_dir)
|
|
412
|
+
return true unless Dir.exist?(pod_dir)
|
|
413
|
+
return true unless File.file?(File.join(pod_dir, "Podfile"))
|
|
414
|
+
|
|
415
|
+
build_note("Running CocoaPods install for #{platform_dir}")
|
|
416
|
+
build_log(verbose, "pod install in #{pod_dir}")
|
|
417
|
+
ok =
|
|
418
|
+
if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
|
|
419
|
+
Bundler.with_unbundled_env do
|
|
420
|
+
run_external_command(unbundled_command_env(env), "pod", "install", chdir: pod_dir, unbundled: false)
|
|
421
|
+
end
|
|
422
|
+
else
|
|
423
|
+
run_external_command(unbundled_command_env(env), "pod", "install", chdir: pod_dir, unbundled: false)
|
|
424
|
+
end
|
|
425
|
+
return true if ok
|
|
426
|
+
|
|
427
|
+
warn "CocoaPods install failed for #{platform_dir}"
|
|
428
|
+
warn "Make sure `pod` is installed and working for the Ruby used by Flutter."
|
|
429
|
+
false
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def unbundled_command_env(env)
|
|
433
|
+
sanitized_env = env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" }
|
|
434
|
+
cleared_env = {}
|
|
435
|
+
ENV.each_key do |key|
|
|
436
|
+
next unless key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_")
|
|
437
|
+
|
|
438
|
+
cleared_env[key] = nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
cleared_env.merge(sanitized_env)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def run_external_command(env, *cmd, chdir:, unbundled: false)
|
|
445
|
+
if unbundled && defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
|
|
446
|
+
Bundler.with_unbundled_env do
|
|
447
|
+
system(env, *cmd, chdir: chdir)
|
|
448
|
+
end
|
|
449
|
+
else
|
|
450
|
+
system(env, *cmd, chdir: chdir)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def apple_build_path(existing_path)
|
|
455
|
+
segments = existing_path.to_s.split(File::PATH_SEPARATOR)
|
|
456
|
+
segments.reject! { |segment| segment.include?("/.gem/ruby/") && segment.end_with?("/bin") }
|
|
457
|
+
|
|
458
|
+
preferred = []
|
|
459
|
+
preferred << "/opt/homebrew/bin" if File.executable?("/opt/homebrew/bin/pod")
|
|
460
|
+
preferred << "/usr/local/bin" if File.executable?("/usr/local/bin/pod")
|
|
461
|
+
|
|
462
|
+
(preferred + segments).uniq.join(File::PATH_SEPARATOR)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def install_apple_pod_shim(client_dir, env)
|
|
466
|
+
pod_executable = resolve_working_pod_executable
|
|
467
|
+
return unless pod_executable
|
|
468
|
+
|
|
469
|
+
shim_dir = File.join(client_dir, ".ruflet", "bin")
|
|
470
|
+
FileUtils.mkdir_p(shim_dir)
|
|
471
|
+
shim_path = File.join(shim_dir, "pod")
|
|
472
|
+
File.write(
|
|
473
|
+
shim_path,
|
|
474
|
+
<<~SH
|
|
475
|
+
#!/bin/sh
|
|
476
|
+
exec "#{pod_executable}" "$@"
|
|
477
|
+
SH
|
|
478
|
+
)
|
|
479
|
+
FileUtils.chmod("+x", shim_path)
|
|
480
|
+
env["PATH"] = ([shim_dir] + env["PATH"].to_s.split(File::PATH_SEPARATOR)).uniq.join(File::PATH_SEPARATOR)
|
|
481
|
+
env["COCOAPODS_DISABLE_STATS"] = "true"
|
|
482
|
+
env["GEM_HOME"] = nil
|
|
483
|
+
env["GEM_PATH"] = nil
|
|
484
|
+
env["GEM_ROOT"] = nil
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def resolve_working_pod_executable
|
|
488
|
+
return "/opt/homebrew/bin/pod" if File.executable?("/opt/homebrew/bin/pod")
|
|
489
|
+
return "/usr/local/bin/pod" if File.executable?("/usr/local/bin/pod")
|
|
490
|
+
|
|
491
|
+
nil
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def configured_backend_url(config)
|
|
110
495
|
candidates = [
|
|
496
|
+
config["backend_url"],
|
|
497
|
+
config["server_url"],
|
|
111
498
|
config["ruflet_client_url"],
|
|
499
|
+
(config["app"].is_a?(Hash) ? config["app"]["backend_url"] : nil),
|
|
500
|
+
(config["app"].is_a?(Hash) ? config["app"]["server_url"] : nil),
|
|
112
501
|
(config["app"].is_a?(Hash) ? config["app"]["ruflet_client_url"] : nil)
|
|
113
502
|
]
|
|
114
503
|
raw = candidates.find { |v| !v.to_s.strip.empty? }
|
|
@@ -196,19 +585,38 @@ module Ruflet
|
|
|
196
585
|
end
|
|
197
586
|
copy_asset.call(icon_macos, "icon_macos.png")
|
|
198
587
|
|
|
588
|
+
default_splash = File.file?(File.join(assets_dir, "splash.png"))
|
|
589
|
+
default_icon = File.file?(File.join(assets_dir, "icon.png"))
|
|
590
|
+
|
|
591
|
+
using_default_splash = false
|
|
592
|
+
using_default_icon = false
|
|
593
|
+
|
|
199
594
|
if splash_defined && splash.nil?
|
|
200
|
-
|
|
595
|
+
if default_splash
|
|
596
|
+
using_default_splash = true
|
|
597
|
+
build_note("Configured splash_screen was not found; using default template asset assets/splash.png")
|
|
598
|
+
else
|
|
599
|
+
return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found, and no default splash asset exists" }
|
|
600
|
+
end
|
|
201
601
|
end
|
|
202
602
|
if icon_defined && icon.nil?
|
|
203
|
-
|
|
603
|
+
if default_icon
|
|
604
|
+
using_default_icon = true
|
|
605
|
+
build_note("Configured icon_launcher was not found; using default template asset assets/icon.png")
|
|
606
|
+
else
|
|
607
|
+
return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found, and no default icon asset exists" }
|
|
608
|
+
end
|
|
204
609
|
end
|
|
205
610
|
|
|
611
|
+
has_splash = !splash.nil? || default_splash
|
|
612
|
+
has_icon = !icon.nil? || default_icon
|
|
613
|
+
|
|
206
614
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
207
615
|
unless File.file?(pubspec_path)
|
|
208
|
-
return { has_icon:
|
|
616
|
+
return { has_icon: has_icon, has_splash: has_splash, error: nil }
|
|
209
617
|
end
|
|
210
618
|
|
|
211
|
-
if
|
|
619
|
+
if has_icon
|
|
212
620
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
|
|
213
621
|
end
|
|
214
622
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
|
|
@@ -223,12 +631,306 @@ module Ruflet
|
|
|
223
631
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
|
|
224
632
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color
|
|
225
633
|
|
|
226
|
-
update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if
|
|
634
|
+
update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if has_splash
|
|
227
635
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
|
|
228
636
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
|
|
229
637
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
|
|
230
638
|
|
|
231
|
-
{
|
|
639
|
+
{
|
|
640
|
+
has_icon: has_icon,
|
|
641
|
+
has_splash: has_splash,
|
|
642
|
+
using_default_icon: using_default_icon,
|
|
643
|
+
using_default_splash: using_default_splash,
|
|
644
|
+
error: nil
|
|
645
|
+
}
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def sync_client_metadata(client_dir, config = {}, verbose: false)
|
|
649
|
+
metadata = build_client_metadata(config, client_dir)
|
|
650
|
+
apply_pubspec_metadata(client_dir, metadata)
|
|
651
|
+
apply_android_metadata(client_dir, metadata)
|
|
652
|
+
apply_ios_metadata(client_dir, metadata)
|
|
653
|
+
apply_macos_metadata(client_dir, metadata)
|
|
654
|
+
apply_web_metadata(client_dir, metadata)
|
|
655
|
+
apply_windows_metadata(client_dir, metadata)
|
|
656
|
+
apply_linux_metadata(client_dir, metadata)
|
|
657
|
+
build_log(
|
|
658
|
+
verbose,
|
|
659
|
+
"app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}"
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def build_client_metadata(config, client_dir)
|
|
664
|
+
app = config["app"].is_a?(Hash) ? config["app"] : {}
|
|
665
|
+
current_pubspec = load_client_pubspec(client_dir)
|
|
666
|
+
current_name = current_pubspec["name"].to_s
|
|
667
|
+
inferred_display_name = app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd))
|
|
668
|
+
package_name = normalize_package_name(app["package_name"] || config["package_name"] || current_name || inferred_display_name)
|
|
669
|
+
display_name = first_present(app["display_name"], app["name"], config["display_name"], config["name"], humanize_name(package_name))
|
|
670
|
+
organization = normalize_bundle_prefix(
|
|
671
|
+
first_present(app["org"], app["organization"], config["org"], config["organization"], "com.example")
|
|
672
|
+
)
|
|
673
|
+
bundle_identifier = normalize_bundle_identifier(
|
|
674
|
+
first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
{
|
|
678
|
+
package_name: package_name,
|
|
679
|
+
display_name: display_name,
|
|
680
|
+
description: first_present(app["description"], config["description"], current_pubspec["description"], "A new Flutter project."),
|
|
681
|
+
version: first_present(app["version"], config["version"], current_pubspec["version"], "1.0.0+1"),
|
|
682
|
+
organization: organization,
|
|
683
|
+
company_name: first_present(app["company_name"], config["company_name"], organization),
|
|
684
|
+
bundle_identifier: bundle_identifier,
|
|
685
|
+
android_application_id: normalize_bundle_identifier(
|
|
686
|
+
first_present(app["android_application_id"], config["android_application_id"], bundle_identifier)
|
|
687
|
+
),
|
|
688
|
+
ios_bundle_identifier: normalize_bundle_identifier(
|
|
689
|
+
first_present(app["ios_bundle_identifier"], config["ios_bundle_identifier"], bundle_identifier)
|
|
690
|
+
),
|
|
691
|
+
macos_bundle_identifier: normalize_bundle_identifier(
|
|
692
|
+
first_present(app["macos_bundle_identifier"], config["macos_bundle_identifier"], bundle_identifier)
|
|
693
|
+
),
|
|
694
|
+
linux_application_id: normalize_bundle_identifier(
|
|
695
|
+
first_present(app["linux_application_id"], config["linux_application_id"], bundle_identifier)
|
|
696
|
+
),
|
|
697
|
+
short_name: first_present(app["short_name"], config["short_name"], display_name)
|
|
698
|
+
}
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def load_client_pubspec(client_dir)
|
|
702
|
+
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
703
|
+
return {} unless File.file?(pubspec_path)
|
|
704
|
+
|
|
705
|
+
YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
|
|
706
|
+
rescue StandardError
|
|
707
|
+
{}
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def apply_pubspec_metadata(client_dir, metadata)
|
|
711
|
+
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
712
|
+
return unless File.file?(pubspec_path)
|
|
713
|
+
|
|
714
|
+
data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
|
|
715
|
+
data["name"] = metadata[:package_name]
|
|
716
|
+
data["description"] = metadata[:description]
|
|
717
|
+
data["version"] = metadata[:version]
|
|
718
|
+
write_pubspec_yaml(pubspec_path, data)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def apply_android_metadata(client_dir, metadata)
|
|
722
|
+
gradle_path = File.join(client_dir, "android", "app", "build.gradle.kts")
|
|
723
|
+
replace_in_file(
|
|
724
|
+
gradle_path,
|
|
725
|
+
/^\s*namespace = ".*"$/,
|
|
726
|
+
%( namespace = "#{metadata[:android_application_id]}")
|
|
727
|
+
)
|
|
728
|
+
replace_in_file(
|
|
729
|
+
gradle_path,
|
|
730
|
+
/^\s*applicationId = ".*"$/,
|
|
731
|
+
%( applicationId = "#{metadata[:android_application_id]}")
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
|
|
735
|
+
replace_in_file(
|
|
736
|
+
manifest_path,
|
|
737
|
+
/android:label="[^"]*"/,
|
|
738
|
+
%(android:label="#{xml_escape(metadata[:display_name])}")
|
|
739
|
+
)
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def apply_ios_metadata(client_dir, metadata)
|
|
743
|
+
info_plist_path = File.join(client_dir, "ios", "Runner", "Info.plist")
|
|
744
|
+
replace_plist_value(info_plist_path, "CFBundleDisplayName", metadata[:display_name])
|
|
745
|
+
replace_plist_value(info_plist_path, "CFBundleName", metadata[:display_name])
|
|
746
|
+
|
|
747
|
+
pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
|
|
748
|
+
return unless File.file?(pbxproj_path)
|
|
749
|
+
|
|
750
|
+
content = File.read(pbxproj_path)
|
|
751
|
+
content.gsub!(/INFOPLIST_KEY_CFBundleDisplayName = "[^"]*";/, %(INFOPLIST_KEY_CFBundleDisplayName = "#{xcode_escape(metadata[:display_name])}";))
|
|
752
|
+
content.gsub!(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/) do |match|
|
|
753
|
+
identifier = Regexp.last_match(1).to_s.strip
|
|
754
|
+
if identifier.include?("RunnerTests")
|
|
755
|
+
match
|
|
756
|
+
else
|
|
757
|
+
"PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:ios_bundle_identifier]};"
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
File.write(pbxproj_path, content)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def apply_macos_metadata(client_dir, metadata)
|
|
764
|
+
app_info_path = File.join(client_dir, "macos", "Runner", "Configs", "AppInfo.xcconfig")
|
|
765
|
+
replace_in_file(
|
|
766
|
+
app_info_path,
|
|
767
|
+
/^PRODUCT_NAME = .*$/,
|
|
768
|
+
"PRODUCT_NAME = #{metadata[:display_name]}"
|
|
769
|
+
)
|
|
770
|
+
replace_in_file(
|
|
771
|
+
app_info_path,
|
|
772
|
+
/^PRODUCT_BUNDLE_IDENTIFIER = .*$/,
|
|
773
|
+
"PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:macos_bundle_identifier]}"
|
|
774
|
+
)
|
|
775
|
+
replace_in_file(
|
|
776
|
+
app_info_path,
|
|
777
|
+
/^PRODUCT_COPYRIGHT = .*$/,
|
|
778
|
+
"PRODUCT_COPYRIGHT = Copyright © #{Time.now.year} #{metadata[:company_name]}. All rights reserved."
|
|
779
|
+
)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def apply_web_metadata(client_dir, metadata)
|
|
783
|
+
manifest_path = File.join(client_dir, "web", "manifest.json")
|
|
784
|
+
if File.file?(manifest_path)
|
|
785
|
+
data = JSON.parse(File.read(manifest_path))
|
|
786
|
+
data["name"] = metadata[:display_name]
|
|
787
|
+
data["short_name"] = metadata[:short_name]
|
|
788
|
+
data["description"] = metadata[:description]
|
|
789
|
+
File.write(manifest_path, JSON.pretty_generate(data) + "\n")
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
index_path = File.join(client_dir, "web", "index.html")
|
|
793
|
+
replace_in_file(
|
|
794
|
+
index_path,
|
|
795
|
+
/<meta name="description" content="[^"]*">/,
|
|
796
|
+
%(<meta name="description" content="#{html_escape(metadata[:description])}">)
|
|
797
|
+
)
|
|
798
|
+
replace_in_file(
|
|
799
|
+
index_path,
|
|
800
|
+
/<meta name="apple-mobile-web-app-title" content="[^"]*">/,
|
|
801
|
+
%(<meta name="apple-mobile-web-app-title" content="#{html_escape(metadata[:short_name])}">)
|
|
802
|
+
)
|
|
803
|
+
replace_in_file(
|
|
804
|
+
index_path,
|
|
805
|
+
/<title>.*<\/title>/,
|
|
806
|
+
"<title>#{html_escape(metadata[:display_name])}</title>"
|
|
807
|
+
)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def apply_windows_metadata(client_dir, metadata)
|
|
811
|
+
cmake_path = File.join(client_dir, "windows", "CMakeLists.txt")
|
|
812
|
+
replace_in_file(cmake_path, /^project\(.*\)$/, "project(#{metadata[:package_name]} LANGUAGES CXX)")
|
|
813
|
+
replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}")))
|
|
814
|
+
|
|
815
|
+
runner_rc_path = File.join(client_dir, "windows", "runner", "Runner.rc")
|
|
816
|
+
replace_in_file(
|
|
817
|
+
runner_rc_path,
|
|
818
|
+
/VALUE "CompanyName", ".*" "\\0"/,
|
|
819
|
+
%(VALUE "CompanyName", "#{windows_string_escape(metadata[:company_name])}" "\\0")
|
|
820
|
+
)
|
|
821
|
+
replace_in_file(
|
|
822
|
+
runner_rc_path,
|
|
823
|
+
/VALUE "FileDescription", ".*" "\\0"/,
|
|
824
|
+
%(VALUE "FileDescription", "#{windows_string_escape(metadata[:display_name])}" "\\0")
|
|
825
|
+
)
|
|
826
|
+
replace_in_file(
|
|
827
|
+
runner_rc_path,
|
|
828
|
+
/VALUE "InternalName", ".*" "\\0"/,
|
|
829
|
+
%(VALUE "InternalName", "#{windows_string_escape(metadata[:package_name])}" "\\0")
|
|
830
|
+
)
|
|
831
|
+
replace_in_file(
|
|
832
|
+
runner_rc_path,
|
|
833
|
+
/VALUE "LegalCopyright", ".*" "\\0"/,
|
|
834
|
+
%(VALUE "LegalCopyright", "Copyright (C) #{Time.now.year} #{windows_string_escape(metadata[:company_name])}. All rights reserved." "\\0")
|
|
835
|
+
)
|
|
836
|
+
replace_in_file(
|
|
837
|
+
runner_rc_path,
|
|
838
|
+
/VALUE "OriginalFilename", ".*" "\\0"/,
|
|
839
|
+
%(VALUE "OriginalFilename", "#{windows_string_escape(metadata[:package_name])}.exe" "\\0")
|
|
840
|
+
)
|
|
841
|
+
replace_in_file(
|
|
842
|
+
runner_rc_path,
|
|
843
|
+
/VALUE "ProductName", ".*" "\\0"/,
|
|
844
|
+
%(VALUE "ProductName", "#{windows_string_escape(metadata[:display_name])}" "\\0")
|
|
845
|
+
)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def apply_linux_metadata(client_dir, metadata)
|
|
849
|
+
cmake_path = File.join(client_dir, "linux", "CMakeLists.txt")
|
|
850
|
+
replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}")))
|
|
851
|
+
replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def replace_plist_value(path, key, value)
|
|
855
|
+
return unless File.file?(path)
|
|
856
|
+
|
|
857
|
+
content = File.read(path)
|
|
858
|
+
pattern = %r{(<key>#{Regexp.escape(key)}</key>\s*<string>)(.*?)(</string>)}m
|
|
859
|
+
updated = content.gsub(pattern) do
|
|
860
|
+
"#{Regexp.last_match(1)}#{xml_escape(value)}#{Regexp.last_match(3)}"
|
|
861
|
+
end
|
|
862
|
+
File.write(path, updated) unless updated == content
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def replace_in_file(path, pattern, replacement)
|
|
866
|
+
return unless File.file?(path)
|
|
867
|
+
|
|
868
|
+
content = File.read(path)
|
|
869
|
+
updated = content.gsub(pattern, replacement)
|
|
870
|
+
File.write(path, updated) unless updated == content
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def first_present(*values)
|
|
874
|
+
values.find { |value| !value.to_s.strip.empty? }
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def normalize_package_name(value)
|
|
878
|
+
normalized = value.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_")
|
|
879
|
+
normalized.gsub!(/\A_+|_+\z/, "")
|
|
880
|
+
normalized.gsub!(/_+/, "_")
|
|
881
|
+
normalized = "ruflet_client" if normalized.empty?
|
|
882
|
+
normalized = "app_#{normalized}" if normalized.match?(/\A\d/)
|
|
883
|
+
normalized
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def normalize_bundle_prefix(value)
|
|
887
|
+
segments = value.to_s.strip.downcase.split(".").map do |segment|
|
|
888
|
+
normalized = segment.gsub(/[^a-z0-9_]+/, "")
|
|
889
|
+
normalized = "app" if normalized.empty?
|
|
890
|
+
normalized = "app#{normalized}" if normalized.match?(/\A\d/)
|
|
891
|
+
normalized
|
|
892
|
+
end
|
|
893
|
+
segments.reject!(&:empty?)
|
|
894
|
+
segments = %w[com example] if segments.empty?
|
|
895
|
+
segments.join(".")
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def normalize_bundle_identifier(value)
|
|
899
|
+
segments = value.to_s.strip.downcase.split(".").map do |segment|
|
|
900
|
+
normalized = segment.gsub(/[^a-z0-9_]+/, "_")
|
|
901
|
+
normalized.gsub!(/\A_+|_+\z/, "")
|
|
902
|
+
normalized = "app" if normalized.empty?
|
|
903
|
+
normalized = "app#{normalized}" if normalized.match?(/\A\d/)
|
|
904
|
+
normalized
|
|
905
|
+
end
|
|
906
|
+
segments.reject!(&:empty?)
|
|
907
|
+
segments = %w[com example ruflet_client] if segments.empty?
|
|
908
|
+
segments.join(".")
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def humanize_name(name)
|
|
912
|
+
name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def xml_escape(value)
|
|
916
|
+
value.to_s
|
|
917
|
+
.gsub("&", "&")
|
|
918
|
+
.gsub("<", "<")
|
|
919
|
+
.gsub(">", ">")
|
|
920
|
+
.gsub('"', """)
|
|
921
|
+
.gsub("'", "'")
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def html_escape(value)
|
|
925
|
+
xml_escape(value)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def xcode_escape(value)
|
|
929
|
+
value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def windows_string_escape(value)
|
|
933
|
+
value.to_s.gsub('"', '""')
|
|
232
934
|
end
|
|
233
935
|
|
|
234
936
|
def key_defined?(hash, key)
|
|
@@ -242,9 +944,263 @@ module Ruflet
|
|
|
242
944
|
extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
|
|
243
945
|
|
|
244
946
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
245
|
-
main_path = File.join(client_dir, "lib", "main.dart")
|
|
246
947
|
prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path)
|
|
247
|
-
|
|
948
|
+
client_entrypoint_paths(client_dir).each do |entrypoint|
|
|
949
|
+
prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def clear_flutter_build_state(client_dir, verbose: false)
|
|
954
|
+
flutter_build_dir = File.join(client_dir, ".dart_tool", "flutter_build")
|
|
955
|
+
return unless Dir.exist?(flutter_build_dir)
|
|
956
|
+
|
|
957
|
+
FileUtils.rm_rf(flutter_build_dir)
|
|
958
|
+
build_log(verbose, "cleared .dart_tool/flutter_build")
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
def clear_stale_platform_outputs(client_dir, platform, verbose: false)
|
|
962
|
+
return unless platform == "ios"
|
|
963
|
+
|
|
964
|
+
stale_paths = %w[
|
|
965
|
+
build/ios/Debug-iphonesimulator
|
|
966
|
+
build/ios/iphonesimulator
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
stale_paths.each do |relative_path|
|
|
970
|
+
path = File.join(client_dir, relative_path)
|
|
971
|
+
next unless Dir.exist?(path)
|
|
972
|
+
|
|
973
|
+
FileUtils.rm_rf(path)
|
|
974
|
+
build_log(verbose, "cleared stale #{relative_path}")
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def client_entrypoint_paths(client_dir)
|
|
979
|
+
%w[main.dart main.self.dart main.server.dart].map do |name|
|
|
980
|
+
File.join(client_dir, "lib", name)
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def configure_client_runtime_mode(client_dir, self_contained:, verbose: false)
|
|
985
|
+
build_log(verbose, "configuring #{self_contained ? 'self-contained' : 'server-driven'} runtime")
|
|
986
|
+
sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained)
|
|
987
|
+
if self_contained
|
|
988
|
+
sync_self_contained_project_assets(client_dir, verbose: verbose)
|
|
989
|
+
remove_local_ruby_runtime_override(client_dir, verbose: verbose)
|
|
990
|
+
else
|
|
991
|
+
remove_self_contained_project_assets(client_dir, verbose: verbose)
|
|
992
|
+
remove_local_ruby_runtime_override(client_dir, verbose: verbose)
|
|
993
|
+
end
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
def sync_client_pubspec_for_runtime_mode(client_dir, self_contained:)
|
|
997
|
+
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
998
|
+
return unless File.file?(pubspec_path)
|
|
999
|
+
|
|
1000
|
+
data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
|
|
1001
|
+
dependencies = data["dependencies"]
|
|
1002
|
+
dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
|
|
1003
|
+
flutter = data["flutter"]
|
|
1004
|
+
flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
|
|
1005
|
+
assets = Array(flutter["assets"]).map(&:to_s)
|
|
1006
|
+
|
|
1007
|
+
if self_contained
|
|
1008
|
+
dependencies["ruby_runtime"] = "^0.0.3"
|
|
1009
|
+
assets.delete("assets/main.rb")
|
|
1010
|
+
assets.delete("assets/ruby_project/")
|
|
1011
|
+
project_asset_path = "assets/#{self_contained_project_name}/"
|
|
1012
|
+
assets << project_asset_path unless assets.include?(project_asset_path)
|
|
1013
|
+
else
|
|
1014
|
+
dependencies.delete("ruby_runtime")
|
|
1015
|
+
assets.delete("assets/main.rb")
|
|
1016
|
+
assets.delete("assets/ruby_project/")
|
|
1017
|
+
assets.delete("assets/#{self_contained_project_name}/")
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
flutter["assets"] = assets unless assets.empty?
|
|
1021
|
+
flutter.delete("assets") if assets.empty?
|
|
1022
|
+
write_pubspec_yaml(pubspec_path, data)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def refresh_managed_client_template_files(client_dir, verbose: false)
|
|
1026
|
+
template_root =
|
|
1027
|
+
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
|
|
1028
|
+
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
|
|
1029
|
+
end
|
|
1030
|
+
return unless template_root && Dir.exist?(template_root)
|
|
1031
|
+
|
|
1032
|
+
managed_files = [
|
|
1033
|
+
"lib/main.dart",
|
|
1034
|
+
"lib/main.self.dart",
|
|
1035
|
+
"lib/main.server.dart",
|
|
1036
|
+
"lib/connection_probe.dart",
|
|
1037
|
+
"lib/connection_probe_io.dart",
|
|
1038
|
+
"lib/connection_probe_stub.dart"
|
|
1039
|
+
]
|
|
1040
|
+
|
|
1041
|
+
managed_files.each do |relative_path|
|
|
1042
|
+
source = File.join(template_root, relative_path)
|
|
1043
|
+
next unless File.file?(source)
|
|
1044
|
+
|
|
1045
|
+
destination = File.join(client_dir, relative_path)
|
|
1046
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
1047
|
+
FileUtils.cp(source, destination)
|
|
1048
|
+
build_log(verbose, "refreshed template file #{relative_path}")
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
def write_pubspec_yaml(path, data)
|
|
1053
|
+
content = YAML.dump(data)
|
|
1054
|
+
content = content.sub(/\A---\n/, "")
|
|
1055
|
+
|
|
1056
|
+
content = indent_pubspec_sequences(content)
|
|
1057
|
+
|
|
1058
|
+
File.write(path, content)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
def indent_pubspec_sequences(content)
|
|
1062
|
+
current_key_indent = nil
|
|
1063
|
+
content.lines.map do |line|
|
|
1064
|
+
if (match = line.match(/\A(\s*)[^#\s][^:]*:\s*(?:#.*)?\n?\z/))
|
|
1065
|
+
current_key_indent = match[1].length
|
|
1066
|
+
elsif (match = line.match(/\A(\s*)-\s/)) && current_key_indent && match[1].length <= current_key_indent
|
|
1067
|
+
line = (" " * (current_key_indent + 2)) + line.lstrip
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
line
|
|
1071
|
+
end.join
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def sync_self_contained_project_assets(client_dir, verbose: false)
|
|
1075
|
+
project_root = Pathname.new(Dir.pwd)
|
|
1076
|
+
assets_root = File.join(client_dir, "assets")
|
|
1077
|
+
destination_root = File.join(assets_root, self_contained_project_name)
|
|
1078
|
+
FileUtils.rm_rf(destination_root)
|
|
1079
|
+
FileUtils.rm_rf(File.join(assets_root, "ruby_project"))
|
|
1080
|
+
FileUtils.mkdir_p(destination_root)
|
|
1081
|
+
|
|
1082
|
+
legacy_entrypoint = File.join(client_dir, "assets", "main.rb")
|
|
1083
|
+
FileUtils.rm_f(legacy_entrypoint)
|
|
1084
|
+
|
|
1085
|
+
copied = 0
|
|
1086
|
+
project_asset_relative_paths.each do |relative_path|
|
|
1087
|
+
source = project_root.join(relative_path)
|
|
1088
|
+
next unless source.exist? && source.file?
|
|
1089
|
+
|
|
1090
|
+
destination = File.join(destination_root, relative_path)
|
|
1091
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
1092
|
+
FileUtils.cp(source.to_s, destination)
|
|
1093
|
+
copied += 1
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
build_log(verbose, "copied #{copied} project file#{copied == 1 ? '' : 's'} to assets/#{self_contained_project_name}")
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def remove_self_contained_project_assets(client_dir, verbose: false)
|
|
1100
|
+
assets_root = File.join(client_dir, "assets")
|
|
1101
|
+
legacy_entrypoint = File.join(client_dir, "assets", "main.rb")
|
|
1102
|
+
FileUtils.rm_f(legacy_entrypoint)
|
|
1103
|
+
removed = false
|
|
1104
|
+
|
|
1105
|
+
project_root = File.join(assets_root, self_contained_project_name)
|
|
1106
|
+
if Dir.exist?(project_root)
|
|
1107
|
+
FileUtils.rm_rf(project_root)
|
|
1108
|
+
removed = true
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
legacy_root = File.join(assets_root, "ruby_project")
|
|
1112
|
+
if Dir.exist?(legacy_root)
|
|
1113
|
+
FileUtils.rm_rf(legacy_root)
|
|
1114
|
+
removed = true
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
build_log(verbose, "removed embedded self-contained project assets") if removed
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def project_asset_relative_paths
|
|
1121
|
+
root = Pathname.new(Dir.pwd)
|
|
1122
|
+
included = []
|
|
1123
|
+
|
|
1124
|
+
Find.find(root.to_s) do |path|
|
|
1125
|
+
pathname = Pathname.new(path)
|
|
1126
|
+
relative = pathname.relative_path_from(root).to_s
|
|
1127
|
+
next if relative.empty?
|
|
1128
|
+
|
|
1129
|
+
if pathname.directory?
|
|
1130
|
+
if skip_project_asset_directory?(relative)
|
|
1131
|
+
Find.prune
|
|
1132
|
+
else
|
|
1133
|
+
next
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
next unless include_project_asset_file?(relative)
|
|
1138
|
+
|
|
1139
|
+
included << relative
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
included.sort
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def self_contained_project_name
|
|
1146
|
+
name = File.basename(Dir.pwd.to_s)
|
|
1147
|
+
name = "app" if name.to_s.strip.empty?
|
|
1148
|
+
name
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
def skip_project_asset_directory?(relative)
|
|
1152
|
+
first = relative.split(File::SEPARATOR).first
|
|
1153
|
+
%w[
|
|
1154
|
+
.git
|
|
1155
|
+
.bundle
|
|
1156
|
+
.dart_tool
|
|
1157
|
+
.idea
|
|
1158
|
+
.ruby-lsp
|
|
1159
|
+
.vscode
|
|
1160
|
+
build
|
|
1161
|
+
coverage
|
|
1162
|
+
log
|
|
1163
|
+
node_modules
|
|
1164
|
+
pkg
|
|
1165
|
+
ruflet_client
|
|
1166
|
+
tmp
|
|
1167
|
+
vendor
|
|
1168
|
+
].include?(first)
|
|
1169
|
+
end
|
|
1170
|
+
|
|
1171
|
+
def include_project_asset_file?(relative)
|
|
1172
|
+
basename = File.basename(relative)
|
|
1173
|
+
return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename)
|
|
1174
|
+
return true if %w[main.rb Gemfile ruflet.yaml ruflet.yml manifest.json].include?(basename)
|
|
1175
|
+
|
|
1176
|
+
ext = File.extname(relative).downcase
|
|
1177
|
+
return true if %w[.rb .json .yml .yaml].include?(ext)
|
|
1178
|
+
|
|
1179
|
+
first = relative.split(File::SEPARATOR).first
|
|
1180
|
+
return true if first == "assets"
|
|
1181
|
+
|
|
1182
|
+
false
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def flutter_target_entrypoint(client_dir, self_contained:)
|
|
1186
|
+
candidate = File.join(
|
|
1187
|
+
client_dir,
|
|
1188
|
+
"lib",
|
|
1189
|
+
self_contained ? "main.self.dart" : "main.server.dart"
|
|
1190
|
+
)
|
|
1191
|
+
return nil unless File.file?(candidate)
|
|
1192
|
+
|
|
1193
|
+
File.join("lib", File.basename(candidate))
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def remove_local_ruby_runtime_override(client_dir, verbose: false)
|
|
1197
|
+
overrides_path = File.join(client_dir, "pubspec_overrides.yaml")
|
|
1198
|
+
return unless File.file?(overrides_path)
|
|
1199
|
+
|
|
1200
|
+
File.delete(overrides_path)
|
|
1201
|
+
build_log(verbose, "removed ruby_runtime override")
|
|
1202
|
+
rescue StandardError => e
|
|
1203
|
+
warn "Failed to remove ruby_runtime override: #{e.class}: #{e.message}"
|
|
248
1204
|
end
|
|
249
1205
|
|
|
250
1206
|
def normalize_extension_key(value)
|
|
@@ -274,38 +1230,34 @@ module Ruflet
|
|
|
274
1230
|
end
|
|
275
1231
|
|
|
276
1232
|
def prune_client_main(path, selected_aliases)
|
|
277
|
-
|
|
1233
|
+
content = File.read(path)
|
|
278
1234
|
alias_to_package = {}
|
|
279
1235
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
next unless match
|
|
283
|
-
|
|
284
|
-
alias_to_package[match[2]] = match[1]
|
|
1236
|
+
content.scan(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias|
|
|
1237
|
+
alias_to_package[import_alias] = package_name
|
|
285
1238
|
end
|
|
286
1239
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1240
|
+
content = content.gsub(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
|
|
1241
|
+
package_name = Regexp.last_match(1)
|
|
1242
|
+
import_alias = Regexp.last_match(2)
|
|
1243
|
+
if package_name == "flet" || selected_aliases.include?(import_alias)
|
|
1244
|
+
match
|
|
1245
|
+
else
|
|
1246
|
+
""
|
|
294
1247
|
end
|
|
1248
|
+
end
|
|
295
1249
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1250
|
+
content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.Extension\(\),\s*$/) do |match|
|
|
1251
|
+
extension_alias = Regexp.last_match(2)
|
|
1252
|
+
package_name = alias_to_package[extension_alias]
|
|
1253
|
+
if package_name.nil? || selected_aliases.include?(extension_alias)
|
|
1254
|
+
match
|
|
1255
|
+
else
|
|
1256
|
+
""
|
|
303
1257
|
end
|
|
304
|
-
|
|
305
|
-
true
|
|
306
1258
|
end
|
|
307
1259
|
|
|
308
|
-
File.write(path,
|
|
1260
|
+
File.write(path, content)
|
|
309
1261
|
end
|
|
310
1262
|
|
|
311
1263
|
def update_pubspec_value(path, block, key, value, multiple: false)
|
|
@@ -354,7 +1306,7 @@ module Ruflet
|
|
|
354
1306
|
when "aab", "appbundle"
|
|
355
1307
|
["build", "appbundle"]
|
|
356
1308
|
when "ios"
|
|
357
|
-
["build", "ios"
|
|
1309
|
+
["build", "ios"]
|
|
358
1310
|
when "web"
|
|
359
1311
|
["build", "web"]
|
|
360
1312
|
when "macos"
|
|
@@ -367,6 +1319,47 @@ module Ruflet
|
|
|
367
1319
|
nil
|
|
368
1320
|
end
|
|
369
1321
|
end
|
|
1322
|
+
|
|
1323
|
+
def ios_device_build_needs_codesign_flag?(platform, build_args)
|
|
1324
|
+
return false unless platform == "ios"
|
|
1325
|
+
return false if build_args.include?("--simulator")
|
|
1326
|
+
return false if build_args.include?("--no-codesign")
|
|
1327
|
+
return false if build_args.include?("--codesign")
|
|
1328
|
+
|
|
1329
|
+
true
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def build_log(verbose, message)
|
|
1333
|
+
return unless verbose
|
|
1334
|
+
|
|
1335
|
+
puts "[ruflet build] #{message}"
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
def build_note(message)
|
|
1339
|
+
puts "[ruflet build] #{message}"
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
def announce_asset_configuration(asset_flags)
|
|
1343
|
+
if asset_flags[:has_splash]
|
|
1344
|
+
if asset_flags[:using_default_splash]
|
|
1345
|
+
build_note("Splash screen will use the default template asset")
|
|
1346
|
+
else
|
|
1347
|
+
build_note("Splash screen is configured")
|
|
1348
|
+
end
|
|
1349
|
+
else
|
|
1350
|
+
build_note("No splash screen configured")
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
if asset_flags[:has_icon]
|
|
1354
|
+
if asset_flags[:using_default_icon]
|
|
1355
|
+
build_note("Launcher icons will use the default template asset")
|
|
1356
|
+
else
|
|
1357
|
+
build_note("Launcher icons are configured")
|
|
1358
|
+
end
|
|
1359
|
+
else
|
|
1360
|
+
build_note("No launcher icons configured")
|
|
1361
|
+
end
|
|
1362
|
+
end
|
|
370
1363
|
end
|
|
371
1364
|
end
|
|
372
1365
|
end
|