ruflet 0.0.19 → 0.0.21

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,13 +53,27 @@ 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")
55
73
  verbose = args.delete("--verbose") || args.delete("-v")
56
74
  platform = (args.shift || "").downcase
57
75
  if platform.empty?
58
- warn "Usage: ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]"
76
+ warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--self] [--verbose]"
59
77
  return 1
60
78
  end
61
79
 
@@ -65,6 +83,20 @@ module Ruflet
65
83
  return 1
66
84
  end
67
85
 
86
+ # `ipa` produces the uploadable archive; every other step — pods,
87
+ # signing, icons, package name — is the same as a plain iOS build.
88
+ requested_platform = platform
89
+ platform = "ios" if platform == "ipa"
90
+
91
+ # The embedded Ruby VM is a native plugin with no browser
92
+ # implementation, so a self-contained web build produces an app that
93
+ # cannot start. Say so rather than shipping one that hangs.
94
+ if self_contained && platform == "web"
95
+ warn "build config error: --self is not supported for web"
96
+ warn "A web client runs no embedded Ruby; build it with `ruflet build web`."
97
+ return 1
98
+ end
99
+
68
100
  ensure_ruflet_build_assets(verbose: !!verbose)
69
101
  client_dir = ensure_flutter_client_dir(verbose: !!verbose)
70
102
  unless client_dir
@@ -88,7 +120,7 @@ module Ruflet
88
120
  return 1 unless ok
89
121
 
90
122
  build_args = [*flutter_cmd, *args]
91
- build_args << "--codesign" if ios_device_build_needs_codesign_flag?(platform, build_args)
123
+ build_args << "--codesign" if ios_device_build_needs_codesign_flag?(requested_platform, build_args)
92
124
  target_entrypoint = flutter_target_entrypoint(client_dir, self_contained: !!self_contained)
93
125
  build_args += ["--target", target_entrypoint] if target_entrypoint
94
126
  backend_url = configured_backend_url(config)
@@ -98,15 +130,21 @@ module Ruflet
98
130
  # deterministically instead of inferring from a single main.rb — the
99
131
  # app tree now ships many main.rb files (standalone_apps/*/main.rb).
100
132
  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
133
+ elsif backend_url
107
134
  build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"]
135
+ elsif RUNTIME_RESOLVED_BACKEND_PLATFORMS.include?(platform)
136
+ # These clients learn their server at launch: a web client from the
137
+ # origin it is served from, a desktop client from the URL the launcher
138
+ # passes. Baking one in would pin them to a single host and port,
139
+ # which a preview client cannot use.
140
+ build_note("No backend_url configured; the #{platform} client will resolve its server at launch")
141
+ else
142
+ warn "build config error: backend_url is required for server-driven builds"
143
+ warn "Set app.backend_url or backend_url in ruflet.yaml"
144
+ return 1
108
145
  end
109
146
  build_args << "-v" if verbose
147
+ stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: !!verbose) if self_contained
110
148
 
111
149
  build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}")
112
150
  build_log(verbose, "client_dir=#{client_dir}")
@@ -400,8 +438,12 @@ module Ruflet
400
438
 
401
439
  def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false)
402
440
  refresh_managed_client_template_files(client_dir, verbose: verbose)
403
- sync_client_metadata(client_dir, config, verbose: verbose)
441
+ metadata = sync_client_metadata(client_dir, config, verbose: verbose)
442
+ return false unless validate_mobile_app_identity(metadata, platform: platform)
443
+
404
444
  apply_native_service_permissions(client_dir, config)
445
+ apply_android_signing_config(client_dir, platform, verbose: !!verbose)
446
+ apply_ios_signing_team(client_dir, config) if %w[ios ipa macos].include?(platform.to_s)
405
447
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
406
448
  return false if configured == false
407
449
  @ruflet_self_contained_build = self_contained
@@ -424,6 +466,10 @@ module Ruflet
424
466
  return false
425
467
  end
426
468
 
469
+ unless apply_mobile_package_name(client_dir, metadata, platform: platform, tools: tools, verbose: verbose)
470
+ return false
471
+ end
472
+
427
473
  unless ensure_native_build_dependencies(client_dir, platform, tools[:env], verbose: verbose)
428
474
  return false
429
475
  end
@@ -446,9 +492,60 @@ module Ruflet
446
492
  end
447
493
  end
448
494
 
495
+ verify_android_generated_assets(client_dir, asset_flags, platform, verbose: verbose)
496
+
449
497
  true
450
498
  end
451
499
 
500
+ # The generators can succeed while silently skipping Android output, which
501
+ # ships a build with the stock Flutter icon and splash. Confirm the native
502
+ # resources that ruflet.yaml asked for are actually on disk.
503
+ def verify_android_generated_assets(client_dir, asset_flags, platform, verbose: false)
504
+ return true unless %w[apk android aab appbundle].include?(platform.to_s)
505
+
506
+ res_dir = File.join(client_dir, "android", "app", "src", "main", "res")
507
+ return true unless File.directory?(res_dir)
508
+
509
+ ok = true
510
+
511
+ if asset_flags[:has_splash]
512
+ launch_background = File.join(res_dir, "drawable", "launch_background.xml")
513
+ if !File.file?(launch_background) || !read_text_file(launch_background).include?("splash")
514
+ warn "Android splash screen was not generated in res/drawable/launch_background.xml"
515
+ ok = false
516
+ end
517
+
518
+ styles_v31 = File.join(res_dir, "values-v31", "styles.xml")
519
+ if !File.file?(styles_v31) || !read_text_file(styles_v31).include?("windowSplashScreenBackground")
520
+ warn "Android 12+ splash screen is missing from res/values-v31/styles.xml; " \
521
+ "devices on Android 12 and newer will show the system default splash"
522
+ ok = false
523
+ else
524
+ build_log(verbose, "android 12+ splash present in values-v31/styles.xml")
525
+ end
526
+ end
527
+
528
+ if asset_flags[:has_icon]
529
+ adaptive_icon = File.join(res_dir, "mipmap-anydpi-v26", "launcher_icon.xml")
530
+ if File.file?(adaptive_icon)
531
+ build_log(verbose, "adaptive launcher icon present in mipmap-anydpi-v26")
532
+ else
533
+ warn "Android adaptive launcher icon was not generated in res/mipmap-anydpi-v26/; " \
534
+ "set android.adaptive_icon_foreground and android.adaptive_icon_background in ruflet.yaml"
535
+ ok = false
536
+ end
537
+
538
+ manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
539
+ if File.file?(manifest) && !read_text_file(manifest).include?("@mipmap/launcher_icon")
540
+ warn "AndroidManifest.xml does not reference @mipmap/launcher_icon; the configured launcher icon is unused"
541
+ ok = false
542
+ end
543
+ end
544
+
545
+ build_note("Android launcher icon and splash resources verified") if ok && (asset_flags[:has_icon] || asset_flags[:has_splash])
546
+ ok
547
+ end
548
+
452
549
  def ensure_flutter_platform_artifacts(client_dir, platform, env, flutter, verbose: false)
453
550
  precache_flags = flutter_precache_flags(platform)
454
551
  return true if precache_flags.empty?
@@ -495,6 +592,50 @@ module Ruflet
495
592
  end
496
593
  end
497
594
 
