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.
@@ -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 = detect_flutter_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 place client at ./ruflet_client"
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
- ok = prepare_flutter_client(client_dir, tools: tools, config: config)
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
- client_url = configured_client_url(config)
57
- if client_url
58
- build_args += ["--dart-define", "RUFLET_CLIENT_URL=#{client_url}"]
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
- ok = system(tools[:env], tools[:flutter], *build_args, chdir: client_dir)
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 prepare_flutter_client(client_dir, tools:, config:)
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
- unless system(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir)
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
- unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir)
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
- unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir)
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 configured_client_url(config)
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
- return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found" }
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
- return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found" }
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: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil }
616
+ return { has_icon: has_icon, has_splash: has_splash, error: nil }
209
617
  end
210
618
 
211
- if icon_defined && icon
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 splash_defined && splash
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
- { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil }
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("&", "&amp;")
918
+ .gsub("<", "&lt;")
919
+ .gsub(">", "&gt;")
920
+ .gsub('"', "&quot;")
921
+ .gsub("'", "&apos;")
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
- prune_client_main(main_path, extension_aliases) if File.file?(main_path)
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
- lines = File.readlines(path)
1233
+ content = File.read(path)
278
1234
  alias_to_package = {}
279
1235
 
280
- lines.each do |line|
281
- match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
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
- kept = lines.select do |line|
288
- import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
289
- if import_match
290
- package_name = import_match[1]
291
- next true if package_name == "flet"
292
- next true if selected_aliases.include?(import_match[2])
293
- next false
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
- extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/)
297
- if extension_match
298
- extension_alias = extension_match[1]
299
- package_name = alias_to_package[extension_alias]
300
- next true if package_name.nil?
301
- next true if selected_aliases.include?(extension_alias)
302
- next false
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, kept.join)
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", "--no-codesign"]
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