ruflet 0.0.19 → 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.
@@ -26,6 +26,7 @@ module Ruflet
26
26
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
27
27
  "map" => { package: "flet_map", alias: "ruflet_map" },
28
28
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
29
+ "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
29
30
  "rive" => { package: "flet_rive", alias: "ruflet_rive" },
30
31
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
31
32
  "video" => { package: "flet_video", alias: "ruflet_video" },
@@ -37,6 +38,9 @@ module Ruflet
37
38
  "location" => %w[geolocator permission_handler],
38
39
  "motion" => %w[permission_handler]
39
40
  }.freeze
41
+ EXTENSION_REQUIRED_SERVICES = {
42
+ "qrcode_scanner" => %w[camera]
43
+ }.freeze
40
44
  ANDROID_SERVICE_PERMISSIONS = {
41
45
  "camera" => %w[android.permission.CAMERA],
42
46
  "microphone" => %w[android.permission.RECORD_AUDIO],
@@ -49,6 +53,20 @@ module Ruflet
49
53
  "location" => "NSLocationWhenInUseUsageDescription",
50
54
  "motion" => "NSMotionUsageDescription"
51
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
52
70
 
53
71
  def command_build(args)
54
72
  self_contained = args.delete("--self")
@@ -65,6 +83,15 @@ module Ruflet
65
83
  return 1
66
84
  end
67
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
+
68
95
  ensure_ruflet_build_assets(verbose: !!verbose)
69
96
  client_dir = ensure_flutter_client_dir(verbose: !!verbose)
70
97
  unless client_dir
@@ -98,15 +125,21 @@ module Ruflet
98
125
  # deterministically instead of inferring from a single main.rb — the
99
126
  # app tree now ships many main.rb files (standalone_apps/*/main.rb).
100
127
  build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
101
- else
102
- unless backend_url
103
- warn "build config error: backend_url is required for server-driven builds"
104
- warn "Set app.backend_url or backend_url in ruflet.yaml"
105
- return 1
106
- end
128
+ elsif backend_url
107
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
108
140
  end
109
141
  build_args << "-v" if verbose
142
+ stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
110
143
 
111
144
  build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
112
145
  build_log(verbose, "client_dir=#{client_dir}")
@@ -400,7 +433,9 @@ module Ruflet
400
433
 
401
434
  def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
402
435
  refresh_managed_client_template_files(client_dir, verbose: verbose)
403
- 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
+
404
439
  apply_native_service_permissions(client_dir, config)
405
440
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
406
441
  return false if configured == false
@@ -424,6 +459,10 @@ module Ruflet
424
459
  return false
425
460
  end
426
461
 
462
+ unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
463
+ return false
464
+ end
465
+
427
466
  unless ensure_native_build_dependencies(client_dir, platform, tools[:env], verbose: verbose)
428
467
  return false
429
468
  end
@@ -446,9 +485,60 @@ module Ruflet
446
485
  end
447
486
  end
448
487
 
488
+ verify_android_generated_assets(client_dir, asset_flags, platform, verbose: verbose)
489
+
449
490
  true
450
491
  end
451
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
+
452
542
  def ensure_flutter_platform_artifacts(client_dir, platform, env, flutter, verbose: false)
453
543
  precache_flags = flutter_precache_flags(platform)
454
544
  return true if precache_flags.empty?
@@ -495,6 +585,50 @@ module Ruflet
495
585
  end
496
586
  end
497
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
+
498
632
  def ensure_cocoapods_install(client_dir, platform_dir, env, verbose: false)
499
633
  pod_dir = File.join(client_dir, platform_dir)
500
634
  return true unless Dir.exist?(pod_dir)
@@ -549,7 +683,7 @@ module Ruflet
549
683
  shim_dir = File.join(client_dir, ".ruflet", "bin")
550
684
  FileUtils.mkdir_p(shim_dir)
551
685
  shim_path = File.join(shim_dir, "pod")
552
- File.write(
686
+ write_text_file(
553
687
  shim_path,
554
688
  <<~SH
555
689
  #!/bin/sh
@@ -599,12 +733,16 @@ module Ruflet
599
733
  alt = "ruflet.yml"
600
734
  config_path = alt if File.file?(alt)
601
735
  end
602
- return {} unless File.file?(config_path)
603
-
604
- config = YAML.safe_load(File.read(config_path), aliases: true) || {}
605
- 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")
606
740
  if File.file?(services_path)
607
- 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
608
746
  config["services"] = service_config["services"] if service_config.key?("services")
609
747
  end
610
748
  config
@@ -640,16 +778,93 @@ module Ruflet
640
778
  splash = resolve_asset.call(build["splash_screen"] || assets["splash_screen"] || config["splash_screen"])
641
779
  splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
642
780
  icon = resolve_asset.call(build["icon_launcher"] || assets["icon_launcher"] || config["icon_launcher"])
643
- icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"])
644
- icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"])
645
- icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"])
646
- icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"])
647
- icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"])
648
781
 
649
- splash_color = build["splash_color"]
650
- splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"]
651
- icon_background = build["icon_background"]
652
- 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"]
653
868
 
654
869
  assets_dir = File.join(client_dir, "assets")
655
870
  FileUtils.mkdir_p(assets_dir)
@@ -662,14 +877,43 @@ module Ruflet
662
877
  copy_asset.call(splash, "splash.png")
663
878
  copy_asset.call(splash_dark, "splash_dark.png")
664
879
  copy_asset.call(icon, "icon.png")
665
- copy_asset.call(icon_android, "icon_android.png")
666
- copy_asset.call(icon_ios, "icon_ios.png")
667
- copy_asset.call(icon_web, "icon_web.png")
668
- if icon_windows
669
- ext = File.extname(icon_windows).downcase
670
- 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
671
910
  end
672
- 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")
673
917
 
674
918
  default_splash = File.file?(File.join(assets_dir, "splash.png"))
675
919
  default_icon = File.file?(File.join(assets_dir, "icon.png"))
@@ -694,43 +938,235 @@ module Ruflet
694
938
  end
695
939
  end
696
940
 
697
- has_splash = !splash.nil? || default_splash
698
- 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)
699
964
 
700
965
  pubspec_path = File.join(client_dir, "pubspec.yaml")
701
966
  unless File.file?(pubspec_path)
702
967
  return { has_icon: has_icon, has_splash: has_splash, error: nil }
703
968
  end
704
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
+
705
973
  if has_icon
706
- 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
707
1009
  end
708
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
709
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true) if icon_ios
710
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_web", "\"assets/icon_web.png\"", multiple: true) if icon_web
711
- if icon_windows
712
- ext = File.extname(icon_windows).downcase
713
- value = ext == ".ico" ? "\"assets/icon_windows.ico\"" : "\"assets/icon_windows.png\""
714
- 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
715
1045
  end