595
+ # CocoaPods can incorrectly treat the static-library XCFramework copy
596
+ # phase as up to date after Ruflet clears Debug-iphonesimulator. The
597
+ # Runner then links with -lruflet_vm while the selected simulator slice
598
+ # is absent. Stage that deterministic slice before Flutter invokes
599
+ # Xcode; CocoaPods may still copy over it normally.
600
+ def stage_ios_simulator_ruby_runtime(client_dir, build_args, verbose: false)
601
+ return true unless build_args.include?("ios")
602
+ return true unless build_args.include?("--simulator")
603
+
604
+ runtime_root = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
605
+ return true unless runtime_root
606
+
607
+ source = File.join(
608
+ runtime_root,
609
+ "ios",
610
+ "Frameworks",
611
+ "RufletVM.xcframework",
612
+ "ios-arm64_x86_64-simulator"
613
+ )
614
+ library = File.join(source, "libruflet_vm.a")
615
+ return true unless File.file?(library)
616
+
617
+ destination = File.join(
618
+ client_dir,
619
+ "build",
620
+ "ios",
621
+ "Debug-iphonesimulator",
622
+ "XCFrameworkIntermediates",
623
+ "ruby_runtime"
624
+ )
625
+ FileUtils.mkdir_p(destination)
626
+ FileUtils.cp(library, File.join(destination, "libruflet_vm.a"))
627
+
628
+ headers = File.join(source, "Headers")
629
+ if Dir.exist?(headers)
630
+ destination_headers = File.join(destination, "Headers")
631
+ FileUtils.rm_rf(destination_headers)
632
+ FileUtils.cp_r(headers, destination_headers)
633
+ end
634
+
635
+ build_log(verbose, "staged iOS simulator Ruflet VM at #{destination}")
636
+ true
637
+ end
638
+
498
639
  def ensure_cocoapods_install(client_dir, platform_dir, env, verbose: false)
499
640
  pod_dir = File.join(client_dir, platform_dir)
500
641
  return true unless Dir.exist?(pod_dir)
@@ -549,7 +690,7 @@ module Ruflet
549
690
  shim_dir = File.join(client_dir, ".ruflet", "bin")
550
691
  FileUtils.mkdir_p(shim_dir)
551
692
  shim_path = File.join(shim_dir, "pod")
