ruflet 0.0.18 → 0.0.20

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.
@@ -14,7 +14,6 @@ module Ruflet
14
14
  module BuildCommand
15
15
  include FlutterSdk
16
16
  CLIENT_EXTENSION_MAP = {
17
- "ads" => { package: "flet_ads", alias: "ruflet_ads" },
18
17
  "audio" => { package: "flet_audio", alias: "ruflet_audio" },
19
18
  "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
20
19
  "camera" => { package: "flet_camera", alias: "ruflet_camera" },
@@ -27,6 +26,7 @@ module Ruflet
27
26
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
28
27
  "map" => { package: "flet_map", alias: "ruflet_map" },
29
28
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
29
+ "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
30
30
  "rive" => { package: "flet_rive", alias: "ruflet_rive" },
31
31
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
32
32
  "video" => { package: "flet_video", alias: "ruflet_video" },
@@ -38,6 +38,9 @@ module Ruflet
38
38
  "location" => %w[geolocator permission_handler],
39
39
  "motion" => %w[permission_handler]
40
40
  }.freeze
41
+ EXTENSION_REQUIRED_SERVICES = {
42
+ "qrcode_scanner" => %w[camera]
43
+ }.freeze
41
44
  ANDROID_SERVICE_PERMISSIONS = {
42
45
  "camera" => %w[android.permission.CAMERA],
43
46
  "microphone" => %w[android.permission.RECORD_AUDIO],
@@ -50,6 +53,20 @@ module Ruflet
50
53
  "location" => "NSLocationWhenInUseUsageDescription",
51
54
  "motion" => "NSMotionUsageDescription"
52
55
  }.freeze
56
+ # Clients that are told which server to use at launch rather than at build
57
+ # time, so they may be built without a configured backend_url.
58
+ RUNTIME_RESOLVED_BACKEND_PLATFORMS = %w[web macos windows linux].freeze
59
+ # What the underlying generators can actually produce per platform:
60
+ # flutter_native_splash covers android/ios/web, flutter_launcher_icons
61
+ # covers android/ios/web/windows/macos. Linux has neither.
62
+ PLATFORM_ASSET_SUPPORT = {
63
+ "android" => { splash: true, icon: true },
64
+ "ios" => { splash: true, icon: true },
65
+ "web" => { splash: true, icon: true },
66
+ "macos" => { splash: false, icon: true },
67
+ "windows" => { splash: false, icon: true },
68
+ "linux" => { splash: false, icon: false }
69
+ }.freeze
53
70
 
54
71
  def command_build(args)
55
72
  self_contained = args.delete("--self")
@@ -66,6 +83,15 @@ module Ruflet
66
83
  return 1
67
84
  end
68
85
 
86
+ # The embedded Ruby VM is a native plugin with no browser
87
+ # implementation, so a self-contained web build produces an app that
88
+ # cannot start. Say so rather than shipping one that hangs.
89
+ if self_contained && platform == "web"
90
+ warn "build config error: --self is not supported for web"
91
+ warn "A web client runs no embedded Ruby; build it with `ruflet build web`."
92
+ return 1
93
+ end
94
+
69
95
  ensure_ruflet_build_assets(verbose: !!verbose)
70
96
  client_dir = ensure_flutter_client_dir(verbose: !!verbose)
71
97
  unless client_dir
@@ -99,15 +125,21 @@ module Ruflet
99
125
  # deterministically instead of inferring from a single main.rb — the
100
126
  # app tree now ships many main.rb files (standalone_apps/*/main.rb).
101
127
  build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
102
- else
103
- unless backend_url
104
- warn "build config error: backend_url is required for server-driven builds"
105
- warn "Set app.backend_url or backend_url in ruflet.yaml"
106
- return 1
107
- end
128
+ elsif backend_url
108
129
  build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
130
+ elsif RUNTIME_RESOLVED_BACKEND_PLATFORMS.include?(platform)
131
+ # These clients learn their server at launch: a web client from the
132
+ # origin it is served from, a desktop client from the URL the launcher
133
+ # passes. Baking one in would pin them to a single host and port,
134
+ # which a preview client cannot use.
135
+ build_note("No backend_url configured; the #{platform} client will resolve its server at launch")
136
+ else
137
+ warn "build config error: backend_url is required for server-driven builds"
138
+ warn "Set app.backend_url or backend_url in ruflet.yaml"
139
+ return 1
109
140
  end
110
141
  build_args << "-v" if verbose
142
+ stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
111
143
 
112
144
  build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
113
145
  build_log(verbose, "client_dir=#{client_dir}")
@@ -175,13 +207,30 @@ module Ruflet
175
207
 
176
208
  def ensure_flutter_client_dir(verbose: false)
177
209
  client_dir = detect_flutter_client_dir
178
- return client_dir if client_dir
210
+ if client_dir
211
+ refresh_hidden_flutter_client_template(client_dir, verbose: verbose)
212
+ return client_dir
213
+ end
179
214
 
180
215
  bootstrapped = bootstrap_flutter_client_template
181
216
  build_log(verbose, "bootstrapped client template at #{bootstrapped}") if bootstrapped
182
217
  bootstrapped
183
218
  end
184
219
 
220
+ def refresh_hidden_flutter_client_template(client_dir, verbose: false)
221
+ return unless File.expand_path(client_dir) == File.expand_path(hidden_flutter_client_dir)
222
+ return unless File.file?(File.join(client_dir, ".metadata"))
223
+ return unless Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
224
+
225
+ template_root = Ruflet::CLI.send(:resolve_ruflet_client_template_root)
226
+ return unless template_root && Dir.exist?(template_root)
227
+ return if Ruflet::CLI.respond_to?(:client_template_current?, true) &&
228
+ Ruflet::CLI.send(:client_template_current?, client_dir, template_root)
229
+
230
+ Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd)
231
+ build_log(verbose, "refreshed managed Flutter client from template #{template_root}")
232
+ end
233
+
185
234
  def build_tool_env(env, platform, client_dir = nil)
186
235
  return env unless %w[ios macos].include?(platform)
187
236
 
@@ -384,7 +433,9 @@ module Ruflet
384
433
 
385
434
  def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
386
435
  refresh_managed_client_template_files(client_dir, verbose: verbose)
387
- sync_client_metadata(client_dir, config, verbose: verbose)
436
+ metadata = sync_client_metadata(client_dir, config, verbose: verbose)
437
+ return false unless validate_mobile_app_identity(metadata, platform: platform)
438
+
388
439
  apply_native_service_permissions(client_dir, config)
389
440
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
390
441
  return false if configured == false
@@ -408,6 +459,10 @@ module Ruflet
408
459
  return false
409
460
  end
410
461
 
462
+ unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
463
+ return false
464
+ end
465
+
411
466
  unless ensure_native_build_dependencies(client_dir, platform, tools[:env], verbose: verbose)
412
467
  return false
413
468
  end
@@ -430,9 +485,60 @@ module Ruflet
430
485
  end
431
486
  end
432
487
 
488
+ verify_android_generated_assets(client_dir, asset_flags, platform, verbose: verbose)
489
+
433
490
  true
434
491
  end
435
492
 