716
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_macos", "\"assets/icon_macos.png\"", multiple: true) if icon_macos
717
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
718
- 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
719
1048
 
720
- 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
721
1050
  update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
722
1051
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
723
1052
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
724
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
+
725
1135
  {
726
1136
  has_icon: has_icon,
727
1137
  has_splash: has_splash,
728
1138
  using_default_icon: using_default_icon,
729
1139
  using_default_splash: using_default_splash,
1140
+ android_adaptive_icon: has_icon,
1141
+ android_12_splash: has_splash,
730
1142
  error: nil
731
1143
  }
732
1144
  end
733
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
+
734
1170
  def sync_client_metadata(client_dir, config = {}, verbose: false)
735
1171
  metadata = build_client_metadata(config, client_dir)
736
1172
  apply_pubspec_metadata(client_dir, metadata)
@@ -744,21 +1180,30 @@ module Ruflet
744
1180
  verbose,
745
1181
  "app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}"
746
1182
  )
1183
+ metadata
747
1184
  end
748
1185
 
749
1186
  def build_client_metadata(config, client_dir)
750
1187
  app = config["app"].is_a?(Hash) ? config["app"] : {}
751
1188
  current_pubspec = load_client_pubspec(client_dir)
752
1189
  current_name = current_pubspec["name"].to_s
753
- inferred_display_name = app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd))
754
- package_name = normalize_package_name(app["package_name"] || config["package_name"] || current_name || inferred_display_name)
755
- 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))
756
1196
  organization = normalize_bundle_prefix(
757
1197
  first_present(app["org"], app["organization"], config["org"], config["organization"], "com.example")
758
1198
  )