552
- File.write(
693
+ write_text_file(
553
694
  shim_path,
554
695
  <<~SH
555
696
  #!/bin/sh
@@ -599,12 +740,19 @@ module Ruflet
599
740
  alt = "ruflet.yml"
600
741
  config_path = alt if File.file?(alt)
601
742
  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")
743
+ config_exists = File.file?(config_path)
744
+ config = config_exists ? YAML.safe_load(read_text_file(config_path), aliases: true) || {} : {}
745
+ config_dir = File.dirname(File.expand_path(config_path))
746
+ services_path = File.join(config_dir, "services.yaml")
606
747
  if File.file?(services_path)
607
- service_config = YAML.safe_load(File.read(services_path), aliases: true) || {}
748
+ service_config = YAML.safe_load(read_text_file(services_path), aliases: true) || {}
749
+ if service_config["app"].is_a?(Hash)
750
+ # ruflet.yaml declares the app; services.yaml may still carry an
751
+ # identity from older projects, so it fills gaps rather than
752
+ # overriding what the project states.
753
+ declared = config["app"].is_a?(Hash) ? config["app"] : {}
754
+ config["app"] = service_config["app"].merge(declared)
755
+ end
608
756
  config["services"] = service_config["services"] if service_config.key?("services")
609
757
  end
610
758
  config
@@ -640,16 +788,93 @@ module Ruflet
640
788
  splash = resolve_asset.call(build["splash_screen"] || assets["splash_screen"] || config["splash_screen"])
641
789
  splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
642
790
  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
791
 
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"]
792
+ # Splash and icon appearance belongs with the assets it styles. `build`
793
+ # is still read first so existing projects keep working.
794
+ splash_color = build["splash_color"] || assets["splash_color"]
795
+ splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"] ||
796
+ assets["splash_dark_color"] || assets["splash_color_dark"]
797
+ icon_background = build["icon_background"] || assets["icon_background"]
798
+ theme_color = build["theme_color"] || assets["theme_color"]
799
+
800
+ # Every platform gets its own section with the same key names, falling
801
+ # back to the shared assets/build values when a key is not overridden.
802
+ platforms = PLATFORM_ASSET_SUPPORT.keys.each_with_object({}) do |name, resolved|
803
+ section = platform_build_config(config, name)
804
+ resolved[name] = {
805
+ config: section,
806
+ splash: resolve_asset.call(
807
+ section["splash_screen"] || section["splash_image"] ||
808
+ build["splash_#{name}"] || assets["splash_#{name}"]
809
+ ),
810
+ splash_dark: resolve_asset.call(
811
+ section["splash_dark"] || section["splash_dark_image"] || assets["splash_#{name}_dark"]
812
+ ),
813
+ icon: resolve_asset.call(
814
+ section["icon_launcher"] || section["icon"] ||
815
+ build["icon_#{name}"] || assets["icon_#{name}"]
816
+ ),
817
+ background_image: resolve_asset.call(
818
+ section["splash_background_image"] || section["background_image"] || assets["splash_background_#{name}"]
819
+ ),
820
+ background_image_dark: resolve_asset.call(
821
+ section["splash_background_image_dark"] || section["background_image_dark"]
822
+ ),
823
+ branding: resolve_asset.call(section["splash_branding"] || section["branding"]),
824
+ branding_dark: resolve_asset.call(section["splash_branding_dark"] || section["branding_dark"]),
825
+ splash_color: section["splash_color"] || splash_color,
826
+ splash_dark_color: section["splash_dark_color"] || section["splash_color_dark"] || splash_dark_color,
827
+ icon_background: section["icon_background"] || icon_background,
828
+ theme_color: section["theme_color"] || theme_color
829
+ }
830
+ end
831
+
832
+ splash_background_image = resolve_asset.call(
833
+ build["splash_background_image"] || assets["splash_background_image"] || build["background_image"]
834
+ )
835
+ splash_background_image_dark = resolve_asset.call(
836
+ build["splash_background_image_dark"] || assets["splash_background_image_dark"]
837
+ )
838
+ splash_branding = resolve_asset.call(build["splash_branding"] || assets["splash_branding"])
839
+ splash_branding_dark = resolve_asset.call(build["splash_branding_dark"] || assets["splash_branding_dark"])
840
+ splash_branding_mode = build["splash_branding_mode"] || build["branding_mode"] ||
841
+ assets["splash_branding_mode"] || assets["branding_mode"]
842
+ splash_branding_padding = build["splash_branding_bottom_padding"] || build["branding_bottom_padding"] ||
843
+ assets["splash_branding_bottom_padding"] || assets["branding_bottom_padding"]
844
+
845
+ android = platforms.dig("android", :config)
846
+ android_splash = platforms.dig("android", :splash)
847
+ android_splash_dark = platforms.dig("android", :splash_dark)
848
+ android_12_splash = resolve_asset.call(
849
+ android["splash_android_12"] || android["android_12_image"] || assets["splash_android_12"]
850
+ )
851
+ android_12_splash_dark = resolve_asset.call(
852
+ android["splash_android_12_dark"] || android["android_12_image_dark"] || assets["splash_android_12_dark"]
853
+ )
854
+ adaptive_foreground = resolve_asset.call(
855
+ android["adaptive_icon_foreground"] || android["icon_foreground"] ||
856
+ assets["icon_adaptive_foreground"] || assets["icon_foreground"]
857
+ )
858
+ adaptive_background_image = resolve_asset.call(
859
+ android["adaptive_icon_background_image"] || assets["icon_adaptive_background_image"]
860
+ )
861
+ adaptive_monochrome = resolve_asset.call(
862
+ android["adaptive_icon_monochrome"] || android["icon_monochrome"] || assets["icon_adaptive_monochrome"]
863
+ )
864
+
865
+ android_splash_color = platforms.dig("android", :splash_color)
866
+ android_splash_dark_color = platforms.dig("android", :splash_dark_color)
867
+ android_12_icon_background = android["splash_android_12_icon_background_color"] ||
868
+ android["icon_background_color"] || android_splash_color
869
+ android_12_icon_background_dark = android["splash_android_12_icon_background_color_dark"] ||
870
+ android["icon_background_color_dark"] || android_splash_dark_color
871
+ android_12_color = android["splash_android_12_color"] || android["android_12_color"]
872
+ android_12_color_dark = android["splash_android_12_color_dark"] || android["android_12_color_dark"]
873
+ android_12_branding = resolve_asset.call(android["splash_android_12_branding"] || android["android_12_branding"])
874
+ adaptive_background_color = android["adaptive_icon_background"] || icon_background
875
+ android_min_sdk = android["min_sdk"] || android["min_sdk_android"] || build["min_sdk_android"]
876
+ android_splash_fullscreen = first_defined(android, "splash_fullscreen", "fullscreen")
877
+ android_splash_gravity = android["splash_gravity"] || android["android_gravity"]
653
878
 
654
879
  assets_dir = File.join(client_dir, "assets")
655
880
  FileUtils.mkdir_p(assets_dir)
@@ -662,14 +887,43 @@ module Ruflet
662
887
  copy_asset.call(splash, "splash.png")
663
888
  copy_asset.call(splash_dark, "splash_dark.png")
664
889
  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")
890
+ copy_asset.call(splash_background_image, "splash_background.png")
891
+ copy_asset.call(splash_background_image_dark, "splash_background_dark.png")
892
+ copy_asset.call(splash_branding, "splash_branding.png")
893
+ copy_asset.call(splash_branding_dark, "splash_branding_dark.png")
894
+
895
+ platforms.each do |name, entry|
896
+ support = PLATFORM_ASSET_SUPPORT.fetch(name)
897
+ if support[:splash]
898
+ copy_asset.call(entry[:splash], "splash_#{name}.png")
899
+ copy_asset.call(entry[:splash_dark], "splash_#{name}_dark.png")
900
+ copy_asset.call(entry[:background_image], "splash_background_#{name}.png")
901
+ copy_asset.call(entry[:background_image_dark], "splash_background_#{name}_dark.png")
902
+ copy_asset.call(entry[:branding], "splash_branding_#{name}.png")
903
+ copy_asset.call(entry[:branding_dark], "splash_branding_#{name}_dark.png")
904
+ elsif entry[:splash] || entry[:splash_dark]
905
+ build_note("#{name} has no splash screen generator; ignoring #{name}.splash_screen")
906
+ end
907
+
908
+ next unless entry[:icon]
909
+
910
+ unless support[:icon]
911
+ build_note("#{name} has no launcher icon generator; ignoring #{name}.icon_launcher")
912
+ next
913
+ end
914
+
915
+ if name == "windows" && File.extname(entry[:icon]).downcase == ".ico"
916
+ copy_asset.call(entry[:icon], "icon_windows.ico")
917
+ else
918
+ copy_asset.call(entry[:icon], "icon_#{name}.png")
919
+ end
671
920
  end
672
- copy_asset.call(icon_macos, "icon_macos.png")
921
+
922
+ copy_asset.call(android_12_splash, "splash_android_12.png")
923
+ copy_asset.call(android_12_splash_dark, "splash_android_12_dark.png")
924
+ copy_asset.call(adaptive_foreground, "icon_foreground.png")
925
+ copy_asset.call(adaptive_background_image, "icon_background.png")
926
+ copy_asset.call(adaptive_monochrome, "icon_monochrome.png")
673
927
 
674
928
  default_splash = File.file?(File.join(assets_dir, "splash.png"))
675
929
  default_icon = File.file?(File.join(assets_dir, "icon.png"))
@@ -694,43 +948,235 @@ module Ruflet
694
948
  end
695
949
  end
696
950
 
697
- has_splash = !splash.nil? || default_splash
698
- has_icon = !icon.nil? || default_icon
951
+ # A project may configure nothing shared and declare everything under the
952
+ # platform sections, so a platform asset alone has to run the generators.
953
+ platform_splash = platforms.any? { |name, entry| PLATFORM_ASSET_SUPPORT.fetch(name)[:splash] && entry[:splash] }
954
+ platform_icon = platforms.any? { |name, entry| PLATFORM_ASSET_SUPPORT.fetch(name)[:icon] && entry[:icon] }
955
+
956
+ platforms.each do |name, entry|
957
+ support = PLATFORM_ASSET_SUPPORT.fetch(name)
958
+ if support[:splash] && entry[:splash].nil? && key_defined?(entry[:config], "splash_screen")
959
+ build_note("#{name}.splash_screen was set but the file was not found")
960
+ end
961
+ if support[:icon] && entry[:icon].nil? && key_defined?(entry[:config], "icon_launcher")
962
+ build_note("#{name}.icon_launcher was set but the file was not found")
963
+ end
964
+ end
965
+
966
+ shared_splash_asset = !splash.nil? || default_splash
967
+ shared_icon_asset = !icon.nil? || default_icon
968
+ has_splash = shared_splash_asset || platform_splash
969
+ has_icon = shared_icon_asset || platform_icon
970
+
971
+ # Fall back to whatever Android configured when nothing is shared.
972
+ effective_icon_background = icon_background || platforms.dig("android", :icon_background)
973
+ effective_theme_color = theme_color || platforms.dig("android", :theme_color)
699
974
 
700
975
  pubspec_path = File.join(client_dir, "pubspec.yaml")
701
976
  unless File.file?(pubspec_path)
702
977
  return { has_icon: has_icon, has_splash: has_splash, error: nil }
703
978
  end
704
979
 
980
+ ensure_pubspec_block(pubspec_path, "flutter_launcher_icons") if has_icon
981
+ ensure_pubspec_block(pubspec_path, "flutter_native_splash") if has_splash
982
+
705
983
  if has_icon
706
- update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
984
+ if shared_icon_asset
985
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
986
+ end
987
+ # Android 8+ renders adaptive icons. Without these keys flutter_launcher_icons
988
+ # never writes mipmap-anydpi-v26/, and the launcher falls back to the legacy
989
+ # bitmap, ignoring icon_background entirely.
990
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "android", "launcher_icon", multiple: true)
991
+ adaptive_foreground_path =
992
+ if adaptive_foreground then "assets/icon_foreground.png"
993
+ elsif platforms.dig("android", :icon) then "assets/icon_android.png"
994
+ else "assets/icon.png"
995
+ end
996
+ update_pubspec_value(
997
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_foreground",
998
+ "\"#{adaptive_foreground_path}\"", multiple: true
999
+ )
1000
+ adaptive_background_value =
1001
+ if adaptive_background_image
1002
+ "\"assets/icon_background.png\""
1003
+ elsif adaptive_background_color
1004
+ "\"#{adaptive_background_color}\""
1005
+ end
1006
+ if adaptive_background_value
1007
+ update_pubspec_value(
1008
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_background",
1009
+ adaptive_background_value, multiple: true
1010
+ )
1011
+ end
1012
+ if adaptive_monochrome
1013
+ update_pubspec_value(
1014
+ pubspec_path, "flutter_launcher_icons", "adaptive_icon_monochrome",
1015
+ "\"assets/icon_monochrome.png\"", multiple: true
1016
+ )
1017
+ end
1018
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "min_sdk_android", android_min_sdk.to_s) if android_min_sdk
707
1019
  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)