493
+ # The generators can succeed while silently skipping Android output, which
494
+ # ships a build with the stock Flutter icon and splash. Confirm the native
495
+ # resources that ruflet.yaml asked for are actually on disk.
496
+ def verify_android_generated_assets(client_dir, asset_flags, platform, verbose: false)
497
+ return true unless %w[apk android aab appbundle].include?(platform.to_s)
498
+
499
+ res_dir = File.join(client_dir, "android", "app", "src", "main", "res")
500
+ return true unless File.directory?(res_dir)
501
+
502
+ ok = true
503
+
504
+ if asset_flags[:has_splash]
505
+ launch_background = File.join(res_dir, "drawable", "launch_background.xml")
506
+ if !File.file?(launch_background) || !read_text_file(launch_background).include?("splash")
507
+ warn "Android splash screen was not generated in res/drawable/launch_background.xml"
508
+ ok = false
509
+ end
510
+
511
+ styles_v31 = File.join(res_dir, "values-v31", "styles.xml")
512
+ if !File.file?(styles_v31) || !read_text_file(styles_v31).include?("windowSplashScreenBackground")
513
+ warn "Android 12+ splash screen is missing from res/values-v31/styles.xml; " \
514
+ "devices on Android 12 and newer will show the system default splash"
515
+ ok = false
516
+ else
517
+ build_log(verbose, "android 12+ splash present in values-v31/styles.xml")
518
+ end
519
+ end
520
+
521
+ if asset_flags[:has_icon]
522
+ adaptive_icon = File.join(res_dir, "mipmap-anydpi-v26", "launcher_icon.xml")
523
+ if File.file?(adaptive_icon)
524
+ build_log(verbose, "adaptive launcher icon present in mipmap-anydpi-v26")
525
+ else
526
+ warn "Android adaptive launcher icon was not generated in res/mipmap-anydpi-v26/; " \
527
+ "set android.adaptive_icon_foreground and android.adaptive_icon_background in ruflet.yaml"
528
+ ok = false
529
+ end
530
+
531
+ manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
532
+ if File.file?(manifest) && !read_text_file(manifest).include?("@mipmap/launcher_icon")
533
+ warn "AndroidManifest.xml does not reference @mipmap/launcher_icon; the configured launcher icon is unused"
534
+ ok = false
535
+ end
536
+ end
537
+
538
+ build_note("Android launcher icon and splash resources verified") if ok && (asset_flags[:has_icon] || asset_flags[:has_splash])
539
+ ok
540
+ end
541
+
436
542
  def ensure_flutter_platform_artifacts(client_dir, platform, env, flutter, verbose: false)
437
543
  precache_flags = flutter_precache_flags(platform)
438
544
  return true if precache_flags.empty?
@@ -479,6 +585,50 @@ module Ruflet
479
585
  end
480
586
  end
481
587
 
588
+ # CocoaPods can incorrectly treat the static-library XCFramework copy
589
+ # phase as up to date after Ruflet clears Debug-iphonesimulator. The
590
+ # Runner then links with -lruflet_vm while the selected simulator slice
591
+ # is absent. Stage that deterministic slice before Flutter invokes
592
+ # Xcode; CocoaPods may still copy over it normally.
593
+ def stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: false)
594
+ return true unless build_args.include?("ios")
595
+ return true unless build_args.include?("--simulator")
596
+
597
+ runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
598
+ return true unless runtime_root
599
+
600
+ source = File.join(
601
+ runtime_root,
602
+ "ios",
603
+ "Frameworks",
604
+ "RufletVM.xcframework",
605
+ "ios-arm64_x86_64-simulator"
606
+ )
607
+ library = File.join(source, "libruflet_vm.a")
608
+ return true unless File.file?(library)
609
+
610
+ destination = File.join(
611
+ client_dir,
612
+ "build",
613
+ "ios",
614
+ "Debug-iphonesimulator",
615
+ "XCFrameworkIntermediates",
616
+ "ruby_runtime"
617
+ )
618
+ FileUtils.mkdir_p(destination)
619
+ FileUtils.cp(library, File.join(destination, "libruflet_vm.a"))
620
+
621
+ headers = File.join(source, "Headers")
622
+ if Dir.exist?(headers)
623
+ destination_headers = File.join(destination, "Headers")
624
+ FileUtils.rm_rf(destination_headers)
625
+ FileUtils.cp_r(headers, destination_headers)
626
+ end
627
+
628
+ build_log(verbose, "staged iOS simulator Ruflet VM at #{destination}")
629
+ true
630
+ end
631
+
482
632
  def ensure_cocoapods_install(client_dir, platform_dir, env, verbose: false)
483
633
  pod_dir = File.join(client_dir, platform_dir)
484
634
  return true unless Dir.exist?(pod_dir)
@@ -533,7 +683,7 @@ module Ruflet
533
683
  shim_dir = File.join(client_dir, ".ruflet", "bin")
534
684
  FileUtils.mkdir_p(shim_dir)
535
685
  shim_path = File.join(shim_dir, "pod")