759
1199
  bundle_identifier = normalize_bundle_identifier(
760
1200
  first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
761
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"
762
1207
 
763
1208
  {
764
1209
  package_name: package_name,
@@ -780,15 +1225,28 @@ module Ruflet
780
1225
  linux_application_id: normalize_bundle_identifier(
781
1226
  first_present(app["linux_application_id"], config["linux_application_id"], bundle_identifier)
782
1227
  ),
783
- 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
784
1230
  }
785
1231
  end
786
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
+
787
1245
  def load_client_pubspec(client_dir)
788
1246
  pubspec_path = File.join(client_dir, "pubspec.yaml")
789
1247
  return {} unless File.file?(pubspec_path)
790
1248
 
791
- YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1249
+ YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
792
1250
  rescue StandardError
793
1251
  {}
794
1252
  end
@@ -797,7 +1255,7 @@ module Ruflet
797
1255
  pubspec_path = File.join(client_dir, "pubspec.yaml")
798
1256
  return unless File.file?(pubspec_path)
799
1257
 
800
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1258
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
801
1259
  data["name"] = metadata[:package_name]
802
1260
  data["description"] = metadata[:description]
803
1261
  data["version"] = metadata[:version]
@@ -805,28 +1263,6 @@ module Ruflet
805
1263
  end
806
1264
 
807
1265
  def apply_android_metadata(client_dir, metadata)
808
- gradle_path = File.join(client_dir, "android", "app", "build.gradle.kts")
809
- replace_in_file(
810
- gradle_path,
811
- /^\s*namespace = ".*"$/,
812
- %( namespace = "#{metadata[:android_application_id]}")
813
- )
814
- replace_in_file(
815
- gradle_path,
816
- /^\s*applicationId = ".*"$/,
817
- %( applicationId = "#{metadata[:android_application_id]}")
818
- )
819
-
820
- Dir.glob(
821
- File.join(client_dir, "android", "app", "src", "main", "kotlin", "**", "MainActivity.kt")
822
- ).each do |activity_path|
823
- replace_in_file(
824
- activity_path,
825
- /^package\s+[^\s]+$/,
826
- "package #{metadata[:android_application_id]}"
827
- )
828
- end
829
-
830
1266
  manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
831
1267
  replace_in_file(
832
1268
  manifest_path,
@@ -843,17 +1279,37 @@ module Ruflet
843
1279
  pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
844
1280
  return unless File.file?(pbxproj_path)
845
1281
 
846
- content = File.read(pbxproj_path)
1282
+ content = read_text_file(pbxproj_path)
847
1283
  content.gsub!(/INFOPLIST_KEY_CFBundleDisplayName = "[^"]*";/, %(INFOPLIST_KEY_CFBundleDisplayName = "#{xcode_escape(metadata[:display_name])}";))
848
- content.gsub!(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/) do |match|
849
- identifier = Regexp.last_match(1).to_s.strip
850
- if identifier.include?("RunnerTests")
851
- match
852
- else
853
- "PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:ios_bundle_identifier]};"
854
- end
855
- end
856
- 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
857
1313
  end
858
1314
 
859
1315
  def apply_macos_metadata(client_dir, metadata)
@@ -878,11 +1334,11 @@ module Ruflet
878
1334
  def apply_web_metadata(client_dir, metadata)
879
1335
  manifest_path = File.join(client_dir, "web", "manifest.json")
880
1336
  if File.file?(manifest_path)
881
- data = JSON.parse(File.read(manifest_path))
1337
+ data = JSON.parse(read_text_file(manifest_path))
882
1338
  data["name"] = metadata[:display_name]
883
1339
  data["short_name"] = metadata[:short_name]
884
1340
  data["description"] = metadata[:description]
885
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
1341
+ write_text_file(manifest_path, JSON.pretty_generate(data) + "\n")
886
1342
  end
887
1343
 
888
1344
  index_path = File.join(client_dir, "web", "index.html")
@@ -947,23 +1403,34 @@ module Ruflet
947
1403
  replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
948
1404
  end
949
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
+
950
1417
  def replace_plist_value(path, key, value)
951
1418
  return unless File.file?(path)
952
1419
 
953
- content = File.read(path)
1420
+ content = read_text_file(path)
954
1421
  pattern = %r{(<key>#{Regexp.escape(key)}</key>\s*<string>)(.*?)(</string>)}m
955
1422
  updated = content.gsub(pattern) do
956
1423
  "#{Regexp.last_match(1)}#{xml_escape(value)}#{Regexp.last_match(3)}"
957
1424
  end
958
- File.write(path, updated) unless updated == content
1425
+ write_text_file(path, updated) unless updated == content
959
1426
  end
960
1427
 
961
1428
  def replace_in_file(path, pattern, replacement)
962
1429
  return unless File.file?(path)
963
1430
 
964
- content = File.read(path)
1431
+ content = read_text_file(path)
965
1432
  updated = content.gsub(pattern) { replacement }
966
- File.write(path, updated) unless updated == content
1433
+ write_text_file(path, updated) unless updated == content
967
1434
  end
968
1435
 
969
1436
  def first_present(*values)
@@ -1071,6 +1538,13 @@ module Ruflet
1071
1538
 
1072
1539
  def apply_native_service_permissions(client_dir, config)
1073
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
1074
1548
  return if entries.empty?
1075
1549
 
1076
1550
  apply_android_service_permissions(client_dir, entries)
@@ -1081,20 +1555,20 @@ module Ruflet
1081
1555
  path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
1082
1556
  return unless File.file?(path)
1083
1557
 
1084
- content = File.read(path)
1558
+ content = read_text_file(path)
1085
1559
  entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
1086
1560
  next if content.include?(%(android:name="#{permission}"))
1087
1561
 
1088
1562
  content.sub!(/<manifest\b[^>]*>\s*/, "\\0 <uses-permission android:name=\"#{permission}\"/>\n")
1089
1563
  end
1090
- File.write(path, content)
1564
+ write_text_file(path, content)
1091
1565
  end
1092
1566
 
1093
1567
  def apply_ios_service_usage_descriptions(client_dir, entries)
1094
1568
  path = File.join(client_dir, "ios", "Runner", "Info.plist")
1095
1569
  return unless File.file?(path)
1096
1570
 
1097
- content = File.read(path)
1571
+ content = read_text_file(path)
1098
1572
  entries.each do |entry|
1099
1573
  key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
1100
1574
  next unless key
@@ -1110,7 +1584,7 @@ module Ruflet
1110
1584
  content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
1111
1585
  end
1112
1586
  end
1113
- File.write(path, content)
1587
+ write_text_file(path, content)
1114
1588
  end
1115
1589
 
1116
1590
  def clear_flutter_build_state(client_dir, verbose: false)
@@ -1161,11 +1635,11 @@ module Ruflet
1161
1635
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1162
1636
  return unless File.file?(pubspec_path)
1163
1637
 
1164
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1638
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1165
1639
  dependencies = data["dependencies"]
1166
1640
  dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
1167
- spinkit_dependency = template_client_pubspec_dependencies["flutter_spinkit"]
1168
- 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
1169
1643
  flutter = data["flutter"]
1170
1644
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1171
1645
  assets = Array(flutter["assets"]).map(&:to_s)
@@ -1189,11 +1663,16 @@ module Ruflet
1189
1663
  write_pubspec_yaml(pubspec_path, data)
1190
1664
  end
1191
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
+
1192
1671
  def ruby_runtime_dependency(current_dependency = nil)
1193
1672
  local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
1194
1673
  return { "path" => local_path } if local_path
1195
1674
 
1196
- current_dependency || "^0.0.3"
1675
+ current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
1197
1676
  end
1198
1677
 
1199
1678
  def explicit_local_ruby_runtime_path
@@ -1225,7 +1704,6 @@ module Ruflet
1225
1704
  "lib/main.self.dart",
1226
1705
  "lib/main.server.dart",
1227
1706
  "lib/ruflet_file_picker_service.dart",
1228
- "lib/ruflet_spinkit.dart",
1229
1707
  "lib/connection_probe.dart",
1230
1708
  "lib/connection_probe_io.dart",
1231
1709
  "lib/connection_probe_stub.dart",
@@ -1250,7 +1728,7 @@ module Ruflet
1250
1728
 
1251
1729
  content = indent_pubspec_sequences(content)
1252
1730
 
1253
- File.write(path, content)
1731
+ write_text_file(path, content)
1254
1732
  end
1255
1733
 
1256
1734
  def indent_pubspec_sequences(content)
@@ -1352,6 +1830,8 @@ module Ruflet
1352
1830
  .vscode
1353
1831
  build
1354
1832
  coverage
1833
+ credentials
1834
+ fastlane
1355
1835
  log
1356
1836
  node_modules
1357
1837
  pkg
@@ -1364,10 +1844,30 @@ module Ruflet
1364
1844
  end
1365
1845
  end
1366
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
+
1367
1863
  def include_project_asset_file?(relative)
1368
1864
  basename = File.basename(relative)
1369
1865
  return false if basename == ".DS_Store"
1370
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
1371
1871
  true
1372
1872
  end
1373
1873
 
@@ -1398,12 +1898,13 @@ module Ruflet
1398
1898
 
1399
1899
  key.tr!("-", "_")
1400
1900
  key.gsub!(/\A(flet_)+/, "")
1901
+ key.gsub!(/\A(ruflet_)+/, "")
1401
1902
  key.gsub!(/\Aservice_/, "")
1402
1903
  key
1403
1904
  end
1404
1905
 
1405
1906
  def prune_client_pubspec(path, selected_packages)
1406
- data = YAML.safe_load(File.read(path), aliases: true) || {}
1907
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1407
1908
  deps = (data["dependencies"] || {}).dup
1408
1909
  optional_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1409
1910
 
@@ -1424,7 +1925,7 @@ module Ruflet
1424
1925
  template_deps = template_client_pubspec_dependencies
1425
1926
  return if template_deps.empty?
1426
1927
 
1427
- data = YAML.safe_load(File.read(path), aliases: true) || {}
1928
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1428
1929
  deps = (data["dependencies"] || {}).dup
1429
1930
  selected_packages.each do |package_name|
1430
1931
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
@@ -1444,7 +1945,7 @@ module Ruflet
1444
1945
  pubspec_path = File.join(template_root, "pubspec.yaml")
1445
1946
  return {} unless File.file?(pubspec_path)
1446
1947
 
1447
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1948
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1448
1949
  deps = data["dependencies"]
1449
1950
  deps.is_a?(Hash) ? deps : {}
1450
1951
  rescue StandardError
@@ -1457,8 +1958,8 @@ module Ruflet
1457
1958
  template_path = template_client_entrypoint_path(File.basename(path))
1458
1959
  return unless template_path
1459
1960
 
1460
- content = File.read(path)
1461
- template = File.read(template_path)
1961
+ content = read_text_file(path)
1962
+ template = read_text_file(template_path)
1462
1963
 
1463
1964
  selected_aliases.each do |extension_alias|
1464
1965
  import_line = template.lines.find { |line| line.match?(/\sas #{Regexp.escape(extension_alias)};\s*\z/) }
@@ -1470,7 +1971,7 @@ module Ruflet
1470
1971
  content = insert_missing_extension(content, extension_line) if extension_line && !content.include?(extension_line)
1471
1972
  end
1472
1973
 
1473
- File.write(path, content)
1974
+ write_text_file(path, content)
1474
1975
  end
1475
1976
 
1476
1977
  def template_client_entrypoint_path(name)
@@ -1508,7 +2009,7 @@ module Ruflet
1508
2009
  end
1509
2010
 
1510
2011
  def prune_client_main(path, selected_aliases)
1511
- content = File.read(path)
2012
+ content = read_text_file(path)
1512
2013
  alias_to_package = {}
1513
2014
  optional_aliases = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:alias) }.uniq
1514
2015
 
@@ -1547,11 +2048,58 @@ module Ruflet
1547
2048
  end
1548
2049
  end
1549
2050
 
1550
- 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")))
1551
2099
  end
1552
2100
 
1553
2101
  def update_pubspec_value(path, block, key, value, multiple: false)
1554
- lines = File.read(path).split("\n", -1)
2102
+ lines = read_text_file(path).split("\n", -1)
1555
2103
  out = []
1556
2104
  in_block = false
1557
2105
  replaced = false
@@ -1586,7 +2134,7 @@ module Ruflet
1586
2134
  if in_block && !replaced
1587
2135
  out << "#{block_indent}#{key}: #{value}"
1588
2136
  end
1589
- File.write(path, indent_pubspec_sequences(out.join("\n")))
2137
+ write_text_file(path, indent_pubspec_sequences(out.join("\n")))
1590
2138
  end
1591
2139
 
1592
2140
  def flutter_build_command(platform)