1020
+ if has_icon
1021
+ # flutter_launcher_icons takes android/ios as flat image_path_* keys but
1022
+ # web/windows/macos as nested platform blocks.
1023
+ if platforms.dig("android", :icon)
1024
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true)
1025
+ end
1026
+ if platforms.dig("ios", :icon)
1027
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true)
1028
+ end
1029
+ if (remove_alpha = first_defined(platforms.dig("ios", :config), "remove_alpha", "remove_alpha_ios"))
1030
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "remove_alpha_ios", remove_alpha ? "true" : "false")
1031
+ end
1032
+
1033
+ %w[web windows macos].each do |name|
1034
+ entry = platforms.fetch(name)
1035
+ section = entry[:config]
1036
+ next if entry[:icon].nil? && section.empty?
1037
+
1038
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", name, "generate", "true")
1039
+ if entry[:icon]
1040
+ image = if name == "windows" && File.extname(entry[:icon]).downcase == ".ico"
1041
+ "assets/icon_windows.ico"
1042
+ else
1043
+ "assets/icon_#{name}.png"
1044
+ end
1045
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", name, "image_path", "\"#{image}\"")
1046
+ end
1047
+ if name == "web"
1048
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "web", "background_color", "\"#{entry[:icon_background]}\"") if entry[:icon_background]
1049
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "web", "theme_color", "\"#{entry[:theme_color]}\"") if entry[:theme_color]
1050
+ end
1051
+ if name == "windows" && (icon_size = section["icon_size"])
1052
+ update_pubspec_nested_value(pubspec_path, "flutter_launcher_icons", "windows", "icon_size", icon_size.to_s)
1053
+ end
1054
+ end
715
1055
  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
1056
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{effective_icon_background}\"") if effective_icon_background
1057
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{effective_theme_color}\"") if effective_theme_color
719
1058
 
720
- update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if has_splash
1059
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if shared_splash_asset
721
1060
  update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
722
1061
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
723
1062
  update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
724
1063
 
1064
+ if has_splash
1065
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image", "\"assets/splash_background.png\"") if splash_background_image
1066
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_dark", "\"assets/splash_background_dark.png\"") if splash_background_image_dark
1067
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding", "\"assets/splash_branding.png\"") if splash_branding
1068
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_dark", "\"assets/splash_branding_dark.png\"") if splash_branding_dark
1069
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_mode", splash_branding_mode.to_s) if splash_branding_mode
1070
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_bottom_padding", splash_branding_padding.to_s) if splash_branding_padding
1071
+ end
1072
+
1073
+ if has_splash
1074
+ # flutter_native_splash only generates for android, ios, and web; each
1075
+ # takes the shared keys suffixed with the platform name.
1076
+ %w[android ios web].each do |name|
1077
+ entry = platforms.fetch(name)
1078
+ update_pubspec_value(pubspec_path, "flutter_native_splash", name, "true")
1079
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_#{name}", "\"assets/splash_#{name}.png\"") if entry[:splash]
1080
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark_#{name}", "\"assets/splash_#{name}_dark.png\"") if entry[:splash_dark]
1081
+ if entry[:splash_color] && entry[:splash_color] != splash_color
1082
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_#{name}", "\"#{entry[:splash_color]}\"")
1083
+ end
1084
+ if entry[:splash_dark_color] && entry[:splash_dark_color] != splash_dark_color
1085
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark_#{name}", "\"#{entry[:splash_dark_color]}\"")
1086
+ end
1087
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_#{name}", "\"assets/splash_background_#{name}.png\"") if entry[:background_image]
1088
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "background_image_dark_#{name}", "\"assets/splash_background_#{name}_dark.png\"") if entry[:background_image_dark]
1089
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_#{name}", "\"assets/splash_branding_#{name}.png\"") if entry[:branding]
1090
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "branding_dark_#{name}", "\"assets/splash_branding_#{name}_dark.png\"") if entry[:branding_dark]
1091
+ end
1092
+
1093
+ if (ios_content_mode = platforms.dig("ios", :config)["content_mode"] || platforms.dig("ios", :config)["ios_content_mode"])
1094
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "ios_content_mode", ios_content_mode.to_s)
1095
+ end
1096
+ if (web_image_mode = platforms.dig("web", :config)["image_mode"] || platforms.dig("web", :config)["web_image_mode"])
1097
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "web_image_mode", web_image_mode.to_s)
1098
+ end
1099
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "fullscreen", android_splash_fullscreen ? "true" : "false") unless android_splash_fullscreen.nil?
1100
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "android_gravity", android_splash_gravity.to_s) if android_splash_gravity
1101
+
1102
+ # Android 12+ draws the splash itself and ignores the legacy `image`/`color`
1103
+ # keys. Without an android_12 section the OS shows the launcher icon on a
1104
+ # system background, so mirror the configured splash into it.
1105
+ android_12_image =
1106
+ if android_12_splash then "assets/splash_android_12.png"
1107
+ elsif android_splash then "assets/splash_android.png"
1108
+ else "assets/splash.png"
1109
+ end
1110
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "image", "\"#{android_12_image}\"")
1111
+ if android_12_icon_background
1112
+ update_pubspec_nested_value(
1113
+ pubspec_path, "flutter_native_splash", "android_12",
1114
+ "icon_background_color", "\"#{android_12_icon_background}\""
1115
+ )
1116
+ end
1117
+ android_12_image_dark =
1118
+ if android_12_splash_dark then "assets/splash_android_12_dark.png"
1119
+ elsif android_splash_dark then "assets/splash_android_dark.png"
1120
+ elsif splash_dark then "assets/splash_dark.png"
1121
+ end
1122
+ if android_12_image_dark
1123
+ update_pubspec_nested_value(
1124
+ pubspec_path, "flutter_native_splash", "android_12",
1125
+ "image_dark", "\"#{android_12_image_dark}\""
1126
+ )
1127
+ end
1128
+ if android_12_icon_background_dark
1129
+ update_pubspec_nested_value(
1130
+ pubspec_path, "flutter_native_splash", "android_12",
1131
+ "icon_background_color_dark", "\"#{android_12_icon_background_dark}\""
1132
+ )
1133
+ end
1134
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "color", "\"#{android_12_color}\"") if android_12_color
1135
+ update_pubspec_nested_value(pubspec_path, "flutter_native_splash", "android_12", "color_dark", "\"#{android_12_color_dark}\"") if android_12_color_dark
1136
+ if android_12_branding
1137
+ copy_asset.call(android_12_branding, "splash_android_12_branding.png")
1138
+ update_pubspec_nested_value(
1139
+ pubspec_path, "flutter_native_splash", "android_12",
1140
+ "branding", "\"assets/splash_android_12_branding.png\""
1141
+ )
1142
+ end
1143
+ end
1144
+
725
1145
  {
726
1146
  has_icon: has_icon,
727
1147
  has_splash: has_splash,
728
1148
  using_default_icon: using_default_icon,
729
1149
  using_default_splash: using_default_splash,
1150
+ android_adaptive_icon: has_icon,
1151
+ android_12_splash: has_splash,
730
1152
  error: nil
731
1153
  }
732
1154
  end
733
1155
 
1156
+ # Platform-specific overrides may live in a top-level `<platform>:` block,
1157
+ # under `build.<platform>:`, or under `assets.<platform>:`. Later sources win.
1158
+ def platform_build_config(config, platform)
1159
+ name = platform.to_s
1160
+ build = config["build"].is_a?(Hash) ? config["build"] : {}
1161
+ assets = config["assets"].is_a?(Hash) ? config["assets"] : {}
1162
+ [config[name], build[name], assets[name]].each_with_object({}) do |source, merged|
1163
+ next unless source.is_a?(Hash)
1164
+
1165
+ source.each { |key, value| merged[key.to_s] = value }
1166
+ end
1167
+ end
1168
+
1169
+ # Like first_present, but keeps `false` — needed for boolean toggles.
1170
+ def first_defined(hash, *keys)
1171
+ return nil unless hash.is_a?(Hash)
1172
+
1173
+ keys.each do |key|
1174
+ return hash[key] if hash.key?(key)
1175
+ return hash[key.to_sym] if hash.key?(key.to_sym)
1176
+ end
1177
+ nil
1178
+ end
1179
+
734
1180
  def sync_client_metadata(client_dir, config = {}, verbose: false)