536
- File.write(
686
+ write_text_file(
537
687
  shim_path,
538
688
  <<~SH
539
689
  #!/bin/sh
@@ -583,12 +733,16 @@ module Ruflet
583
733
  alt = "ruflet.yml"
584
734
  config_path = alt if File.file?(alt)
585
735
  end
586
- return {} unless File.file?(config_path)
587
-
588
- config = YAML.safe_load(File.read(config_path), aliases: true) || {}
589
- services_path = File.join(File.dirname(File.expand_path(config_path)), "services.yaml")
736
+ config_exists = File.file?(config_path)
737
+ config = config_exists ? YAML.safe_load(read_text_file(config_path), aliases: true) || {} : {}
738
+ config_dir = File.dirname(File.expand_path(config_path))
739
+ services_path = File.join(config_dir, "services.yaml")
590
740
  if File.file?(services_path)
591
- service_config = YAML.safe_load(File.read(services_path), aliases: true) || {}
741
+ service_config = YAML.safe_load(read_text_file(services_path), aliases: true) || {}
742
+ if service_config["app"].is_a?(Hash)
743
+ config["app"] = (config["app"].is_a?(Hash) ? config["app"] : {}).merge(service_config["app"])
744
+ config["_app_identity_source"] = "services.yaml"
745
+ end
592
746
  config["services"] = service_config["services"] if service_config.key?("services")
593
747
  end
594
748
  config
@@ -624,16 +778,93 @@ module Ruflet
624
778
  splash = resolve_asset.call(build["splash_screen"] || assets["splash_screen"] || config["splash_screen"])
625
779
  splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
626
780
  icon = resolve_asset.call(build["icon_launcher"] || assets["icon_launcher"] || config["icon_launcher"])
627
- icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"])
628
- icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"])
629
- icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"])
630
- icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"])
631
- icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"])
632
781
 
633
- splash_color = build["splash_color"]
634
- splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"]
635
- icon_background = build["icon_background"]
636
- theme_color = build["theme_color"]
782
+ # Splash and icon appearance belongs with the assets it styles. `build`
783
+ # is still read first so existing projects keep working.
784
+ splash_color = build["splash_color"] || assets["splash_color"]
785
+ splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"] ||
786
+ assets["splash_dark_color"] || assets["splash_color_dark"]
787
+ icon_background = build["icon_background"] || assets["icon_background"]
788
+ theme_color = build["theme_color"] || assets["theme_color"]
789
+
790
+ # Every platform gets its own section with the same key names, falling
791
+ # back to the shared assets/build values when a key is not overridden.
792
+ platforms = PLATFORM_ASSET_SUPPORT.keys.each_with_object({}) do |name, resolved|
793
+ section = platform_build_config(config, name)
794
+ resolved[name] = {
795
+ config: section,
796
+ splash: resolve_asset.call(
797
+ section["splash_screen"] || section["splash_image"] ||
798
+ build["splash_#{name}"] || assets["splash_#{name}"]
799
+ ),
800
+ splash_dark: resolve_asset.call(
801
+ section["splash_dark"] || section["splash_dark_image"] || assets["splash_#{name}_dark"]
802
+ ),
803
+ icon: resolve_asset.call(
804
+ section["icon_launcher"] || section["icon"] ||
805
+ build["icon_#{name}"] || assets["icon_#{name}"]
806
+ ),
807
+ background_image: resolve_asset.call(
808
+ section["splash_background_image"] || section["background_image"] || assets["splash_background_#{name}"]
809
+ ),
810
+ background_image_dark: resolve_asset.call(
811
+ section["splash_background_image_dark"] || section["background_image_dark"]
812
+ ),
813
+ branding: resolve_asset.call(section["splash_branding"] || section["branding"]),
814
+ branding_dark: resolve_asset.call(section["splash_branding_dark"] || section["branding_dark"]),
815
+ splash_color: section["splash_color"] || splash_color,
816
+ splash_dark_color: section["splash_dark_color"] || section["splash_color_dark"] || splash_dark_color,
817
+ icon_background: section["icon_background"] || icon_background,
818
+ theme_color: section["theme_color"] || theme_color
819
+ }
820
+ end
821
+
822
+ splash_background_image = resolve_asset.call(
823
+ build["splash_background_image"] || assets["splash_background_image"] || build["background_image"]
824
+ )
825
+ splash_background_image_dark = resolve_asset.call(
826
+ build["splash_background_image_dark"] || assets["splash_background_image_dark"]
827
+ )
828
+ splash_branding = resolve_asset.call(build["splash_branding"] || assets["splash_branding"])
829
+ splash_branding_dark = resolve_asset.call(build["splash_branding_dark"] || assets["splash_branding_dark"])
830
+ splash_branding_mode = build["splash_branding_mode"] || build["branding_mode"] ||
831
+ assets["splash_branding_mode"] || assets["branding_mode"]
832
+ splash_branding_padding = build["splash_branding_bottom_padding"] || build["branding_bottom_padding"] ||
833
+ assets["splash_branding_bottom_padding"] || assets["branding_bottom_padding"]
834
+
835
+ android = platforms.dig("android", :config)
836
+ android_splash = platforms.dig("android", :splash)
837
+ android_splash_dark = platforms.dig("android", :splash_dark)
838
+ android_12_splash = resolve_asset.call(
839
+ android["splash_android_12"] || android["android_12_image"] || assets["splash_android_12"]
840
+ )
841
+ android_12_splash_dark = resolve_asset.call(
842
+ android["splash_android_12_dark"] || android["android_12_image_dark"] || assets["splash_android_12_dark"]
843
+ )
844
+ adaptive_foreground = resolve_asset.call(
845
+ android["adaptive_icon_foreground"] || android["icon_foreground"] ||
846
+ assets["icon_adaptive_foreground"] || assets["icon_foreground"]
847
+ )
848
+ adaptive_background_image = resolve_asset.call(
849
+ android["adaptive_icon_background_image"] || assets["icon_adaptive_background_image"]
850
+ )
851
+ adaptive_monochrome = resolve_asset.call(
852
+ android["adaptive_icon_monochrome"] || android["icon_monochrome"] || assets["icon_adaptive_monochrome"]
853
+ )
854
+
855
+ android_splash_color = platforms.dig("android", :splash_color)
856
+ android_splash_dark_color = platforms.dig("android", :splash_dark_color)
857
+ android_12_icon_background = android["splash_android_12_icon_background_color"] ||
858
+ android["icon_background_color"] || android_splash_color
859
+ android_12_icon_background_dark = android["splash_android_12_icon_background_color_dark"] ||
860
+ android["icon_background_color_dark"] || android_splash_dark_color
861
+ android_12_color = android["splash_android_12_color"] || android["android_12_color"]
862
+ android_12_color_dark = android["splash_android_12_color_dark"] || android["android_12_color_dark"]
863
+ android_12_branding = resolve_asset.call(android["splash_android_12_branding"] || android["android_12_branding"])
864
+ adaptive_background_color = android["adaptive_icon_background"] || icon_background
865
+ android_min_sdk = android["min_sdk"] || android["min_sdk_android"] || build["min_sdk_android"]
866
+ android_splash_fullscreen = first_defined(android, "splash_fullscreen", "fullscreen")
867
+ android_splash_gravity = android["splash_gravity"] || android["android_gravity"]
637
868
 
638
869
  assets_dir = File.join(client_dir, "assets")
639
870
  FileUtils.mkdir_p(assets_dir)
@@ -646,14 +877,43 @@ module Ruflet
646
877
  copy_asset.call(splash, "splash.png")
647
878
  copy_asset.call(splash_dark, "splash_dark.png")
648
879
  copy_asset.call(icon, "icon.png")
649
- copy_asset.call(icon_android, "icon_android.png")
650
- copy_asset.call(icon_ios, "icon_ios.png")
651
- copy_asset.call(icon_web, "icon_web.png")
652
- if icon_windows
653
- ext = File.extname(icon_windows).downcase
654
- copy_asset.call(icon_windows, ext == ".ico" ? "icon_windows.ico" : "icon_windows.png")
880
+ copy_asset.call(splash_background_image, "splash_background.png")
881
+ copy_asset.call(splash_background_image_dark, "splash_background_dark.png")
882
+ copy_asset.call(splash_branding, "splash_branding.png")
883
+ copy_asset.call(splash_branding_dark, "splash_branding_dark.png")
884
+
885
+ platforms.each do |name, entry|
886
+ support = PLATFORM_ASSET_SUPPORT.fetch(name)
887
+ if support[:splash]
888
+ copy_asset.call(entry[:splash], "splash_#{name}.png")
889
+ copy_asset.call(entry[:splash_dark], "splash_#{name}_dark.png")
890
+ copy_asset.call(entry[:background_image], "splash_background_#{name}.png")
891
+ copy_asset.call(entry[:background_image_dark], "splash_background_#{name}_dark.png")
892
+ copy_asset.call(entry[:branding], "splash_branding_#{name}.png")
893
+ copy_asset.call(entry[:branding_dark], "splash_branding_#{name}_dark.png")
894
+ elsif entry[:splash] || entry[:splash_dark]
895
+ build_note("#{name} has no splash screen generator; ignoring #{name}.splash_screen")
896
+ end
897
+
898
+ next unless entry[:icon]
899
+
900
+ unless support[:icon]
901
+ build_note("#{name} has no launcher icon generator; ignoring #{name}.icon_launcher")
902
+ next
903
+ end
904
+
905
+ if name == "windows" && File.extname(entry[:icon]).downcase == ".ico"
906
+ copy_asset.call(entry[:icon], "icon_windows.ico")
907
+ else
908
+ copy_asset.call(entry[:icon], "icon_#{name}.png")
909
+ end
655
910
  end
656
- copy_asset.call(icon_macos, "icon_macos.png")
911
+
912
+ copy_asset.call(android_12_splash, "splash_android_12.png")
913
+ copy_asset.call(android_12_splash_dark, "splash_android_12_dark.png")
914
+ copy_asset.call(adaptive_foreground, "icon_foreground.png")
915
+ copy_asset.call(adaptive_background_image, "icon_background.png")
916
+ copy_asset.call(adaptive_monochrome, "icon_monochrome.png")
657
917
 
658
918
  default_splash = File.file?(File.join(assets_dir, "splash.png"))
659
919
  default_icon = File.file?(File.join(assets_dir, "icon.png"))
@@ -678,43 +938,235 @@ module Ruflet
678
938
  end
679
939
  end
680
940
 
681
- has_splash = !splash.nil? || default_splash
682
- has_icon = !icon.nil? || default_icon
941
+ # A project may configure nothing shared and declare everything under the
942
+ # platform sections, so a platform asset alone has to run the generators.
943
+ platform_splash = platforms.any? { |name, entry| PLATFORM_ASSET_SUPPORT.fetch(name)[:splash] && entry[:splash] }
944
+ platform_icon = platforms.any? { |name, entry| PLATFORM_ASSET_SUPPORT.fetch(name)[:icon] && entry[:icon] }
945
+
946
+ platforms.each do |name, entry|
947
+ support = PLATFORM_ASSET_SUPPORT.fetch(name)
948
+ if support[:splash] && entry[:splash].nil? && key_defined?(entry[:config], "splash_screen")
949
+ build_note("#{name}.splash_screen was set but the file was not found")
950
+ end
951
+ if support[:icon] && entry[:icon].nil? && key_defined?(entry[:config], "icon_launcher")
952
+ build_note("#{name}.icon_launcher was set but the file was not found")
953
+ end
954
+ end
955
+
956
+ shared_splash_asset = !splash.nil? || default_splash
957
+ shared_icon_asset = !icon.nil? || default_icon
958
+ has_splash = shared_splash_asset || platform_splash
959
+ has_icon = shared_icon_asset || platform_icon
960
+
961
+ # Fall back to whatever Android configured when nothing is shared.
962
+ effective_icon_background = icon_background || platforms.dig("android", :icon_background)
963
+ effective_theme_color = theme_color || platforms.dig("android", :theme_color)
683
964
 
684
965
  pubspec_path = File.join(client_dir, "pubspec.yaml")
685
966
  unless File.file?(pubspec_path)
686
967
  return { has_icon: has_icon, has_splash: has_splash, error: nil }
687
968
  end
688
969
 
970
+ ensure_pubspec_block(pubspec_path, "flutter_launcher_icons") if has_icon
971
+ ensure_pubspec_block(pubspec_path, "flutter_native_splash") if has_splash
972
+
689
973
  if has_icon
690
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
974
+ if shared_icon_asset
975
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
976
+ end
977
+ # Android 8+ renders adaptive icons. Without these keys flutter_launcher_icons
978
+ # never writes mipmap-anydpi-v26/, and the launcher falls back to the legacy
979
+ # bitmap, ignoring icon_background entirely.
980
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "android", "launcher_icon", multiple: true)
981
+ adaptive_foreground_path =
982
+ if adaptive_foreground then "assets/icon_foreground.png"
983
+ elsif platforms.dig("android", :icon) then "assets/icon_android.png"
984
+ else "assets/icon.png"
985
+ end
986
+ update_pubspec_value(
987
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_foreground",
988
+ "\"#{adaptive_foreground_path}\"", multiple: true
989
+ )
990
+ adaptive_background_value =
991
+ if adaptive_background_image
992
+ "\"assets/icon_background.png\""
993
+ elsif adaptive_background_color
994
+ "\"#{adaptive_background_color}\""
995
+ end
996
+ if adaptive_background_value
997
+ update_pubspec_value(
998
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_background",
999
+ adaptive_background_value, multiple: true
1000
+ )
1001
+ end
1002
+ if adaptive_monochrome
1003
+ update_pubspec_value(
1004
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_monochrome",
1005
+ "\"assets/icon_monochrome.png\"", multiple: true
1006
+ )
1007
+ end
1008
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "min_sdk_android", android_min_sdk.to_s) if android_min_sdk
691
1009
  end
