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.
- checksums.yaml +4 -4
- data/README.md +2 -3
- data/lib/ruflet/cli/build_command.rb +867 -112
- data/lib/ruflet/cli/flutter_sdk.rb +17 -3
- data/lib/ruflet/cli/new_command.rb +36 -23
- data/lib/ruflet/cli/run_command.rb +235 -61
- data/lib/ruflet/cli/templates.rb +8 -5
- data/lib/ruflet/cli/update_command.rb +6 -6
- data/lib/ruflet/cli.rb +1 -1
- data/lib/ruflet/hot_reload/harness.rb +32 -0
- data/lib/ruflet/hot_reload.rb +226 -0
- data/lib/ruflet/version.rb +1 -1
- metadata +3 -1
|
@@ -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|
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
services_path = File.join(
|
|
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(
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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(
|
|
666
|
-
copy_asset.call(
|
|
667
|
-
copy_asset.call(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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", "
|
|
717
|
-
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "
|
|
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
|
|
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
|
-
|
|
755
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
1319
|
+
content = read_text_file(pbxproj_path)
|
|
847
1320
|
content.gsub!(/INFOPLIST_KEY_CFBundleDisplayName = "[^"]*";/, %(INFOPLIST_KEY_CFBundleDisplayName = "#{xcode_escape(metadata[:display_name])}";))
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
1468
|
+
content = read_text_file(path)
|
|
965
1469
|
updated = content.gsub(pattern) { replacement }
|
|
966
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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["
|
|
1168
|
-
dependencies["
|
|
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 ||
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
1461
|
-
template =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|