735
1181
  metadata = build_client_metadata(config, client_dir)
736
1182
  apply_pubspec_metadata(client_dir, metadata)
@@ -744,21 +1190,30 @@ module Ruflet
744
1190
  verbose,
745
1191
  "app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}"
746
1192
  )
1193
+ metadata
747
1194
  end
748
1195
 
749
1196
  def build_client_metadata(config, client_dir)
750
1197
  app = config["app"].is_a?(Hash) ? config["app"] : {}
751
1198
  current_pubspec = load_client_pubspec(client_dir)
752
1199
  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))
1200
+ inferred_display_name = app["app_name"] || app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd))
1201
+ configured_app_name = first_present(app["app_name"], app["display_name"], app["name"])
1202
+ package_source = first_present(app["package_name"], config["package_name"], configured_app_name)
1203
+ package_source = current_name if package_source.nil?
1204
+ package_name = normalize_package_name(package_source)
1205
+ display_name = first_present(app["app_name"], app["display_name"], app["name"], config["display_name"], config["name"], humanize_name(package_name))
756
1206
  organization = normalize_bundle_prefix(
757
1207
  first_present(app["org"], app["organization"], config["org"], config["organization"], "com.example")
758
1208
  )
759
1209
  bundle_identifier = normalize_bundle_identifier(
760
1210
  first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
761
1211
  )
1212
+ identity_errors = []
1213
+ named = first_present(app["name"], app["app_name"], app["display_name"])
1214
+ identity_errors << "app.name" if named.nil?
1215
+ identity_errors << "app.package_name" if app["package_name"].to_s.strip.empty?
1216
+ identity_errors << "app.organization" if first_present(app["organization"], app["org"]).nil?
762
1217
 
763
1218
  {
764
1219
  package_name: package_name,
@@ -780,15 +1235,28 @@ module Ruflet
780
1235
  linux_application_id: normalize_bundle_identifier(
781
1236
  first_present(app["linux_application_id"], config["linux_application_id"], bundle_identifier)
782
1237
  ),
783
- short_name: first_present(app["short_name"], config["short_name"], display_name)
1238
+ short_name: first_present(app["short_name"], config["short_name"], display_name),
1239
+ mobile_identity_errors: identity_errors.uniq
784
1240
  }
785
1241
  end
786
1242
 
1243
+ def validate_mobile_app_identity(metadata, platform:)
1244
+ return true unless %w[apk android aab ios].include?(platform.to_s)
1245
+ return true unless metadata
1246
+
1247
+ errors = Array(metadata[:mobile_identity_errors])
1248
+ return true if errors.empty?
1249
+
1250
+ warn "build config error: ruflet.yaml must define #{errors.join(', ')}"
1251
+ warn "A mobile build needs app.name, app.package_name and app.organization."
1252
+ false
1253
+ end
1254
+
787
1255
  def load_client_pubspec(client_dir)
788
1256
  pubspec_path = File.join(client_dir, "pubspec.yaml")
789
1257
  return {} unless File.file?(pubspec_path)
790
1258
 
791
- YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1259
+ YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
792
1260
  rescue StandardError
793
1261
  {}
794
1262
  end
@@ -797,7 +1265,7 @@ module Ruflet
797
1265
  pubspec_path = File.join(client_dir, "pubspec.yaml")
798
1266
  return unless File.file?(pubspec_path)
799
1267
 
800
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1268
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
801
1269
  data["name"] = metadata[:package_name]
802
1270
  data["description"] = metadata[:description]
803
1271
  data["version"] = metadata[:version]
@@ -805,28 +1273,6 @@ module Ruflet
805
1273
  end
806
1274
 
807
1275
  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
1276
  manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
831
1277
  replace_in_file(
832
1278
  manifest_path,
@@ -835,6 +1281,33 @@ module Ruflet
835
1281
  )
836
1282
  end
837
1283
 
1284
+ # The signing team belongs to whoever ships the app, so it comes from the
1285
+ # project rather than being baked into the client.
1286
+ def apply_ios_signing_team(client_dir, config)
1287
+ team = ios_signing_team(config)
1288
+ pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
1289
+ return unless File.file?(pbxproj_path)
1290
+
1291
+ if team.to_s.strip.empty?
1292
+ build_note("No ios.team_id configured; Xcode will pick the signing team")
1293
+ replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = \"\";")
1294
+ return
1295
+ end
1296
+
1297
+ replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = #{team};")
1298
+ build_note("iOS signing team set to #{team}")
1299
+ end
1300
+
1301
+ def ios_signing_team(config)
1302
+ app = config["app"].is_a?(Hash) ? config["app"] : {}
1303
+ first_present(
1304
+ platform_build_config(config, "ios")["team_id"],
1305
+ app["ios_team_id"],
1306
+ app["team_id"],
1307
+ ENV["RUFLET_IOS_TEAM_ID"]
1308
+ )
1309
+ end
1310
+
838
1311
  def apply_ios_metadata(client_dir, metadata)
839
1312
  info_plist_path = File.join(client_dir, "ios", "Runner", "Info.plist")
840
1313
  replace_plist_value(info_plist_path, "CFBundleDisplayName", metadata[:display_name])
@@ -843,17 +1316,37 @@ module Ruflet
843
1316
  pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
844
1317
  return unless File.file?(pbxproj_path)
845
1318
 
846
- content = File.read(pbxproj_path)
1319
+ content = read_text_file(pbxproj_path)
847
1320
  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)
1321
+ write_text_file(pbxproj_path, content)
1322
+ end
1323
+
1324
+ def apply_mobile_package_name(client_dir, metadata, platform:, tools:, verbose: false)
1325
+ return true unless metadata
1326
+
1327
+ package_name, platform_flag = case platform.to_s
1328
+ when "apk", "android", "aab"
1329
+ [metadata[:android_application_id], "--android"]
1330
+ when "ios"
1331
+ [metadata[:ios_bundle_identifier], "--ios"]
1332
+ else
1333
+ return true
1334
+ end
1335
+
1336
+ build_note("Applying #{platform_flag.delete_prefix('--')} package name #{package_name}")
1337
+ build_log(verbose, "running change_app_package_name for #{package_name} #{platform_flag}")
1338
+ ok = run_external_command(
1339
+ tools[:env],
1340
+ tools[:dart],
1341
+ "run",
1342
+ "change_app_package_name:main",
1343
+ package_name,
1344
+ platform_flag,
1345
+ chdir: client_dir,
1346
+ unbundled: true
1347
+ )
1348
+ warn "change_app_package_name failed for #{package_name}" unless ok
1349
+ ok
857
1350
  end
858
1351
 
859
1352
  def apply_macos_metadata(client_dir, metadata)
@@ -878,11 +1371,11 @@ module Ruflet
878
1371
  def apply_web_metadata(client_dir, metadata)
879
1372
  manifest_path = File.join(client_dir, "web", "manifest.json")
880
1373
  if File.file?(manifest_path)
881
- data = JSON.parse(File.read(manifest_path))
1374
+ data = JSON.parse(read_text_file(manifest_path))
882
1375
  data["name"] = metadata[:display_name]
883
1376
  data["short_name"] = metadata[:short_name]
884
1377
  data["description"] = metadata[:description]
885
- File.write(manifest_path, JSON.pretty_generate(data) + "\n")
1378
+ write_text_file(manifest_path, JSON.pretty_generate(data) + "\n")
886
1379
  end
887
1380
 
888
1381
  index_path = File.join(client_dir, "web", "index.html")
@@ -947,23 +1440,34 @@ module Ruflet
947
1440
  replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
948
1441
  end
949
1442
 