692
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
693
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true) if icon_ios
694
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_web", "\"assets/icon_web.png\"", multiple: true) if icon_web
695
- if icon_windows
696
- ext = File.extname(icon_windows).downcase
697
- value = ext == ".ico" ? "\"assets/icon_windows.ico\"" : "\"assets/icon_windows.png\""
698
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_windows", value, multiple: true)
1010
+ if has_icon
1011
+ # flutter_launcher_icons takes android/ios as flat image_path_* keys but
1012
+ # web/windows/macos as nested platform blocks.
1013
+ if platforms.dig("android", :icon)
1014
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true)
1015
+ end
1016
+ if platforms.dig("ios", :icon)
1017
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true)
1018
+ end
1019
+ if (remove_alpha = first_defined(platforms.dig("ios", :config), "remove_alpha", "remove_alpha_ios"))
1020
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "remove_alpha_ios", remove_alpha ? "true" : "false")
1021
+ end
1022
+
1023
+ %w[web windows macos].each do |name|
1024
+ entry = platforms.fetch(name)
1025
+ section = entry[:config]
1026
+ next if entry[:icon].nil? && section.empty?
1027
+
1028
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", name, "generate", "true")
1029
+ if entry[:icon]
1030
+ image = if name == "windows" && File.extname(entry[:icon]).downcase == ".ico"
1031
+ "assets/icon_windows.ico"
1032
+ else
1033
+ "assets/icon_#{name}.png"
1034
+ end
1035
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", name, "image_path", "\"#{image}\"")
1036
+ end
1037
+ if name == "web"
1038
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "web", "background_color", "\"#{entry[:icon_background]}\"") if entry[:icon_background]
1039
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "web", "theme_color", "\"#{entry[:theme_color]}\"") if entry[:theme_color]
1040
+ end
1041
+ if name == "windows" && (icon_size = section["icon_size"])
1042
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "windows", "icon_size", icon_size.to_s)
1043
+ end
1044
+ end
699
1045
  end
700
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_macos", "\"assets/icon_macos.png\"", multiple: true) if icon_macos
701
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
702
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color
1046
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{effective_icon_background}\"") if effective_icon_background
1047
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{effective_theme_color}\"") if effective_theme_color
703
1048
 
704
- update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if has_splash
1049
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if shared_splash_asset
705
1050
  update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
706
1051
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
707
1052
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
708
1053
 