1443
+ # Native project files are UTF-8 and the values written into them are too
1444
+ # (the macOS copyright line carries a ©). Reading them with the default
1445
+ # external encoding fails outright under a non-UTF-8 locale, so pin it.
1446
+ def read_text_file(path)
1447
+ File.read(path, encoding: Encoding::UTF_8)
1448
+ end
1449
+
1450
+ def write_text_file(path, content)
1451
+ File.write(path, content, encoding: Encoding::UTF_8)
1452
+ end
1453
+
950
1454
  def replace_plist_value(path, key, value)
951
1455
  return unless File.file?(path)
952
1456
 
953
- content = File.read(path)
1457
+ content = read_text_file(path)
954
1458
  pattern = %r{(<key>#{Regexp.escape(key)}</key>\s*<string>)(.*?)(</string>)}m
955
1459
  updated = content.gsub(pattern) do
956
1460
  "#{Regexp.last_match(1)}#{xml_escape(value)}#{Regexp.last_match(3)}"
957
1461
  end
958
- File.write(path, updated) unless updated == content
1462
+ write_text_file(path, updated) unless updated == content
959
1463
  end
960
1464
 
961
1465
  def replace_in_file(path, pattern, replacement)
962
1466
  return unless File.file?(path)
963
1467
 
964
- content = File.read(path)
1468
+ content = read_text_file(path)
965
1469
  updated = content.gsub(pattern) { replacement }
966
- File.write(path, updated) unless updated == content
1470
+ write_text_file(path, updated) unless updated == content
967
1471
  end
968
1472
 
969
1473
  def first_present(*values)
@@ -1041,17 +1545,109 @@ module Ruflet
1041
1545
  extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1042
1546
  extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1043
1547
 
1548
+ external = external_extension_entries(config)
1549
+
1044
1550
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1045
1551
  if File.file?(pubspec_path)
1046
1552
  sync_client_extension_dependencies(pubspec_path, extension_packages)
1047
1553
  prune_client_pubspec(pubspec_path, extension_packages)
1554
+ sync_external_extension_dependencies(pubspec_path, external)
1048
1555
  end
1049
1556
  client_entrypoint_paths(client_dir).each do |entrypoint|
1050
- sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
1051
- prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
1557
+ next unless File.file?(entrypoint)
1558
+
1559
+ sync_client_main_extensions(entrypoint, extension_aliases)
1560
+ prune_client_main(entrypoint, extension_aliases)
1561
+ sync_external_extension_registrations(entrypoint, external)
1052
1562
  end
1053
1563
  end
1054
1564
 
1565
+ # An extension may name a package the template does not bundle, declared
1566
+ # with the source to fetch it from:
1567
+ #
1568
+ # extensions:
1569
+ # - charts
1570
+ # - my_package:
1571
+ # git:
1572
+ # url: https://github.com/owner/my_package
1573
+ # ref: main
1574
+ #
1575
+ # `branch` is accepted for `ref`, and `path` for a local checkout.
1576
+ def external_extension_entries(config)
1577
+ Array(config["extensions"]).filter_map do |entry|
1578
+ next unless entry.is_a?(Hash)
1579
+ next unless entry.size == 1
1580
+
1581
+ name, source = entry.first
1582
+ package = name.to_s.strip
1583
+ next if package.empty?
1584
+
1585
+ dependency = external_extension_dependency(source)
1586
+ next unless dependency
1587
+
1588
+ { name: package, dependency: dependency }
1589
+ end
1590
+ end
1591
+
1592
+ def external_extension_dependency(source)
1593
+ return nil unless source.is_a?(Hash)
1594
+
1595
+ normalized = source.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
1596
+ git = normalized["git"] || normalized["github"] || normalized["repository"]
1597
+ git = normalized if git.nil? && normalized.key?("url")
1598
+
1599
+ if git.is_a?(String)
1600
+ return { "git" => git }
1601
+ elsif git.is_a?(Hash)
1602
+ git = git.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
1603
+ url = git["url"].to_s.strip
1604
+ return nil if url.empty?
1605
+
1606
+ spec = { "url" => url }
1607
+ ref = (git["ref"] || git["branch"] || git["tag"]).to_s.strip
1608
+ spec["ref"] = ref unless ref.empty?
1609
+ path = git["path"].to_s.strip
1610
+ spec["path"] = path unless path.empty?
1611
+ return { "git" => spec }
1612
+ end
1613
+
1614
+ local = normalized["path"]
1615
+ return { "path" => local.to_s } if local.is_a?(String) && !local.to_s.strip.empty?
1616
+
1617
+ nil
1618
+ end
1619
+
1620
+ def sync_external_extension_dependencies(pubspec_path, entries)
1621
+ return if entries.empty?
1622
+
1623
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1624
+ dependencies = (data["dependencies"] || {}).dup
1625
+ entries.each { |entry| dependencies[entry[:name]] = entry[:dependency] }
1626
+ data["dependencies"] = dependencies
1627
+ write_pubspec_yaml(pubspec_path, data)
1628
+ build_note("Added #{entries.map { |e| e[:name] }.join(', ')} from the extension configuration")
1629
+ end
1630
+
1631
+ # Flet extension packages expose an Extension class from a library named
1632
+ # after the package, so the import and registration can be derived.
1633
+ def sync_external_extension_registrations(path, entries)
1634
+ return if entries.empty?
1635
+
1636
+ content = read_text_file(path)
1637
+ original = content.dup
1638
+
1639
+ entries.each do |entry|
1640
+ name = entry[:name]
1641
+ import_line = %(import 'package:#{name}/#{name}.dart' as #{name};\n)
1642
+ extension_line = " #{name}.Extension(),\n"
1643
+
1644
+ content = insert_missing_import(content, import_line) unless content.include?("package:#{name}/#{name}.dart")
1645
+ content = insert_missing_extension(content, extension_line) unless content.match?(/^\s*#{Regexp.escape(name)}\.Extension\(\),/)
1646
+ end
1647
+
1648
+ write_text_file(path, content) unless content == original
1649
+ end
1650
+
1055
1651
  def configured_service_entries(config)
1056
1652
  Array(config["services"]).filter_map do |entry|
1057
1653
  case entry
@@ -1069,8 +1665,88 @@ module Ruflet
1069
1665
  end
1070
1666
  end
1071
1667
 
1668
+ ANDROID_SIGNING_KEYS = {
1669
+ "storeFile" => "RUFLET_ANDROID_KEYSTORE",
1670
+ "storePassword" => "RUFLET_ANDROID_KEYSTORE_PASSWORD",
1671
+ "keyAlias" => "RUFLET_ANDROID_KEY_ALIAS",
1672
+ "keyPassword" => "RUFLET_ANDROID_KEY_PASSWORD"
1673
+ }.freeze
1674
+
1675
+ # Release signing is read from the project, never from the managed client,
1676
+ # so nobody has to edit build/client by hand. Either keep an
1677
+ # android/key.properties beside the app, or set the RUFLET_ANDROID_*
1678
+ # variables. Without one of those, Gradle falls back to the debug key.
1679
+ def apply_android_signing_config(client_dir, platform, verbose: false)
1680
+ return unless %w[apk android aab appbundle].include?(platform.to_s)
1681
+
1682
+ android_dir = File.join(client_dir, "android")
1683
+ return unless Dir.exist?(android_dir)
1684
+
1685
+ destination = File.join(android_dir, "key.properties")
1686
+ properties = android_signing_properties
1687
+ if properties.empty?
1688
+ File.delete(destination) if File.file?(destination)
1689
+ build_note("No Android signing configured; the release build will use the debug key")
1690
+ return
1691
+ end
1692
+
1693
+ missing = ANDROID_SIGNING_KEYS.keys - properties.keys
1694
+ unless missing.empty?
1695
+ warn "build config error: Android signing is missing #{missing.join(', ')}"
1696
+ warn "Set them in android/key.properties or the matching RUFLET_ANDROID_* variables."
1697
+ return
1698
+ end
1699
+
1700
+ body = ANDROID_SIGNING_KEYS.keys.map { |key| "#{key}=#{properties.fetch(key)}" }.join("\n")
1701
+ write_text_file(destination, "#{body}\n")
1702
+ build_log(verbose, "wrote #{destination}")
1703
+ build_note("Android release signing configured from #{properties.fetch("_source")}")
1704
+ end
1705
+
1706
+ def android_signing_properties
1707
+ from_env = ANDROID_SIGNING_KEYS.each_with_object({}) do |(key, variable), out|
1708
+ value = ENV[variable].to_s.strip
1709
+ out[key] = value unless value.empty?
1710
+ end
1711
+ unless from_env.empty?
1712
+ from_env["storeFile"] = File.expand_path(from_env["storeFile"]) if from_env["storeFile"]
1713
+ return from_env.merge("_source" => "the RUFLET_ANDROID_* environment")
1714
+ end
1715
+
1716
+ source = project_android_key_properties_path
1717
+ return {} unless source
1718
+
1719
+ parsed = read_text_file(source).each_line.with_object({}) do |line, out|
1720
+ next if line.strip.empty? || line.strip.start_with?("#")
1721
+
1722
+ key, value = line.split("=", 2)
1723
+ out[key.to_s.strip] = value.to_s.strip unless value.nil?
1724
+ end
1725
+ return {} if parsed.empty?
1726
+
1727
+ # storeFile is written relative to the project; the client sits deeper.
1728
+ if parsed["storeFile"] && !Pathname.new(parsed["storeFile"]).absolute?
1729
+ parsed["storeFile"] = File.expand_path(parsed["storeFile"], File.dirname(source))
1730
+ end
1731
+ parsed.merge("_source" => source)
1732
+ end
1733
+
1734
+ def project_android_key_properties_path
1735
+ [
1736
+ File.join(Dir.pwd, "android", "key.properties"),
1737
+ File.join(Dir.pwd, "key.properties")
1738
+ ].find { |path| File.file?(path) }
1739
+ end
1740
+
1072
1741
  def apply_native_service_permissions(client_dir, config)
1073
1742
  entries = configured_service_entries(config)
1743
+ configured_extensions = Array(config["extensions"]).filter_map { |entry| normalize_extension_key(entry) }
1744
+ extension_services = configured_extensions.flat_map do |extension|
1745
+ EXTENSION_REQUIRED_SERVICES.fetch(extension, [])
1746
+ end
1747
+ extension_services.each do |service|
1748
+ entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
1749
+ end
1074
1750
  return if entries.empty?
1075
1751
 
1076
1752
  apply_android_service_permissions(client_dir, entries)
@@ -1081,20 +1757,20 @@ module Ruflet
1081
1757
  path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
1082
1758
  return unless File.file?(path)
1083
1759
 
1084
- content = File.read(path)
1760
+ content = read_text_file(path)
1085
1761
  entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
1086
1762
  next if content.include?(%(android:name="#{permission}"))
1087
1763
 
1088
1764
  content.sub!(/<manifest\b[^>]*>\s*/, "\\0 <uses-permission android:name=\"#{permission}\"/>\n")
1089
1765
  end
1090
- File.write(path, content)
1766
+ write_text_file(path, content)
1091
1767
  end
1092
1768
 
1093
1769
  def apply_ios_service_usage_descriptions(client_dir, entries)
1094
1770
  path = File.join(client_dir, "ios", "Runner", "Info.plist")
1095
1771
  return unless File.file?(path)
1096
1772
 
1097
- content = File.read(path)
1773
+ content = read_text_file(path)
1098
1774
  entries.each do |entry|
1099
1775
  key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
1100
1776
  next unless key
@@ -1110,7 +1786,7 @@ module Ruflet
1110
1786
  content.sub!(%r{</dict>\s*</plist>}m, "#{pair}</dict>\n</plist>")
1111
1787
  end
1112
1788
  end
1113
- File.write(path, content)
1789
+ write_text_file(path, content)
1114
1790
  end
1115
1791
 
1116
1792
  def clear_flutter_build_state(client_dir, verbose: false)
@@ -1161,11 +1837,11 @@ module Ruflet
1161
1837
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1162
1838
  return unless File.file?(pubspec_path)
1163
1839
 
1164
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
1840
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1165
1841
  dependencies = data["dependencies"]
1166
1842
  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
1843
+ spinkit_dependency = template_client_pubspec_dependencies["flet_spinkit"]
1844
+ dependencies["flet_spinkit"] = spinkit_dependency if spinkit_dependency
1169
1845
  flutter = data["flutter"]
1170
1846
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1171
1847
  assets = Array(flutter["assets"]).map(&:to_s)
@@ -1189,11 +1865,16 @@ module Ruflet
1189
1865
  write_pubspec_yaml(pubspec_path, data)
1190
1866
  end
1191
1867
 
1868
+ # A self-contained build needs the embedded VM. Prefer a local checkout so
1869
+ # the runtime under development is the one packaged, and otherwise resolve
1870
+ # the published package.
1871
+ PUBLISHED_RUBY_RUNTIME_CONSTRAINT = "^0.0.9"
1872
+
1192
1873
  def ruby_runtime_dependency(current_dependency = nil)
1193
1874
  local_path = explicit_local_ruby_runtime_path || source_checkout_ruby_runtime_path
1194
1875
  return { "path" => local_path } if local_path
1195
1876
 
1196
- current_dependency || "^0.0.3"
1877
+ current_dependency || PUBLISHED_RUBY_RUNTIME_CONSTRAINT
1197
1878
  end
1198
1879
 
1199
1880
  def explicit_local_ruby_runtime_path
@@ -1225,12 +1906,14 @@ module Ruflet
1225
1906
  "lib/main.self.dart",
1226
1907
  "lib/main.server.dart",
1227
1908
  "lib/ruflet_file_picker_service.dart",
1228
- "lib/ruflet_spinkit.dart",
1229
1909
  "lib/connection_probe.dart",
1230
1910
  "lib/connection_probe_io.dart",
1231
1911
  "lib/connection_probe_stub.dart",
1232
1912
  "ios/Podfile",
1233
- "windows/CMakeLists.txt"
1913
+ "windows/CMakeLists.txt",
1914
+ # Release signing lives here; an existing client would otherwise keep
1915
+ # signing release builds with the debug key.
1916
+ "android/app/build.gradle.kts"
1234
1917
  ]
1235
1918
 
1236
1919
  managed_files.each do |relative_path|
@@ -1250,7 +1933,7 @@ module Ruflet
1250
1933
 
1251
1934
  content = indent_pubspec_sequences(content)
1252
1935
 
1253
- File.write(path, content)
1936
+ write_text_file(path, content)
1254
1937
  end
1255
1938
 
1256
1939
  def indent_pubspec_sequences(content)
@@ -1352,6 +2035,8 @@ module Ruflet
1352
2035
  .vscode
1353
2036
  build
1354
2037
  coverage
2038
+ credentials
2039
+ fastlane
1355
2040
  log
1356
2041
  node_modules
1357
2042
  pkg
@@ -1364,10 +2049,30 @@ module Ruflet
1364
2049
  end
1365
2050
  end
1366
2051
 
2052
+ # Anything embedded here is readable by anyone who unpacks the shipped
2053
+ # app, so signing keys and local environment files must never be copied
2054
+ # in even when a project keeps them beside its source.
2055
+ SECRET_ASSET_EXTENSIONS = %w[.p8 .p12 .pem .key .jks .keystore .mobileprovision].freeze
2056
+ SECRET_ASSET_BASENAMES = %w[.env .netrc key.properties].freeze
2057
+
2058
+ def secret_project_asset?(relative)
2059
+ basename = File.basename(relative)
2060
+ return true if SECRET_ASSET_BASENAMES.include?(basename)
2061
+ return true if basename.start_with?(".env.") && basename != ".env.example"
2062
+ return true if SECRET_ASSET_EXTENSIONS.include?(File.extname(basename).downcase)
2063
+ return true if basename.match?(/\Agoogle-play.*\.json\z/i)
2064
+
2065
+ false
2066
+ end
2067
+
1367
2068
  def include_project_asset_file?(relative)
1368
2069
  basename = File.basename(relative)
1369
2070
  return false if basename == ".DS_Store"
1370
2071
  return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename)
2072
+ if secret_project_asset?(relative)
2073
+ build_note("Excluded #{relative} from the embedded project; it looks like a credential")
2074
+ return false
2075
+ end
1371
2076
  true
1372
2077
  end
1373
2078
 
@@ -1398,12 +2103,13 @@ module Ruflet
1398
2103
 
1399
2104
  key.tr!("-", "_")
1400
2105
  key.gsub!(/\A(flet_)+/, "")
2106
+ key.gsub!(/\A(ruflet_)+/, "")
1401
2107
  key.gsub!(/\Aservice_/, "")
1402
2108
  key
1403
2109
  end
1404
2110
 
1405
2111
  def prune_client_pubspec(path, selected_packages)
1406
- data = YAML.safe_load(File.read(path), aliases: true) || {}
2112
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1407
2113
  deps = (data["dependencies"] || {}).dup
1408
2114
  optional_packages = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:package) }.uniq
1409
2115
 
@@ -1424,7 +2130,7 @@ module Ruflet
1424
2130
  template_deps = template_client_pubspec_dependencies
1425
2131
  return if template_deps.empty?
1426
2132
 
1427
- data = YAML.safe_load(File.read(path), aliases: true) || {}
2133
+ data = YAML.safe_load(read_text_file(path), aliases: true) || {}
1428
2134
  deps = (data["dependencies"] || {}).dup
1429
2135
  selected_packages.each do |package_name|
1430
2136
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
@@ -1444,7 +2150,7 @@ module Ruflet
1444
2150
  pubspec_path = File.join(template_root, "pubspec.yaml")
1445
2151
  return {} unless File.file?(pubspec_path)
1446
2152
 
1447
- data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {}
2153
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1448
2154
  deps = data["dependencies"]
1449
2155
  deps.is_a?(Hash) ? deps : {}
1450
2156
  rescue StandardError
@@ -1457,8 +2163,8 @@ module Ruflet
1457
2163
  template_path = template_client_entrypoint_path(File.basename(path))
1458
2164
  return unless template_path
1459
2165
 
1460
- content = File.read(path)
1461
- template = File.read(template_path)
2166
+ content = read_text_file(path)
2167
+ template = read_text_file(template_path)
1462
2168
 