1054
+ if has_splash
1055
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image", "\"assets/splash_background.png\"") if splash_background_image
1056
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_dark", "\"assets/splash_background_dark.png\"") if splash_background_image_dark
1057
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding", "\"assets/splash_branding.png\"") if splash_branding
1058
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_dark", "\"assets/splash_branding_dark.png\"") if splash_branding_dark
1059
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_mode", splash_branding_mode.to_s) if splash_branding_mode
1060
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_bottom_padding", splash_branding_padding.to_s) if splash_branding_padding
1061
+ end
1062
+
1063
+ if has_splash
1064
+ # flutter_native_splash only generates for android, ios, and web; each
1065
+ # takes the shared keys suffixed with the platform name.
1066
+ %w[android ios web].each do |name|
1067
+ entry = platforms.fetch(name)
1068
+ update_pubspec_value(pubspec_path, "flutter_native_splash", name, "true")
1069
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_#{name}", "\"assets/splash_#{name}.png\"") if entry[:splash]
1070
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark_#{name}", "\"assets/splash_#{name}_dark.png\"") if entry[:splash_dark]
1071
+ if entry[:splash_color] && entry[:splash_color] != splash_color
1072
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_#{name}", "\"#{entry[:splash_color]}\"")
1073
+ end
1074
+ if entry[:splash_dark_color] && entry[:splash_dark_color] != splash_dark_color
1075
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark_#{name}", "\"#{entry[:splash_dark_color]}\"")
1076
+ end
1077
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_#{name}", "\"assets/splash_background_#{name}.png\"") if entry[:background_image]
1078
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_dark_#{name}", "\"assets/splash_background_#{name}_dark.png\"") if entry[:background_image_dark]
1079
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_#{name}", "\"assets/splash_branding_#{name}.png\"") if entry[:branding]
1080
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_dark_#{name}", "\"assets/splash_branding_#{name}_dark.png\"") if entry[:branding_dark]
1081
+ end
1082
+
1083
+ if (ios_content_mode = platforms.dig("ios", :config)["content_mode"] || platforms.dig("ios", :config)["ios_content_mode"])
1084
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "ios_content_mode", ios_content_mode.to_s)
1085
+ end
1086
+ if (web_image_mode = platforms.dig("web", :config)["image_mode"] || platforms.dig("web", :config)["web_image_mode"])
1087
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "web_image_mode", web_image_mode.to_s)
1088
+ end
1089
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "fullscreen", android_splash_fullscreen ? "true" : "false") unless android_splash_fullscreen.nil?
1090
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "android_gravity", android_splash_gravity.to_s) if android_splash_gravity
1091
+
1092
+ # Android 12+ draws the splash itself and ignores the legacy `image`/`color`
1093
+ # keys. Without an android_12 section the OS shows the launcher icon on a
1094
+ # system background, so mirror the configured splash into it.
1095
+ android_12_image =
1096
+ if android_12_splash then "assets/splash_android_12.png"
1097
+ elsif android_splash then "assets/splash_android.png"
1098
+ else "assets/splash.png"
1099
+ end
1100
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "image", "\"#{android_12_image}\"")
1101
+ if android_12_icon_background
1102
+ update_pubspec_nested_value(
1103
+ pubspec_path, "flutter_native_splash", "android_12",
1104
+ "icon_background_color", "\"#{android_12_icon_background}\""
1105
+ )
1106
+ end
1107
+ android_12_image_dark =
1108
+ if android_12_splash_dark then "assets/splash_android_12_dark.png"
1109
+ elsif android_splash_dark then "assets/splash_android_dark.png"
1110
+ elsif splash_dark then "assets/splash_dark.png"
1111
+ end
1112
+ if android_12_image_dark
1113
+ update_pubspec_nested_value(
1114
+ pubspec_path, "flutter_native_splash", "android_12",
1115
+ "image_dark", "\"#{android_12_image_dark}\""
1116
+ )
1117
+ end
1118
+ if android_12_icon_background_dark
1119
+ update_pubspec_nested_value(
1120
+ pubspec_path, "flutter_native_splash", "android_12",
1121
+ "icon_background_color_dark", "\"#{android_12_icon_background_dark}\""
1122
+ )
1123
+ end
1124
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "color", "\"#{android_12_color}\"") if android_12_color
1125
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "color_dark", "\"#{android_12_color_dark}\"") if android_12_color_dark
1126
+ if android_12_branding
1127
+ copy_asset.call(android_12_branding, "splash_android_12_branding.png")
1128
+ update_pubspec_nested_value(
1129
+ pubspec_path, "flutter_native_splash", "android_12",
1130
+ "branding", "\"assets/splash_android_12_branding.png\""
1131
+ )
1132
+ end
1133
+ end
1134
+
709
1135
  {
710
1136
  has_icon: has_icon,
711
1137
  has_splash: has_splash,
712
1138
  using_default_icon: using_default_icon,
713
1139
  using_default_splash: using_default_splash,
1140
+ android_adaptive_icon: has_icon,
1141
+ android_12_splash: has_splash,
714
1142
  error: nil
715
1143
  }
716
1144
  end
717
1145
 
1146
+ # Platform-specific overrides may live in a top-level `<platform>:` block,
1147
+ # under `build.<platform>:`, or under `assets.<platform>:`. Later sources win.
1148
+ def platform_build_config(config, platform)
1149
+ name = platform.to_s
1150
+ build = config["build"].is_a?(Hash) ? config["build"] : {}
1151
+ assets = config["assets"].is_a?(Hash) ? config["assets"] : {}
1152
+ [config[name], build[name], assets[name]].each_with_object({}) do |source, merged|
1153
+ next unless source.is_a?(Hash)
1154
+
1155
+ source.each { |key, value| merged[key.to_s] = value }
1156
+ end
1157
+ end
1158
+
1159
+ # Like first_present, but keeps `false` — needed for boolean toggles.
1160
+ def first_defined(hash, *keys)
1161
+ return nil unless hash.is_a?(Hash)
1162
+
1163
+ keys.each do |key|
1164
+ return hash[key] if hash.key?(key)
1165
+ return hash[key.to_sym] if hash.key?(key.to_sym)
1166
+ end
1167
+ nil
1168
+ end
1169
+
718
1170
  def sync_client_metadata(client_dir, config = {}, verbose: false)
719
1171
  metadata = build_client_metadata(config, client_dir)
720
1172
  apply_pubspec_metadata(client_dir, metadata)
@@ -728,21 +1180,30 @@ module Ruflet
728
1180
  verbose,
729
1181
  "app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}"
730
1182
  )
1183
+ metadata
731
1184
  end
732
1185
 
733
1186
  def build_client_metadata(config, client_dir)
734
1187
  app = config["app"].is_a?(Hash) ? config["app"] : {}
735
1188
  current_pubspec = load_client_pubspec(client_dir)
736
1189
  current_name = current_pubspec["name"].to_s
737
- inferred_display_name = app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd))
738
- package_name = normalize_package_name(app["package_name"] || config["package_name"] || current_name || inferred_display_name)
739
- display_name = first_present(app["display_name"], app["name"], config["display_name"], config["name"], humanize_name(package_name))
1190
+ inferred_display_name = app["app_name"] || app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd))
1191
+ configured_app_name = first_present(app["app_name"], app["display_name"], app["name"])
1192
+ package_source = first_present(app["package_name"], config["package_name"], configured_app_name)
1193
+ package_source = current_name if package_source.nil?
1194
+ package_name = normalize_package_name(package_source)
1195
+ display_name = first_present(app["app_name"], app["display_name"], app["name"], config["display_name"], config["name"], humanize_name(package_name))
740
1196
  organization = normalize_bundle_prefix(
741
1197
  first_present(app["org"], app["organization"], config["org"], config["organization"], "com.example")
742
1198
  )
743
1199
  bundle_identifier = normalize_bundle_identifier(
744
1200
  first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
745
1201
  )
1202
+ identity_errors = []
1203
+ identity_errors << "app.app_name" if app["app_name"].to_s.strip.empty?
1204
+ identity_errors << "app.package_name" if app["package_name"].to_s.strip.empty?
1205
+ identity_errors << "app.organization" if app["organization"].to_s.strip.empty?
1206
+ identity_errors << "services.yaml app section" unless config["_app_identity_source"] == "services.yaml"
746
1207
 
747
1208
  {
748
1209
  package_name: package_name,
@@ -764,15 +1225,28 @@ module Ruflet
764
1225
  linux_application_id: normalize_bundle_identifier(
765
1226
  first_present(app["linux_application_id"], config["linux_application_id"], bundle_identifier)
766
1227
  ),
767
- short_name: first_present(app["short_name"], config["short_name"], display_name)
1228
+ short_name: first_present(app["short_name"], config["short_name"], display_name),
1229
+ mobile_identity_errors: identity_errors.uniq
768
1230
  }
769
1231
  end
770
1232
 
1233
+ def validate_mobile_app_identity(metadata, platform:)
1234
+ return true unless %w[apk android aab ios].include?(platform.to_s)
1235
+ return true unless metadata
1236
+
1237
+ errors = Array(metadata[:mobile_identity_errors])
1238
+ return true if errors.empty?
1239
+
1240
+ warn "build config error: services.yaml must define #{errors.join(', ')}"
1241
+ warn "Set app.app_name, app.package_name, and app.organization before building a mobile app."
1242
+ false
1243
+ end
1244
+
771
1245
  def load_client_pubspec(client_dir)
772
1246
  pubspec_path = File.join(client_dir, "pubspec.yaml")
773
1247
  return {} unless File.file?(pubspec_path)
774
1248
 
775
- YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1249
+ YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
776
1250
  rescue StandardError
777
1251
  {}
778
1252
  end
@@ -781,7 +1255,7 @@ module Ruflet
781
1255
  pubspec_path = File.join(client_dir, "pubspec.yaml")
782
1256
  return unless File.file?(pubspec_path)
783
1257
 
784
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1258
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
785
1259
  data["name"] = metadata[:package_name]
786
1260
  data["description"] = metadata[:description]
787
1261
  data["version"] = metadata[:version]
@@ -789,28 +1263,6 @@ module Ruflet
789
1263
  end
790
1264
 
791
1265
  def apply_android_metadata(client_dir, metadata)
792
- gradle_path = File.join(client_dir, "android", "app", "build.gradle.kts")
793
- replace_in_file(
794
- gradle_path,
795
- /^\s*namespace = ".*"$/,
796
- %( namespace = "#{metadata[:android_application_id]}")
797
- )
798
- replace_in_file(
799
- gradle_path,
800
- /^\s*applicationId = ".*"$/,
801
- %( applicationId = "#{metadata[:android_application_id]}")
802
- )
803
-
804
- Dir.glob(
805
- File.join(client_dir, "android", "app", "src", "main", "kotlin", "**", "MainActivity.kt")
806
- ).each do |activity_path|
807
- replace_in_file(
808
- activity_path,
809
- /^package\s+[^\s]+$/,
810
- "package #{metadata[:android_application_id]}"
811
- )
812
- end
813
-
814
1266
  manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