1463
2169
  selected_aliases.each do |extension_alias|
1464
2170
  import_line = template.lines.find { |line| line.match?(/\sas #{Regexp.escape(extension_alias)};\s*\z/) }
@@ -1470,7 +2176,7 @@ module Ruflet
1470
2176
  content = insert_missing_extension(content, extension_line) if extension_line && !content.include?(extension_line)
1471
2177
  end
1472
2178
 
1473
- File.write(path, content)
2179
+ write_text_file(path, content)
1474
2180
  end
1475
2181
 
1476
2182
  def template_client_entrypoint_path(name)
@@ -1508,7 +2214,7 @@ module Ruflet
1508
2214
  end
1509
2215
 
1510
2216
  def prune_client_main(path, selected_aliases)
1511
- content = File.read(path)
2217
+ content = read_text_file(path)
1512
2218
  alias_to_package = {}
1513
2219
  optional_aliases = CLIENT_EXTENSION_MAP.values.map { |entry| entry.fetch(:alias) }.uniq
1514
2220
 
@@ -1547,11 +2253,58 @@ module Ruflet
1547
2253
  end
1548
2254
  end
1549
2255
 
1550
- File.write(path, content)
2256
+ write_text_file(path, content)
2257
+ end
2258
+
2259
+ # update_pubspec_value only rewrites blocks that already exist. Create the
2260
+ # block first so a template without it still receives the configured values.
2261
+ def ensure_pubspec_block(path, block)
2262
+ return unless File.file?(path)
2263
+
2264
+ content = read_text_file(path)
2265
+ return if content.lines.any? { |line| line.start_with?("#{block}:") }
2266
+
2267
+ content += "\n" unless content.empty? || content.end_with?("\n")
2268
+ write_text_file(path, "#{content}#{block}:\n")
2269
+ end
2270
+
2271
+ # Writes `block: -> section: -> key: value`, creating the block and the
2272
+ # nested section when they are missing (for example flutter_native_splash's
2273
+ # android_12 section).
2274
+ def update_pubspec_nested_value(path, block, section, key, value)
2275
+ return unless File.file?(path)
2276
+
2277
+ ensure_pubspec_block(path, block)
2278
+ lines = read_text_file(path).split("\n", -1)
2279
+ block_start = lines.index { |line| line.start_with?("#{block}:") }
2280
+ return unless block_start
2281
+
2282
+ block_end = block_start + 1
2283
+ block_end += 1 while block_end < lines.length && (lines[block_end].strip.empty? || lines[block_end].start_with?(" ", "\t"))
2284
+ block_end -= 1 while block_end > block_start + 1 && lines[block_end - 1].strip.empty?
2285
+
2286
+ section_index = (block_start + 1...block_end).find do |index|
2287
+ lines[index] =~ /\A\s{2}#{Regexp.escape(section)}:\s*(#.*)?\z/
2288
+ end
2289
+
2290
+ if section_index.nil?
2291
+ lines.insert(block_end, " #{section}:", " #{key}: #{value}")
2292
+ else
2293
+ section_end = section_index + 1
2294
+ section_end += 1 while section_end < block_end && lines[section_end] =~ /\A\s{3,}\S/
2295
+ existing = (section_index + 1...section_end).find { |index| lines[index].strip.start_with?("#{key}:") }
2296
+ if existing
2297
+ lines[existing] = "#{lines[existing][/\A\s*/]}#{key}: #{value}"
2298
+ else
2299
+ lines.insert(section_end, " #{key}: #{value}")
2300
+ end
2301
+ end
2302
+
2303
+ write_text_file(path, indent_pubspec_sequences(lines.join("\n")))
1551
2304
  end
1552
2305
 
1553
2306
  def update_pubspec_value(path, block, key, value, multiple: false)
1554
- lines = File.read(path).split("\n", -1)
2307
+ lines = read_text_file(path).split("\n", -1)
1555
2308
  out = []
1556
2309
  in_block = false
1557
2310
  replaced = false
@@ -1586,7 +2339,7 @@ module Ruflet
1586
2339
  if in_block && !replaced
1587
2340
  out << "#{block_indent}#{key}: #{value}"
1588
2341
  end
1589
- File.write(path, indent_pubspec_sequences(out.join("\n")))
2342
+ write_text_file(path, indent_pubspec_sequences(out.join("\n")))
1590
2343
  end
1591
2344
 
1592
2345
  def flutter_build_command(platform)
@@ -1597,6 +2350,8 @@ module Ruflet
1597
2350
  ["build", "appbundle"]
1598
2351
  when "ios"
1599
2352
  ["build", "ios"]
2353
+ when "ipa"
2354
+ ["build", "ipa"]
1600
2355
  when "web"
1601
2356
  ["build", "web"]
1602
2357
  when "macos"