815
1267
  replace_in_file(
816
1268
  manifest_path,
@@ -827,17 +1279,37 @@ module Ruflet
827
1279
  pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
828
1280
  return unless File.file?(pbxproj_path)
829
1281
 
830
- content = File.read(pbxproj_path)
1282
+ content = read_text_file(pbxproj_path)
831
1283
  content.gsub!(/INFOPLIST_KEY_CFBundleDisplayName = "[^"]*";/, %(INFOPLIST_KEY_CFBundleDisplayName = "#{xcode_escape(metadata[:display_name])}";))
832
- content.gsub!(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/) do |match|
833
- identifier = Regexp.last_match(1).to_s.strip
834
- if identifier.include?("RunnerTests")
835
- match
836
- else
837
- "PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:ios_bundle_identifier]};"
838
- end
839
- end
840
- File.write(pbxproj_path, content)
1284
+ write_text_file(pbxproj_path, content)
1285
+ end
1286
+
1287
+ def apply_mobile_package_name(client_dir, metadata, platform:, tools:, verbose: false)
1288
+ return true unless metadata
1289
+
1290
+ package_name, platform_flag = case platform.to_s
1291
+ when "apk", "android", "aab"
1292
+ [metadata[:android_application_id], "--android"]
1293
+ when "ios"
1294
+ [metadata[:ios_bundle_identifier], "--ios"]
1295
+ else
1296
+ return true
1297
+ end
1298
+
1299
+ build_note("Applying #{platform_flag.delete_prefix('--')} package name #{package_name}")
1300
+ build_log(verbose, "running change_app_package_name for #{package_name} #{platform_flag}")
1301
+ ok = run_external_command(
1302
+ tools[:env],
1303
+ tools[:dart],
1304
+ "run",
1305
+ "change_app_package_name:main",
1306
+ package_name,
1307
+ platform_flag,
1308
+ chdir: client_dir,
1309
+ unbundled: true
1310
+ )
1311
+ warn "change_app_package_name failed for #{package_name}" unless ok
1312
+ ok
841
1313
  end
842
1314
 
843
1315
  def apply_macos_metadata(client_dir, metadata)
@@ -862,11 +1334,11 @@ module Ruflet
862
1334
  def apply_web_metadata(client_dir, metadata)
863
1335
  manifest_path = File.join(client_dir, "web", "manifest.json")
864
1336
  if File.file?(manifest_path)
865
- data = JSON.parse(File.read(manifest_path))
1337
+ data = JSON.parse(read_text_file(manifest_path))
866
1338
  data["name"] = metadata[:display_name]
867
1339
  data["short_name"] = metadata[:short_name]
868
1340
  data["description"] = metadata[:description]
869
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
1341
+ write_text_file(manifest_path, JSON.pretty_generate(data) + "\n")
870
1342
  end
871
1343
 
872
1344
  index_path = File.join(client_dir, "web", "index.html")
@@ -931,23 +1403,34 @@ module Ruflet
931
1403
  replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
932
1404
  end
933
1405
 
1406
+ # Native project files are UTF-8 and the values written into them are too
1407
+ # (the macOS copyright line carries a ©). Reading them with the default
1408
+ # external encoding fails outright under a non-UTF-8 locale, so pin it.
1409
+ def read_text_file(path)
1410
+ File.read(path, encoding: Encoding::UTF_8)
1411
+ end
1412
+
1413
+ def write_text_file(path, content)
1414
+ File.write(path, content, encoding: Encoding::UTF_8)
1415
+ end
1416
+
934
1417
  def replace_plist_value(path, key, value)
935
1418
  return unless File.file?(path)
936
1419
 
937
- content = File.read(path)
1420
+ content = read_text_file(path)
938
1421
  pattern = %r{(<key>#{Regexp.escape(key)}</key>\s*<string>)(.*?)(</string>)}m
939
1422
  updated = content.gsub(pattern) do
940
1423
  "#{Regexp.last_match(1)}#{xml_escape(value)}#{Regexp.last_match(3)}"
941
1424
  end
942
- File.write(path, updated) unless updated == content
1425
+ write_text_file(path, updated) unless updated == content
943
1426
  end
944
1427
 
945
1428
  def replace_in_file(path, pattern, replacement)
946
1429
  return unless File.file?(path)
947
1430
 
948
- content = File.read(path)
1431
+ content = read_text_file(path)
949
1432
  updated = content.gsub(pattern) { replacement }
950
- File.write(path, updated) unless updated == content
1433
+ write_text_file(path, updated) unless updated == content
951
1434
  end
952
1435
 
953
1436
  def first_present(*values)
@@ -1055,6 +1538,13 @@ module Ruflet
1055
1538
 
1056
1539
  def apply_native_service_permissions(client_dir, config)
1057
1540
  entries = configured_service_entries(config)
1541
+ configured_extensions = Array(config["extensions"]).filter_map { |entry| normalize_extension_key(entry) }
1542
+ extension_services = configured_extensions.flat_map do |extension|
1543
+ EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
1544
+ end
1545
+ extension_services.each do |service|
1546
+ entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
1547
+ end
1058
1548
  return if entries.empty?
1059
1549
 
1060
1550
  apply_android_service_permissions(client_dir, entries)
@@ -1065,20 +1555,20 @@ module Ruflet
1065
1555
  path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
1066
1556
  return unless File.file?(path)
1067
1557
 
1068
- content = File.read(path)
1558
+ content = read_text_file(path)
1069
1559
  entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
1070
1560
  next if content.include?(%(android:name="#{permission}"))
1071
1561
 
1072
1562
  content.sub!(/<manifest\b[^>]*>\s*/, "\\0 <uses-permission android:name=\"#{permission}\"/>\n")
1073
1563
  end
1074
- File.write(path, content)
1564
+ write_text_file(path, content)
1075
1565
  end
1076
1566
 
1077
1567
  def apply_ios_service_usage_descriptions(client_dir, entries)
1078
1568
  path = File.join(client_dir, "ios", "Runner", "Info.plist")
1079
1569
  return unless File.file?(path)
1080
1570
 
1081
- content = File.read(path)
1571
+ content = read_text_file(path)
1082
1572
  entries.each do |entry|
1083
1573
  key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
1084
1574
  next unless key
@@ -1094,7 +1584,7 @@ module Ruflet
1094
1584
  content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
1095
1585
  end
1096
1586
  end
1097
- File.write(path, content)
1587
+ write_text_file(path, content)
1098
1588
  end
1099
1589
 
1100
1590
  def clear_flutter_build_state(client_dir, verbose: false)
@@ -1145,11 +1635,11 @@ module Ruflet
1145
1635
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1146
1636
  return unless File.file?(pubspec_path)
1147
1637
 
1148
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1638
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1149
1639
  dependencies = data["dependencies"]
1150
1640
  dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
1151
- spinkit_dependency = template_client_pubspec_dependencies["flutter_spinkit"]
1152
- dependencies["flutter_spinkit"] = spinkit_dependency if spinkit_dependency
1641
+ spinkit_dependency = template_client_pubspec_dependencies["flet_spinkit"]
1642
+ dependencies["flet_spinkit"] = spinkit_dependency if spinkit_dependency
1153
1643
  flutter = data["flutter"]
1154
1644
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1155
1645
  assets = Array(flutter["assets"]).map(&:to_s)
@@ -1173,11 +1663,16 @@ module Ruflet
1173
1663
  write_pubspec_yaml(pubspec_path, data)
1174
1664
  end
1175
1665
 
1666
+ # A self-contained build needs the embedded VM. Prefer a local checkout so
1667
+ # the runtime under development is the one packaged, and otherwise resolve
1668
+ # the published package.
1669
+ PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.9"
1670
+
1176
1671
  def ruby_runtime_dependency(current_dependency = nil)
1177
1672
  local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
1178
1673
  return { "path" => local_path } if local_path
1179
1674
 
1180
- current_dependency || "^0.0.3"
1675
+ current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
1181
1676
  end
1182
1677
 
1183
1678
  def explicit_local_ruby_runtime_path
@@ -1209,11 +1704,11 @@ module Ruflet
1209
1704
  "lib/main.self.dart",
1210
1705
  "lib/main.server.dart",
1211
1706
  "lib/ruflet_file_picker_service.dart",
1212
- "lib/ruflet_spinkit.dart",
1213
1707
  "lib/connection_probe.dart",
1214
1708
  "lib/connection_probe_io.dart",
1215
1709
  "lib/connection_probe_stub.dart",
1216
- "ios/Podfile"
1710
+ "ios/Podfile",
1711
+ "windows/CMakeLists.txt"
1217
1712
  ]
1218
1713
 
1219
1714
  managed_files.each do |relative_path|
@@ -1233,7 +1728,7 @@ module Ruflet
1233
1728
 
1234
1729
  content = indent_pubspec_sequences(content)
1235
1730
 
1236
- File.write(path, content)
1731
+ write_text_file(path, content)
1237
1732
  end
1238
1733
 
1239
1734
  def indent_pubspec_sequences(content)
@@ -1335,6 +1830,8 @@ module Ruflet
1335
1830
  .vscode
1336
1831
  build
1337
1832
  coverage
1833
+ credentials
1834
+ fastlane
1338
1835
  log
1339
1836
  node_modules
1340
1837
  pkg
@@ -1347,10 +1844,30 @@ module Ruflet
1347
1844
  end
1348
1845
  end
1349
1846
 
1847
+ # Anything embedded here is readable by anyone who unpacks the shipped
1848
+ # app, so signing keys and local environment files must never be copied
1849
+ # in even when a project keeps them beside its source.
1850
+ SECRET_ASSET_EXTENSIONS = %w[.p8 .p12 .pem .key .jks .keystore .mobileprovision].freeze
1851
+ SECRET_ASSET_BASENAMES = %w[.env .netrc].freeze
1852
+
1853
+ def secret_project_asset?(relative)
1854
+ basename = File.basename(relative)
1855
+ return true if SECRET_ASSET_BASENAMES.include?(basename)
1856
+ return true if basename.start_with?(".env.") && basename != ".env.example"
1857
+ return true if SECRET_ASSET_EXTENSIONS.include?(File.extname(basename).downcase)
1858
+ return true if basename.match?(/\Agoogle-play.*\.json\z/i)
1859
+
1860
+ false
1861
+ end
1862
+
1350
1863
  def include_project_asset_file?(relative)
1351
1864
  basename = File.basename(relative)
1352
1865
  return false if basename == ".DS_Store"
1353
1866
  return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename)
1867
+ if secret_project_asset?(relative)
1868
+ build_note("Excluded #{relative} from the embedded project; it looks like a credential")
1869
+ return false
1870
+ end
1354
1871
  true
1355
1872
  end
1356
1873
 
@@ -1381,17 +1898,18 @@ module Ruflet
1381
1898
 
1382
1899
  key.tr!("-", "_")
1383
1900
  key.gsub!(/\A(flet_)+/, "")
1901
+ key.gsub!(/\A(ruflet_)+/, "")
1384
1902
  key.gsub!(/\Aservice_/, "")
1385
1903
  key
1386
1904
  end
1387
1905
 
1388
1906
  def prune_client_pubspec(path, selected_packages)
1389
- data = YAML.safe_load(File.read(path), aliases: true) || {}
1907
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1390
1908
  deps = (data["dependencies"] || {}).dup
1909
+ optional_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1391
1910
 
1392
1911
  deps.keys.each do |name|
1393
- next unless name.start_with?("flet_")
1394
- next if name == "flet"
1912
+ next unless optional_packages.include?(name)
1395
1913
  next if selected_packages.include?(name)
1396
1914
 
1397
1915
  deps.delete(name)
@@ -1407,7 +1925,7 @@ module Ruflet
1407
1925
  template_deps = template_client_pubspec_dependencies
1408
1926
  return if template_deps.empty?
1409
1927
 
1410
- data = YAML.safe_load(File.read(path), aliases: true) || {}
1928
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1411
1929
  deps = (data["dependencies"] || {}).dup
1412
1930
  selected_packages.each do |package_name|
1413
1931
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
@@ -1427,7 +1945,7 @@ module Ruflet
1427
1945
  pubspec_path = File.join(template_root, "pubspec.yaml")
1428
1946
  return {} unless File.file?(pubspec_path)
1429
1947
 
1430
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1948
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1431
1949
  deps = data["dependencies"]
1432
1950
  deps.is_a?(Hash) ? deps : {}
1433
1951
  rescue StandardError
@@ -1440,18 +1958,20 @@ module Ruflet
1440
1958
  template_path = template_client_entrypoint_path(File.basename(path))
1441
1959
  return unless template_path
1442
1960
 
1443
- content = File.read(path)
1444
- template = File.read(template_path)
1961
+ content = read_text_file(path)
1962
+ template = read_text_file(template_path)
1445
1963
 
1446
1964
  selected_aliases.each do |extension_alias|
1447
1965
  import_line = template.lines.find { |line| line.match?(/\sas #{Regexp.escape(extension_alias)};\s*\z/) }
1448
- extension_line = template.lines.find { |line| line.match?(/^\s*#{Regexp.escape(extension_alias)}\.Extension\(\),\s*$/) }
1966
+ extension_line = template.lines.find do |line|
1967
+ line.match?(/^\s*#{Regexp.escape(extension_alias)}\.[A-Za-z0-9_]+\(\),\s*$/)
1968
+ end
1449
1969
 
1450
1970
  content = insert_missing_import(content, import_line) if import_line && !content.include?(import_line)
1451
1971
  content = insert_missing_extension(content, extension_line) if extension_line && !content.include?(extension_line)
1452
1972
  end
1453
1973
 
1454
- File.write(path, content)
1974
+ write_text_file(path, content)
1455
1975
  end
1456
1976
 
1457
1977
  def template_client_entrypoint_path(name)
@@ -1489,38 +2009,97 @@ module Ruflet
1489
2009
  end
1490
2010
 
1491
2011
  def prune_client_main(path, selected_aliases)
1492
- content = File.read(path)
2012
+ content = read_text_file(path)
1493
2013
  alias_to_package = {}
2014
+ optional_aliases = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:alias) }.uniq
1494
2015
 
1495
- content.scan(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias|
2016
+ content.scan(%r{^import 'package:([^/]+)/[^']+'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias|
1496
2017
  alias_to_package[import_alias] = package_name
1497
2018
  end
2019
+ content.scan(%r{^import '([^']+)'\s+as ([a-zA-Z0-9_]+);$}m) do |_source, import_alias|
2020
+ alias_to_package[import_alias] = :local if optional_aliases.include?(import_alias)
2021
+ end
1498
2022
 
1499
- content = content.gsub(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
2023
+ content = content.gsub(%r{^import 'package:([^/]+)/[^']+'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
1500
2024
  package_name = Regexp.last_match(1)
1501
2025
  import_alias = Regexp.last_match(2)
1502
- if package_name == "flet" || selected_aliases.include?(import_alias)
2026
+ if !optional_aliases.include?(import_alias) || selected_aliases.include?(import_alias)
2027
+ match
2028
+ else
2029
+ ""
2030
+ end
2031
+ end
2032
+ content = content.gsub(%r{^import '([^']+)'\s+as ([a-zA-Z0-9_]+);\n}m) do |match|
2033
+ import_alias = Regexp.last_match(2)
2034
+ if !optional_aliases.include?(import_alias) || selected_aliases.include?(import_alias)
1503
2035
  match
1504
2036
  else
1505
2037
  ""
1506
2038
  end
1507
2039
  end
1508
2040
 
1509
- content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.Extension\(\),\s*$/) do |match|
2041
+ content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.[A-Za-z0-9_]+\(\),\s*$/) do |match|
1510
2042
  extension_alias = Regexp.last_match(2)
1511
2043
  package_name = alias_to_package[extension_alias]
1512
- if package_name.nil? || selected_aliases.include?(extension_alias)
2044
+ if package_name.nil? || !optional_aliases.include?(extension_alias) || selected_aliases.include?(extension_alias)
1513
2045
  match
1514
2046
  else
1515
2047
  ""
1516
2048
  end
1517
2049
  end
1518
2050
 
1519
- File.write(path, content)
2051
+ write_text_file(path, content)
2052
+ end
2053
+
2054
+ # update_pubspec_value only rewrites blocks that already exist. Create the
2055
+ # block first so a template without it still receives the configured values.
2056
+ def ensure_pubspec_block(path, block)
2057
+ return unless File.file?(path)
2058
+
2059
+ content = read_text_file(path)
2060
+ return if content.lines.any? { |line| line.start_with?("#{block}:") }
2061
+
2062
+ content += "\n" unless content.empty? || content.end_with?("\n")
2063
+ write_text_file(path, "#{content}#{block}:\n")
2064
+ end
2065
+
2066
+ # Writes `block: -> section: -> key: value`, creating the block and the
2067
+ # nested section when they are missing (for example flutter_native_splash's
2068
+ # android_12 section).
2069
+ def update_pubspec_nested_value(path, block, section, key, value)
2070
+ return unless File.file?(path)
2071
+
2072
+ ensure_pubspec_block(path, block)
2073
+ lines = read_text_file(path).split("\n", -1)
2074
+ block_start = lines.index { |line| line.start_with?("#{block}:") }
2075
+ return unless block_start
2076
+
2077
+ block_end = block_start + 1
2078
+ block_end += 1 while block_end < lines.length && (lines[block_end].strip.empty? || lines[block_end].start_with?(" ", "\t"))
2079
+ block_end -= 1 while block_end > block_start + 1 && lines[block_end - 1].strip.empty?
2080
+
2081
+ section_index = (block_start + 1...block_end).find do |index|
2082
+ lines[index] =~ /\A\s{2}#{Regexp.escape(section)}:\s*(#.*)?\z/
2083
+ end
2084
+
2085
+ if section_index.nil?
2086
+ lines.insert(block_end, " #{section}:", " #{key}: #{value}")
2087
+ else
2088
+ section_end = section_index + 1
2089
+ section_end += 1 while section_end < block_end && lines[section_end] =~ /\A\s{3,}\S/
2090
+ existing = (section_index + 1...section_end).find { |index| lines[index].strip.start_with?("#{key}:") }
2091
+ if existing
2092
+ lines[existing] = "#{lines[existing][/\A\s*/]}#{key}: #{value}"
2093
+ else
2094
+ lines.insert(section_end, " #{key}: #{value}")
2095
+ end
2096
+ end
2097
+
2098
+ write_text_file(path, indent_pubspec_sequences(lines.join("\n")))
1520
2099
  end
1521
2100
 
1522
2101
  def update_pubspec_value(path, block, key, value, multiple: false)
1523
- lines = File.read(path).split("\n", -1)
2102
+ lines = read_text_file(path).split("\n", -1)
1524
2103
  out = []
1525
2104
  in_block = false
1526
2105
  replaced = false
@@ -1555,7 +2134,7 @@ module Ruflet
1555
2134
  if in_block && !replaced
1556
2135
  out << "#{block_indent}#{key}: #{value}"
1557
2136
  end
1558
- File.write(path, indent_pubspec_sequences(out.join("\n")))
2137
+ write_text_file(path, indent_pubspec_sequences(out.join("\n")))
1559
2138
  end
1560
2139
 
1561
2140
  def flutter_build_command(platform)