ruflet 0.0.14 → 0.0.15

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d273c23251a6550ce0d8fa89003051b98f4b92ade780a048bd0c56c41ca14c81
4
- data.tar.gz: e646f2912853da8f30a6b49b924c04cfb9a00fd8328f391f8a68bea0e7875fcb
3
+ metadata.gz: 629de90fa5d63db66ce7f96b69fcd83da284621d1bf8f3c179d2ea7b54f1591b
4
+ data.tar.gz: 4f91dfe6d31fe054b683f9838120fb6476501ac959e981eff19ef1809c880aa9
5
5
  SHA512:
6
- metadata.gz: d6084b2b22d1775ff0849ade08bbc0ac4cb67198816add2f9d02a4c6461fef156936b2e45cd69cde3c4d7f0cc3510e9c942c245d7c2d032def1a39d4d3620c24
7
- data.tar.gz: 7e73f5b93c8af40c22f7ff5c154bcf4bc40f87595ef7993144ebd7b7eccc7531fab2b72b7e0f19aa002ec2983a4b5e0d0655a5b8946c49b1a98d90185b8831c0
6
+ metadata.gz: 23a877adf84ca8bc119cb5452a480dd666e05f36fd6f3a1a03f75f51fa5b2ff028f07b5373b8c043a1acd43b49dc68f7885992bcb3596e279be07fed5751cb07
7
+ data.tar.gz: c66e78cc467a27803bbb52264444289f4a9126763c4aed1c53df6b945e1c08f0478fff277008a40c742750dde027a67c84a2209ff4d255b6b48483ffa6a21549
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "tmpdir"
7
+
8
+ module Ruflet
9
+ module CLI
10
+ # Provisions the Android toolchain so `ruflet build apk|aab|android`
11
+ # works on a fresh machine:
12
+ #
13
+ # * a JDK (Gradle itself is bootstrapped by the project's Gradle
14
+ # wrapper, but the wrapper needs Java)
15
+ # * the Android SDK command-line tools
16
+ # * platform-tools / a platform / build-tools via sdkmanager
17
+ # * accepted SDK licenses
18
+ #
19
+ # An existing SDK (ANDROID_HOME, ANDROID_SDK_ROOT, or the standard
20
+ # per-OS install location) is always preferred; otherwise a Ruflet-managed
21
+ # SDK is created under ~/.ruflet/android-sdk.
22
+ module AndroidSdk
23
+ CMDLINE_TOOLS_VERSION = "11076708"
24
+ CMDLINE_TOOLS_BASE = "https://dl.google.com/android/repository".freeze
25
+ ANDROID_PLATFORM = "android-35"
26
+ ANDROID_BUILD_TOOLS = "35.0.0"
27
+ MINIMUM_JAVA_MAJOR = 17
28
+
29
+ JDK_PACKAGES = {
30
+ apt: "openjdk-17-jdk",
31
+ dnf: "java-17-openjdk-devel",
32
+ pacman: "jdk17-openjdk",
33
+ zypper: "java-17-openjdk-devel",
34
+ apk: "openjdk17",
35
+ brew: "openjdk@17",
36
+ winget: "EclipseAdoptium.Temurin.17.JDK",
37
+ choco: "temurin17"
38
+ }.freeze
39
+
40
+ def android_environment_setup!(fix: false, verbose: false)
41
+ issues = []
42
+
43
+ java = ensure_java!(fix: fix, verbose: verbose)
44
+ if java
45
+ puts " Java: #{java[:version]} (#{java[:path]})"
46
+ else
47
+ issues << "missing JDK #{MINIMUM_JAVA_MAJOR}+"
48
+ warn " Java: missing — Android builds need a JDK (Gradle wrapper requirement)."
49
+ warn " #{jdk_install_hint}"
50
+ return issues unless fix
51
+ end
52
+
53
+ sdk_root = detect_android_sdk_root
54
+ if sdk_root
55
+ puts " Android SDK: #{sdk_root}"
56
+ elsif fix
57
+ unless java
58
+ warn " Android SDK: skipped (sdkmanager needs Java)"
59
+ return issues
60
+ end
61
+ sdk_root = install_managed_android_sdk(java, verbose: verbose)
62
+ unless sdk_root
63
+ issues << "Android SDK install failed"
64
+ warn " Android SDK: install failed"
65
+ return issues
66
+ end
67
+ puts " Android SDK: #{sdk_root} (installed)"
68
+ else
69
+ issues << "missing Android SDK"
70
+ warn " Android SDK: missing — run `ruflet doctor --fix` to install it under #{managed_android_sdk_root}."
71
+ return issues
72
+ end
73
+
74
+ if fix && java
75
+ ensure_android_packages(sdk_root, java, verbose: verbose)
76
+ accept_android_licenses(sdk_root, java, verbose: verbose)
77
+ end
78
+
79
+ issues
80
+ end
81
+
82
+ # Environment for invoking Flutter/Gradle Android builds.
83
+ def android_build_env(env)
84
+ merged = env.dup
85
+ sdk_root = detect_android_sdk_root
86
+ if sdk_root
87
+ merged["ANDROID_HOME"] ||= sdk_root
88
+ merged["ANDROID_SDK_ROOT"] ||= sdk_root
89
+ platform_tools = File.join(sdk_root, "platform-tools")
90
+ if Dir.exist?(platform_tools)
91
+ merged["PATH"] = "#{platform_tools}#{File::PATH_SEPARATOR}#{merged.fetch('PATH', ENV.fetch('PATH', ''))}"
92
+ end
93
+ end
94
+ java_home = detect_java_home
95
+ merged["JAVA_HOME"] ||= java_home if java_home
96
+ merged
97
+ end
98
+
99
+ def detect_android_sdk_root
100
+ candidates = [
101
+ ENV["ANDROID_HOME"],
102
+ ENV["ANDROID_SDK_ROOT"],
103
+ default_android_sdk_location,
104
+ managed_android_sdk_root
105
+ ]
106
+ candidates.compact.find { |root| android_sdk_present?(root) }
107
+ end
108
+
109
+ def managed_android_sdk_root
110
+ File.join(Dir.home, ".ruflet", "android-sdk")
111
+ end
112
+
113
+ private
114
+
115
+ def android_sdk_present?(root)
116
+ return false if root.to_s.strip.empty?
117
+
118
+ Dir.exist?(File.join(root, "platform-tools")) ||
119
+ Dir.exist?(File.join(root, "cmdline-tools")) ||
120
+ Dir.exist?(File.join(root, "platforms"))
121
+ end
122
+
123
+ def default_android_sdk_location
124
+ if windows_host?
125
+ local = ENV["LOCALAPPDATA"].to_s
126
+ return File.join(local, "Android", "Sdk") unless local.empty?
127
+
128
+ nil
129
+ elsif macos_host?
130
+ File.join(Dir.home, "Library", "Android", "sdk")
131
+ else
132
+ File.join(Dir.home, "Android", "Sdk")
133
+ end
134
+ end
135
+
136
+ def ensure_java!(fix:, verbose: false)
137
+ java = current_java_info
138
+ return java if java && java[:major] >= MINIMUM_JAVA_MAJOR
139
+
140
+ return nil unless fix
141
+
142
+ manager = system_package_manager
143
+ package = manager && JDK_PACKAGES[manager[:id]]
144
+ return nil unless package
145
+
146
+ if manager[:id] == :winget
147
+ run_privileged_command(*manager[:install], package, verbose: verbose)
148
+ else
149
+ run_privileged_command(*manager[:install], package, verbose: verbose)
150
+ end
151
+ current_java_info
152
+ end
153
+
154
+ def current_java_info
155
+ java = which_command("java") || bundled_java_candidate
156
+ return nil unless java
157
+
158
+ output, status = Open3.capture2e(java, "-version")
159
+ return nil unless status.success?
160
+
161
+ major = parse_java_major(output)
162
+ return nil unless major
163
+
164
+ { path: java, major: major, version: output.lines.first.to_s.strip }
165
+ rescue StandardError
166
+ nil
167
+ end
168
+
169
+ def bundled_java_candidate
170
+ if macos_host?
171
+ brew_java = "/opt/homebrew/opt/openjdk@17/bin/java"
172
+ return brew_java if File.executable?(brew_java)
173
+ end
174
+ nil
175
+ end
176
+
177
+ def parse_java_major(output)
178
+ match = output[/version "(\d+)(?:\.(\d+))?/, 1]
179
+ return nil unless match
180
+
181
+ major = match.to_i
182
+ # "1.8" style versions report the major in the second group.
183
+ major = output[/version "1\.(\d+)/, 1].to_i if major == 1
184
+ major
185
+ end
186
+
187
+ def detect_java_home
188
+ if macos_host?
189
+ output, status = Open3.capture2e("/usr/libexec/java_home", "-v", MINIMUM_JAVA_MAJOR.to_s)
190
+ return output.strip if status.success? && !output.strip.empty?
191
+ end
192
+
193
+ java = which_command("java")
194
+ return nil unless java
195
+
196
+ resolved = File.realpath(java) rescue java
197
+ home = File.expand_path("../..", resolved)
198
+ File.directory?(File.join(home, "bin")) ? home : nil
199
+ end
200
+
201
+ def install_managed_android_sdk(java, verbose: false)
202
+ sdk_root = managed_android_sdk_root
203
+ tools_dir = File.join(sdk_root, "cmdline-tools", "latest")
204
+ return sdk_root if File.executable?(sdkmanager_bin(sdk_root))
205
+
206
+ archive_name = "commandlinetools-#{cmdline_tools_os}-#{CMDLINE_TOOLS_VERSION}_latest.zip"
207
+ FileUtils.mkdir_p(sdk_root)
208
+ Dir.mktmpdir("ruflet-android-") do |tmp|
209
+ archive = File.join(tmp, archive_name)
210
+ puts " Downloading Android command-line tools (#{archive_name})"
211
+ download_file("#{CMDLINE_TOOLS_BASE}/#{archive_name}", archive)
212
+ extract_archive(archive, tmp)
213
+ extracted = File.join(tmp, "cmdline-tools")
214
+ raise "cmdline-tools missing from archive" unless Dir.exist?(extracted)
215
+
216
+ FileUtils.rm_rf(tools_dir)
217
+ FileUtils.mkdir_p(File.dirname(tools_dir))
218
+ FileUtils.mv(extracted, tools_dir)
219
+ end
220
+
221
+ File.executable?(sdkmanager_bin(sdk_root)) ? sdk_root : nil
222
+ rescue StandardError => e
223
+ warn " Android SDK download failed: #{e.class}: #{e.message}"
224
+ nil
225
+ end
226
+
227
+ def cmdline_tools_os
228
+ return "win" if windows_host?
229
+ return "mac" if macos_host?
230
+
231
+ "linux"
232
+ end
233
+
234
+ def sdkmanager_bin(sdk_root)
235
+ name = windows_host? ? "sdkmanager.bat" : "sdkmanager"
236
+ File.join(sdk_root, "cmdline-tools", "latest", "bin", name)
237
+ end
238
+
239
+ def ensure_android_packages(sdk_root, java, verbose: false)
240
+ sdkmanager = sdkmanager_bin(sdk_root)
241
+ return unless File.executable?(sdkmanager)
242
+
243
+ packages = ["platform-tools", "platforms;#{ANDROID_PLATFORM}", "build-tools;#{ANDROID_BUILD_TOOLS}"]
244
+ missing = packages.reject { |package| android_package_installed?(sdk_root, package) }
245
+ return if missing.empty?
246
+
247
+ puts " Installing Android packages: #{missing.join(', ')}"
248
+ env = sdkmanager_env(java)
249
+ output = verbose ? $stdout : File::NULL
250
+ system(env, sdkmanager, "--sdk_root=#{sdk_root}", *missing, out: output, err: $stderr)
251
+ end
252
+
253
+ def android_package_installed?(sdk_root, package)
254
+ case package
255
+ when "platform-tools"
256
+ Dir.exist?(File.join(sdk_root, "platform-tools"))
257
+ when /\Aplatforms;(.+)\z/
258
+ Dir.exist?(File.join(sdk_root, "platforms", Regexp.last_match(1)))
259
+ when /\Abuild-tools;(.+)\z/
260
+ Dir.exist?(File.join(sdk_root, "build-tools", Regexp.last_match(1)))
261
+ else
262
+ false
263
+ end
264
+ end
265
+
266
+ def accept_android_licenses(sdk_root, java, verbose: false)
267
+ sdkmanager = sdkmanager_bin(sdk_root)
268
+ return unless File.executable?(sdkmanager)
269
+
270
+ puts " Accepting Android SDK licenses"
271
+ env = sdkmanager_env(java)
272
+ Open3.popen2e(env, sdkmanager, "--sdk_root=#{sdk_root}", "--licenses") do |stdin, stdout, wait|
273
+ begin
274
+ stdin.write("y\n" * 64)
275
+ stdin.close
276
+ rescue Errno::EPIPE
277
+ nil
278
+ end
279
+ stdout.each_line { |line| puts line if verbose }
280
+ wait.value
281
+ end
282
+ rescue StandardError => e
283
+ warn " License acceptance failed: #{e.class}: #{e.message}"
284
+ end
285
+
286
+ def sdkmanager_env(java)
287
+ env = {}
288
+ java_home = detect_java_home
289
+ env["JAVA_HOME"] = java_home if java_home
290
+ if java && java[:path]
291
+ env["PATH"] = "#{File.dirname(java[:path])}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}"
292
+ end
293
+ env
294
+ end
295
+
296
+ def jdk_install_hint
297
+ manager = system_package_manager
298
+ package = manager && JDK_PACKAGES[manager[:id]]
299
+ return "#{(sudo_prefix + manager[:install]).join(' ')} #{package}" if package
300
+
301
+ "install a JDK #{MINIMUM_JAVA_MAJOR}+ (e.g. Temurin: https://adoptium.net)"
302
+ end
303
+ end
304
+ end
305
+ end
@@ -12,6 +12,8 @@ module Ruflet
12
12
  module CLI
13
13
  module BuildCommand
14
14
  include FlutterSdk
15
+ include EnvironmentSetup
16
+ include AndroidSdk
15
17
  CLIENT_EXTENSION_MAP = {
16
18
  "ads" => { package: "flet_ads", alias: "ruflet_ads" },
17
19
  "audio" => { package: "flet_audio", alias: "ruflet_audio" },
@@ -31,8 +33,30 @@ module Ruflet
31
33
  "webview" => { package: "flet_webview", alias: "ruflet_webview" }
32
34
  }.freeze
33
35
 
34
- SERVICE_NATIVE_REQUIREMENTS = {
35
- "audio_recorder" => {
36
+ SERVICE_EXTENSION_MAP = {
37
+ "camera" => %w[camera permission_handler],
38
+ "microphone" => %w[audio_recorder permission_handler],
39
+ "location" => %w[geolocator permission_handler],
40
+ "motion" => %w[permission_handler]
41
+ }.freeze
42
+
43
+ DEFAULT_SERVICE_NATIVE_REQUIREMENTS = {
44
+ "camera" => {
45
+ android_permissions: ["android.permission.CAMERA"],
46
+ ios_info: {
47
+ "NSCameraUsageDescription" => "Camera access is required for camera experiences."
48
+ },
49
+ macos_info: {
50
+ "NSCameraUsageDescription" => "Camera access is required for camera experiences."
51
+ },
52
+ macos_entitlements: {
53
+ "com.apple.security.device.camera" => true
54
+ },
55
+ ios_permission_definitions: %w[
56
+ PERMISSION_CAMERA=1
57
+ ]
58
+ },
59
+ "microphone" => {
36
60
  android_permissions: ["android.permission.RECORD_AUDIO"],
37
61
  ios_info: {
38
62
  "NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
@@ -42,14 +66,20 @@ module Ruflet
42
66
  },
43
67
  macos_entitlements: {
44
68
  "com.apple.security.device.audio-input" => true
45
- }
69
+ },
70
+ ios_permission_definitions: %w[
71
+ PERMISSION_MICROPHONE=1
72
+ ]
46
73
  },
47
- "barometer" => {
74
+ "motion" => {
48
75
  ios_info: {
49
- "NSMotionUsageDescription" => "Motion access is required for barometer readings."
50
- }
76
+ "NSMotionUsageDescription" => "Motion access is required for motion and sensor readings."
77
+ },
78
+ ios_permission_definitions: %w[
79
+ PERMISSION_SENSORS=1
80
+ ]
51
81
  },
52
- "geolocator" => {
82
+ "location" => {
53
83
  android_permissions: [
54
84
  "android.permission.ACCESS_FINE_LOCATION",
55
85
  "android.permission.ACCESS_COARSE_LOCATION"
@@ -62,11 +92,40 @@ module Ruflet
62
92
  },
63
93
  macos_entitlements: {
64
94
  "com.apple.security.personal-information.location" => true
65
- }
95
+ },
96
+ ios_permission_definitions: %w[
97
+ PERMISSION_LOCATION=1
98
+ ]
66
99
  }
67
100
  }.freeze
68
101
 
69
102
  def command_build(args)
103
+ with_project_build_lock { run_build_command(args) }
104
+ end
105
+
106
+ # Concurrent builds share build/client and corrupt each other's state
107
+ # (missing app.dill, Xcode build.db I/O errors). flock is released
108
+ # automatically when the process exits, so the lock cannot go stale.
109
+ def with_project_build_lock
110
+ lock_dir = File.join(Dir.pwd, "build")
111
+ FileUtils.mkdir_p(lock_dir)
112
+ lock_path = File.join(lock_dir, ".ruflet_build.lock")
113
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
114
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
115
+ owner = file.read.to_s.strip
116
+ warn "Another ruflet build is already running for this project#{owner.empty? ? '' : " (#{owner})"}."
117
+ warn "Concurrent builds share the same build directory and corrupt each other."
118
+ warn "Wait for it to finish, then retry."
119
+ return 1
120
+ end
121
+ file.truncate(0)
122
+ file.write("pid=#{Process.pid} started=#{Time.now}")
123
+ file.flush
124
+ yield
125
+ end
126
+ end
127
+
128
+ def run_build_command(args)
70
129
  self_contained = args.delete("--self")
71
130
  verbose = args.delete("--verbose") || args.delete("-v")
72
131
  platform = (args.shift || "").downcase
@@ -194,6 +253,9 @@ module Ruflet
194
253
  end
195
254
 
196
255
  def build_tool_env(env, platform, client_dir = nil)
256
+ if %w[android apk aab appbundle].include?(platform)
257
+ return android_build_env(unbundled_command_env(env))
258
+ end
197
259
  return env unless %w[ios macos].include?(platform)
198
260
 
199
261
  apple_env = unbundled_command_env(env)
@@ -511,7 +573,20 @@ module Ruflet
511
573
  end
512
574
 
513
575
  def unbundled_command_env(env)
514
- env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") }
576
+ command_env = env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") }
577
+ ensure_utf8_locale(command_env)
578
+ end
579
+
580
+ # CocoaPods refuses to run in a non-UTF-8 terminal and Xcode project
581
+ # files contain UTF-8; guarantee a UTF-8 locale for child tools.
582
+ def ensure_utf8_locale(env)
583
+ locale = env["LC_ALL"].to_s
584
+ locale = env["LANG"].to_s if locale.empty?
585
+ return env if locale.downcase.include?("utf-8") || locale.downcase.include?("utf8")
586
+
587
+ env["LANG"] = "en_US.UTF-8"
588
+ env["LC_ALL"] = "en_US.UTF-8"
589
+ env
515
590
  end
516
591
 
517
592
  def run_external_command(env, *cmd, chdir:, unbundled: false)
@@ -1024,8 +1099,11 @@ module Ruflet
1024
1099
  end
1025
1100
 
1026
1101
  def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
1027
- services = Array(config["services"])
1028
- extension_keys = services.map { |v| normalize_extension_key(v) }.compact.uniq
1102
+ service_definitions = load_service_definitions(client_dir)
1103
+ service_extension_keys = service_definitions.keys.flat_map { |key| Array(SERVICE_EXTENSION_MAP[key]) }.uniq
1104
+ protected_extension_keys = SERVICE_EXTENSION_MAP.values.flatten.uniq
1105
+ configured_extension_keys = Array(config["extensions"]).map { |value| normalize_extension_key(value) }.compact
1106
+ extension_keys = ((configured_extension_keys - protected_extension_keys) | service_extension_keys).uniq
1029
1107
  extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1030
1108
  extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1031
1109
 
@@ -1038,14 +1116,19 @@ module Ruflet
1038
1116
  sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
1039
1117
  prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
1040
1118
  end
1041
- apply_service_native_requirements(client_dir, extension_keys)
1119
+ apply_service_native_requirements(client_dir, service_definitions.keys, service_definitions)
1042
1120
  end
1043
1121
 
1044
- def apply_service_native_requirements(client_dir, extension_keys)
1045
- stale_keys = SERVICE_NATIVE_REQUIREMENTS.keys - extension_keys
1046
- remove_service_native_requirements(client_dir, stale_keys)
1122
+ def apply_service_native_requirements(client_dir, extension_keys, service_definitions = load_service_definitions(client_dir))
1123
+ service_requirements = service_native_requirements(service_definitions)
1124
+ stale_keys = service_requirements.keys - extension_keys
1125
+ remove_service_native_requirements(client_dir, stale_keys, service_requirements)
1047
1126
 
1048
- requirements = merge_service_native_requirements(extension_keys)
1127
+ requirements = merge_service_native_requirements(extension_keys, service_requirements)
1128
+ sync_ios_permission_definitions(
1129
+ File.join(client_dir, "ios", "Podfile"),
1130
+ Array(requirements[:ios_permission_definitions])
1131
+ )
1049
1132
  return if requirements.empty?
1050
1133
 
1051
1134
  android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
@@ -1071,8 +1154,8 @@ module Ruflet
1071
1154
  end
1072
1155
  end
1073
1156
 
1074
- def remove_service_native_requirements(client_dir, extension_keys)
1075
- requirements = merge_service_native_requirements(extension_keys)
1157
+ def remove_service_native_requirements(client_dir, extension_keys, service_requirements = service_native_requirements(load_service_definitions(client_dir)))
1158
+ requirements = merge_service_native_requirements(extension_keys, service_requirements)
1076
1159
  return if requirements.empty?
1077
1160
 
1078
1161
  android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
@@ -1094,13 +1177,15 @@ module Ruflet
1094
1177
  end
1095
1178
  end
1096
1179
 
1097
- def merge_service_native_requirements(extension_keys)
1180
+ def merge_service_native_requirements(extension_keys, service_requirements = DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
1098
1181
  extension_keys.each_with_object({}) do |key, memo|
1099
- requirements = SERVICE_NATIVE_REQUIREMENTS[key]
1182
+ requirements = service_requirements[key]
1100
1183
  next unless requirements
1101
1184
 
1102
1185
  memo[:android_permissions] ||= []
1103
1186
  memo[:android_permissions] |= Array(requirements[:android_permissions])
1187
+ memo[:ios_permission_definitions] ||= []
1188
+ memo[:ios_permission_definitions] |= Array(requirements[:ios_permission_definitions])
1104
1189
  %i[ios_info macos_info macos_entitlements].each do |section|
1105
1190
  memo[section] ||= {}
1106
1191
  memo[section].merge!(requirements[section] || {})
@@ -1108,6 +1193,72 @@ module Ruflet
1108
1193
  end
1109
1194
  end
1110
1195
 
1196
+ def load_service_definitions(client_dir = nil)
1197
+ path = services_config_path(client_dir)
1198
+ return {} unless path
1199
+
1200
+ data = YAML.safe_load(File.read(path), aliases: true) || {}
1201
+ Array(data["services"]).each_with_object({}) do |entry, memo|
1202
+ name, metadata =
1203
+ if entry.is_a?(Hash)
1204
+ entry.first
1205
+ else
1206
+ [entry, {}]
1207
+ end
1208
+ key = normalize_extension_key(name)
1209
+ memo[key] = metadata.is_a?(Hash) ? metadata : {} if key
1210
+ end
1211
+ rescue Psych::Exception => e
1212
+ warn "Could not parse #{path}: #{e.message}"
1213
+ {}
1214
+ end
1215
+
1216
+ def services_config_path(client_dir = nil)
1217
+ candidates = [ENV["RUFLET_SERVICES"], File.expand_path("services.yaml", Dir.pwd)]
1218
+ candidates << File.join(client_dir, "services.yaml") if client_dir
1219
+ candidates.compact.find { |path| File.file?(path) }
1220
+ end
1221
+
1222
+ def service_native_requirements(service_definitions)
1223
+ all_keys = (DEFAULT_SERVICE_NATIVE_REQUIREMENTS.keys | service_definitions.keys)
1224
+ all_keys.each_with_object({}) do |key, memo|
1225
+ defaults = DEFAULT_SERVICE_NATIVE_REQUIREMENTS[key] || {}
1226
+ metadata = service_definitions[key] || {}
1227
+ native = metadata["native"].is_a?(Hash) ? metadata["native"] : metadata
1228
+ memo[key] = {
1229
+ android_permissions: Array(native["android_permissions"] || defaults[:android_permissions]),
1230
+ ios_permission_definitions: Array(native["ios_permission_definitions"] || defaults[:ios_permission_definitions]),
1231
+ ios_info: native["ios_info"].is_a?(Hash) ? native["ios_info"] : (defaults[:ios_info] || {}),
1232
+ macos_info: native["macos_info"].is_a?(Hash) ? native["macos_info"] : (defaults[:macos_info] || {}),
1233
+ macos_entitlements: native["macos_entitlements"].is_a?(Hash) ? native["macos_entitlements"] : (defaults[:macos_entitlements] || {})
1234
+ }
1235
+ end
1236
+ end
1237
+
1238
+ def sync_ios_permission_definitions(path, definitions)
1239
+ return unless File.file?(path)
1240
+
1241
+ content = File.read(path)
1242
+ marker_pattern = %r{\n?\s*# BEGIN RUFLET PERMISSION DEFINITIONS.*?# END RUFLET PERMISSION DEFINITIONS\n?}m
1243
+ updated = content.gsub(marker_pattern, "\n")
1244
+ definitions = Array(definitions).map(&:to_s).reject(&:empty?).uniq.sort
1245
+ if definitions.any?
1246
+ block = <<~RUBY.chomp
1247
+ # BEGIN RUFLET PERMISSION DEFINITIONS
1248
+ target.build_configurations.each do |config|
1249
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
1250
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] += #{definitions.inspect}
1251
+ end
1252
+ # END RUFLET PERMISSION DEFINITIONS
1253
+ RUBY
1254
+ updated = updated.sub(
1255
+ /^(\s*)flutter_additional_ios_build_settings\(target\)\s*$/,
1256
+ "\\0\n\n#{block}"
1257
+ )
1258
+ end
1259
+ File.write(path, updated) unless updated == content
1260
+ end
1261
+
1111
1262
  def ensure_android_permission(path, permission)
1112
1263
  return unless File.file?(path)
1113
1264
 
@@ -1226,11 +1377,46 @@ module Ruflet
1226
1377
  write_pubspec_yaml(pubspec_path, data)
1227
1378
  end
1228
1379
 
1380
+ RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.5"
1381
+
1229
1382
  def ruby_runtime_dependency(current_dependency = nil)
1230
- local_path = explicit_local_ruby_runtime_path
1383
+ local_path = explicit_local_ruby_runtime_path || repo_checkout_ruby_runtime_path
1231
1384
  return { "path" => local_path } if local_path
1232
1385
 
1233
- current_dependency || "^0.0.3"
1386
+ template_dependency = template_client_pubspec_dependencies["ruby_runtime"]
1387
+ return template_dependency if usable_ruby_runtime_dependency?(template_dependency)
1388
+
1389
+ return current_dependency if usable_ruby_runtime_dependency?(current_dependency)
1390
+
1391
+ RUBY_RUNTIME_FALLBACK_REQUIREMENT
1392
+ end
1393
+
1394
+ # In a ruflet repo checkout the plugin lives next to templates/; build
1395
+ # against it so framework changes are exercised without publishing.
1396
+ def repo_checkout_ruby_runtime_path
1397
+ template_root =
1398
+ if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
1399
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
1400
+ end
1401
+ return nil unless template_root
1402
+
1403
+ candidate = File.expand_path(File.join(template_root, "..", "..", "ruby_runtime"))
1404
+ File.file?(File.join(candidate, "pubspec.yaml")) ? candidate : nil
1405
+ end
1406
+
1407
+ # Relative path dependencies only resolve from the directory the
1408
+ # template was authored in — never copy them into a user's client.
1409
+ def usable_ruby_runtime_dependency?(dependency)
1410
+ case dependency
1411
+ when nil then false
1412
+ when Hash
1413
+ path = dependency["path"] || dependency[:path]
1414
+ return true if path.nil? # hosted/git table form
1415
+
1416
+ Pathname.new(path.to_s).absolute? && File.file?(File.join(path, "pubspec.yaml"))
1417
+ else
1418
+ !dependency.to_s.strip.empty?
1419
+ end
1234
1420
  end
1235
1421
 
1236
1422
  def explicit_local_ruby_runtime_path
@@ -1271,6 +1457,27 @@ module Ruflet
1271
1457
  FileUtils.cp(source, destination)
1272
1458
  build_log(verbose, "refreshed template file #{relative_path}")
1273
1459
  end
1460
+
1461
+ repair_legacy_self_contained_bootstrap(client_dir, verbose: verbose)
1462
+ end
1463
+
1464
+ def repair_legacy_self_contained_bootstrap(client_dir, verbose: false)
1465
+ path = File.join(client_dir, "lib", "main.self.dart")
1466
+ return unless File.file?(path)
1467
+
1468
+ content = File.read(path)
1469
+ updated = content.gsub(
1470
+ /^\s*await RubyRuntime\.eval\("ENV\['RUFLET_DEBUG'\].*?\n/,
1471
+ ""
1472
+ )
1473
+ updated = updated.gsub(
1474
+ /^\s*final digestLength = await RubyRuntime\.eval\(\n.*?^\s*\);\n\s*debugPrint\('Embedded Digest::SHA1 bytesize: \$digestLength'\);\n/m,
1475
+ ""
1476
+ )
1477
+ return if updated == content
1478
+
1479
+ File.write(path, updated)
1480
+ build_log(verbose, "removed legacy pre-server RubyRuntime.eval diagnostics")
1274
1481
  end
1275
1482
 
1276
1483
  def write_pubspec_yaml(path, data)
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Ruflet
6
+ module CLI
7
+ # Provisions the host so `ruflet build` can run: checks (and with
8
+ # `ruflet doctor --fix`, installs) the system tools Flutter requires.
9
+ #
10
+ # Coverage:
11
+ # Linux apt (Ubuntu, Debian, Kali, Mint), dnf (Fedora, RHEL),
12
+ # pacman (Arch, Omarchy, Manjaro), zypper (openSUSE)
13
+ # macOS Homebrew where available, plus Xcode Command Line Tools and
14
+ # CocoaPods guidance
15
+ # Windows winget or Chocolatey
16
+ #
17
+ # Anything that cannot be installed unattended (Xcode CLT, Visual Studio
18
+ # Build Tools) is reported with the exact command the developer must run.
19
+ module EnvironmentSetup
20
+ Tool = Struct.new(:name, :probe, :purpose, :packages, :manual_hint, keyword_init: true)
21
+
22
+ LINUX_PACKAGE_MANAGERS = [
23
+ { id: :apt, probe: "apt-get", install: %w[apt-get install -y], update: %w[apt-get update] },
24
+ { id: :dnf, probe: "dnf", install: %w[dnf install -y], update: nil },
25
+ { id: :pacman, probe: "pacman", install: %w[pacman -S --noconfirm --needed], update: nil },
26
+ { id: :zypper, probe: "zypper", install: %w[zypper --non-interactive install], update: nil },
27
+ { id: :apk, probe: "apk", install: %w[apk add], update: nil }
28
+ ].freeze
29
+
30
+ def environment_setup!(fix: false, verbose: false)
31
+ issues = []
32
+ tools = required_system_tools
33
+
34
+ unless flutter_host
35
+ issues << unsupported_host_message
36
+ warn " System: #{unsupported_host_message}"
37
+ return issues
38
+ end
39
+
40
+ manager = system_package_manager
41
+ missing = tools.reject { |tool| tool_present?(tool) }
42
+
43
+ if missing.empty?
44
+ puts " System tools: ok (#{tools.map(&:name).join(', ')})"
45
+ return issues
46
+ end
47
+
48
+ puts " System tools missing: #{missing.map(&:name).join(', ')}"
49
+
50
+ unless fix
51
+ missing.each { |tool| warn " #{tool.name}: #{manual_hint_for(tool, manager)}" }
52
+ warn " Run `ruflet doctor --fix` to install them."
53
+ return missing.map { |tool| "missing #{tool.name}" }
54
+ end
55
+
56
+ auto, manual = missing.partition { |tool| installable?(tool, manager) }
57
+
58
+ if auto.any?
59
+ if manager
60
+ install_system_packages(manager, auto, verbose: verbose)
61
+ end
62
+ still_missing = auto.reject { |tool| tool_present?(tool) }
63
+ still_missing.each do |tool|
64
+ issues << "missing #{tool.name}"
65
+ warn " #{tool.name}: install failed — #{manual_hint_for(tool, manager)}"
66
+ end
67
+ installed = auto - still_missing
68
+ puts " Installed: #{installed.map(&:name).join(', ')}" if installed.any?
69
+ end
70
+
71
+ manual.each do |tool|
72
+ issues << "missing #{tool.name}"
73
+ warn " #{tool.name}: requires a manual step — #{manual_hint_for(tool, manager)}"
74
+ end
75
+
76
+ issues
77
+ end
78
+
79
+ def required_system_tools
80
+ case flutter_host
81
+ when "linux" then linux_required_tools
82
+ when "macos", "macos_arm64" then macos_required_tools
83
+ when "windows" then windows_required_tools
84
+ else []
85
+ end
86
+ end
87
+
88
+ def system_package_manager
89
+ if windows_host?
90
+ return { id: :winget, install: %w[winget install --accept-source-agreements --accept-package-agreements -e --id] } if which_command("winget")
91
+ return { id: :choco, install: %w[choco install -y] } if which_command("choco")
92
+ return nil
93
+ end
94
+
95
+ if macos_host?
96
+ return { id: :brew, install: %w[brew install] } if which_command("brew")
97
+ return nil
98
+ end
99
+
100
+ LINUX_PACKAGE_MANAGERS.each do |manager|
101
+ next unless which_command(manager[:probe])
102
+
103
+ return manager
104
+ end
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ def macos_host?
111
+ RbConfig::CONFIG["host_os"].match?(/darwin/i)
112
+ end
113
+
114
+ def linux_required_tools
115
+ [
116
+ Tool.new(name: "git", probe: "git", purpose: "required by the Flutter tool itself",
117
+ packages: { apt: "git", dnf: "git", pacman: "git", zypper: "git", apk: "git" }),
118
+ Tool.new(name: "curl", probe: "curl", purpose: "SDK downloads",
119
+ packages: { apt: "curl", dnf: "curl", pacman: "curl", zypper: "curl", apk: "curl" }),
120
+ Tool.new(name: "unzip", probe: "unzip", purpose: "extracting Flutter artifacts",
121
+ packages: { apt: "unzip", dnf: "unzip", pacman: "unzip", zypper: "unzip", apk: "unzip" }),
122
+ Tool.new(name: "xz", probe: "xz", purpose: "extracting the Flutter SDK (.tar.xz)",
123
+ packages: { apt: "xz-utils", dnf: "xz", pacman: "xz", zypper: "xz", apk: "xz" }),
124
+ Tool.new(name: "zip", probe: "zip", purpose: "packaging app bundles",
125
+ packages: { apt: "zip", dnf: "zip", pacman: "zip", zypper: "zip", apk: "zip" }),
126
+ Tool.new(name: "clang", probe: "clang", purpose: "Linux desktop builds",
127
+ packages: { apt: "clang", dnf: "clang", pacman: "clang", zypper: "clang", apk: "clang" }),
128
+ Tool.new(name: "cmake", probe: "cmake", purpose: "Linux desktop builds",
129
+ packages: { apt: "cmake", dnf: "cmake", pacman: "cmake", zypper: "cmake", apk: "cmake" }),
130
+ Tool.new(name: "ninja", probe: "ninja", purpose: "Linux desktop builds",
131
+ packages: { apt: "ninja-build", dnf: "ninja-build", pacman: "ninja", zypper: "ninja", apk: "ninja" }),
132
+ Tool.new(name: "pkg-config", probe: "pkg-config", purpose: "Linux desktop builds",
133
+ packages: { apt: "pkg-config", dnf: "pkgconf-pkg-config", pacman: "pkgconf", zypper: "pkg-config", apk: "pkgconf" }),
134
+ Tool.new(name: "GTK 3 headers", probe: nil, purpose: "Linux desktop builds",
135
+ packages: { apt: "libgtk-3-dev", dnf: "gtk3-devel", pacman: "gtk3", zypper: "gtk3-devel", apk: "gtk+3.0-dev" })
136
+ ]
137
+ end
138
+
139
+ def macos_required_tools
140
+ [
141
+ Tool.new(name: "git", probe: "git", purpose: "required by the Flutter tool itself",
142
+ packages: { brew: "git" },
143
+ manual_hint: "install Xcode Command Line Tools: xcode-select --install"),
144
+ Tool.new(name: "curl", probe: "curl", purpose: "SDK downloads", packages: { brew: "curl" }),
145
+ Tool.new(name: "unzip", probe: "unzip", purpose: "extracting Flutter artifacts", packages: { brew: "unzip" }),
146
+ Tool.new(name: "Xcode Command Line Tools", probe: nil, purpose: "macOS/iOS builds",
147
+ packages: {},
148
+ manual_hint: "run `xcode-select --install` (or install Xcode from the App Store)"),
149
+ Tool.new(name: "CocoaPods", probe: "pod", purpose: "macOS/iOS plugin dependencies",
150
+ packages: { brew: "cocoapods" },
151
+ manual_hint: "run `brew install cocoapods` or `sudo gem install cocoapods`")
152
+ ]
153
+ end
154
+
155
+ def windows_required_tools
156
+ [
157
+ Tool.new(name: "git", probe: "git", purpose: "required by the Flutter tool itself",
158
+ packages: { winget: "Git.Git", choco: "git" }),
159
+ Tool.new(name: "Visual Studio Build Tools", probe: nil, purpose: "Windows desktop builds",
160
+ packages: {},
161
+ manual_hint: "winget install --id Microsoft.VisualStudio.2022.BuildTools (select the " \
162
+ "\"Desktop development with C++\" workload)")
163
+ ]
164
+ end
165
+
166
+ def tool_present?(tool)
167
+ case tool.name
168
+ when "GTK 3 headers"
169
+ return true if which_command("pkg-config") &&
170
+ system("pkg-config", "--exists", "gtk+-3.0", out: File::NULL, err: File::NULL)
171
+
172
+ false
173
+ when "Xcode Command Line Tools"
174
+ system("xcode-select", "-p", out: File::NULL, err: File::NULL)
175
+ else
176
+ tool.probe ? !which_command(tool.probe).nil? : false
177
+ end
178
+ end
179
+
180
+ def installable?(tool, manager)
181
+ return false unless manager
182
+
183
+ tool.packages.key?(manager[:id])
184
+ end
185
+
186
+ def manual_hint_for(tool, manager)
187
+ return tool.manual_hint if tool.manual_hint && !installable?(tool, manager)
188
+ return "no supported package manager found; install #{tool.name} manually" unless manager
189
+
190
+ package = tool.packages[manager[:id]]
191
+ return tool.manual_hint || "install #{tool.name} manually" unless package
192
+
193
+ "#{(sudo_prefix + manager[:install]).join(' ')} #{package}"
194
+ end
195
+
196
+ def install_system_packages(manager, tools, verbose: false)
197
+ packages = tools.map { |tool| tool.packages[manager[:id]] }.compact.uniq
198
+ return if packages.empty?
199
+
200
+ if manager[:update]
201
+ run_privileged_command(*manager[:update], verbose: verbose)
202
+ end
203
+
204
+ if manager[:id] == :winget
205
+ # winget installs one id at a time.
206
+ packages.each { |package| run_privileged_command(*manager[:install], package, verbose: verbose) }
207
+ else
208
+ run_privileged_command(*manager[:install], *packages, verbose: verbose)
209
+ end
210
+ end
211
+
212
+ def run_privileged_command(*command, verbose: false)
213
+ full = sudo_prefix + command
214
+ puts " $ #{full.join(' ')}"
215
+ output = verbose ? $stdout : File::NULL
216
+ system(*full, out: output, err: $stderr)
217
+ end
218
+
219
+ def sudo_prefix
220
+ return [] if windows_host? || macos_host?
221
+ return [] if Process.respond_to?(:uid) && Process.uid.zero?
222
+ return ["sudo"] if which_command("sudo")
223
+
224
+ []
225
+ end
226
+
227
+ def unsupported_host_message
228
+ os = RbConfig::CONFIG["host_os"]
229
+ if os.match?(/linux/i) && machine_arch.match?(/arm|aarch64/)
230
+ "Flutter publishes no official Linux #{machine_arch} build; use an x64 machine " \
231
+ "or build Flutter from source (https://github.com/flutter/flutter)."
232
+ else
233
+ "unsupported host platform: #{os}"
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -6,6 +6,8 @@ module Ruflet
6
6
  module CLI
7
7
  module ExtraCommand
8
8
  include FlutterSdk
9
+ include EnvironmentSetup
10
+ include AndroidSdk
9
11
  include NewCommand
10
12
 
11
13
  def command_create(args)
@@ -20,6 +22,12 @@ module Ruflet
20
22
  puts "Ruflet doctor"
21
23
  puts " Ruby: #{RUBY_VERSION}"
22
24
  puts " Flutter host target: #{flutter_host || 'unsupported'}"
25
+
26
+ # System prerequisites first: Flutter cannot be installed (or even
27
+ # extracted) without them.
28
+ environment_issues = environment_setup!(fix: !!fix, verbose: !!verbose)
29
+ return 1 unless flutter_host
30
+
23
31
  if template_root
24
32
  puts " Template: #{template_root}"
25
33
  elsif fix
@@ -46,9 +54,14 @@ module Ruflet
46
54
  end
47
55
  end
48
56
  puts " Flutter: #{flutter_version_summary(tools)}"
49
- ok = system(tools[:env], tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
57
+ environment_issues += android_environment_setup!(fix: !!fix, verbose: !!verbose)
58
+ ok = system(android_build_env(tools[:env]), tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
50
59
  status = $?.exitstatus if $?
51
60
  status ||= ok ? 0 : 1
61
+ if environment_issues.any?
62
+ warn "Unresolved environment issues: #{environment_issues.join('; ')}"
63
+ status = 1 if status.zero?
64
+ end
52
65
  status
53
66
  end
54
67
 
@@ -322,7 +322,10 @@ module Ruflet
322
322
  if os.match?(/darwin/i)
323
323
  return machine_arch.include?("arm") ? "macos_arm64" : "macos"
324
324
  end
325
- return "linux" if os.match?(/linux/i)
325
+ if os.match?(/linux/i)
326
+ # Flutter publishes Linux archives for x64 only.
327
+ return machine_arch.match?(/arm|aarch64/) ? nil : "linux"
328
+ end
326
329
  return "windows" if os.match?(/mswin|mingw|cygwin/i)
327
330
 
328
331
  nil
@@ -391,14 +394,25 @@ module Ruflet
391
394
  if windows_host?
392
395
  return system("powershell", "-NoProfile", "-Command", "Expand-Archive -Path '#{archive}' -DestinationPath '#{destination}' -Force")
393
396
  end
397
+
398
+ require_extract_tool!("unzip")
394
399
  return system("unzip", "-oq", archive, "-d", destination)
395
400
  end
396
401
 
397
402
  if archive.end_with?(".tar.xz") || archive.end_with?(".tar.gz") || archive.end_with?(".tgz")
403
+ require_extract_tool!("tar")
404
+ require_extract_tool!("xz") if archive.end_with?(".tar.xz") && !windows_host?
398
405
  return system("tar", "-xf", archive, "-C", destination)
399
406
  end
400
407
 
401
- false
408
+ raise "Unsupported archive format: #{File.basename(archive)}"
409
+ end
410
+
411
+ def require_extract_tool!(name)
412
+ return if which_command(name)
413
+
414
+ raise "`#{name}` is required to extract the Flutter SDK but was not found. " \
415
+ "Run `ruflet doctor --fix` to install the missing system tools."
402
416
  end
403
417
  end
404
418
  end
@@ -84,7 +84,20 @@ module Ruflet
84
84
  end
85
85
 
86
86
  cached_template = cached_ruflet_client_template_root
87
- return cached_template if Dir.exist?(cached_template)
87
+ if Dir.exist?(cached_template)
88
+ return cached_template if cached_template_current?(cached_template)
89
+
90
+ # The cache was written by a different ruflet version; stale
91
+ # framework Dart breaks newer asset layouts. Refresh, but never
92
+ # block a build on the network: fall back to the stale copy loudly.
93
+ refreshed = download_ruflet_template(force: true)
94
+ return refreshed if refreshed && Dir.exist?(refreshed)
95
+
96
+ warn "ruflet: could not refresh the Flutter client template cache; " \
97
+ "using a stale copy from another ruflet version at #{cached_template}. " \
98
+ "Delete it or run with network access to update."
99
+ return cached_template
100
+ end
88
101
 
89
102
  [
90
103
  File.expand_path("../../../ruflet_client", __dir__),
@@ -112,6 +125,21 @@ module Ruflet
112
125
  File.join(template_cache_root, "ruflet_flutter_template")
113
126
  end
114
127
 
128
+ TEMPLATE_CACHE_STAMP = ".ruflet_template_version"
129
+
130
+ def cached_template_current?(target)
131
+ stamp = File.join(target, TEMPLATE_CACHE_STAMP)
132
+ File.file?(stamp) && File.read(stamp).strip == Ruflet::VERSION
133
+ rescue StandardError
134
+ false
135
+ end
136
+
137
+ def write_template_cache_stamp(target)
138
+ File.write(File.join(target, TEMPLATE_CACHE_STAMP), Ruflet::VERSION)
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
115
143
  def cached_ruby_runtime_root
116
144
  File.join(cache_root, "ruby_runtime")
117
145
  end
@@ -150,6 +178,7 @@ module Ruflet
150
178
  FileUtils.cp_r(source, target)
151
179
  end
152
180
 
181
+ write_template_cache_stamp(target)
153
182
  target
154
183
  rescue StandardError => e
155
184
  warn "Failed to fetch Ruflet template: #{e.class}: #{e.message}"
@@ -195,12 +224,11 @@ module Ruflet
195
224
  # Example: https://api.example.com
196
225
  backend_url: ""
197
226
 
198
- # Source of truth for Flutter client extensions/plugins.
199
- # To test every extension, list all services:
200
- # ads, audio, audio_recorder, camera, charts, code_editor, color_pickers,
201
- # datatable2, flashlight, geolocator, lottie, map, permission_handler,
202
- # secure_storage, video, webview
203
- services: []
227
+ # Flutter extension packages included in the managed client.
228
+ # Permission-backed extensions such as camera, audio_recorder,
229
+ # geolocator, and permission_handler are activated by services.yaml
230
+ # and ignored here.
231
+ extensions: []
204
232
 
205
233
  # Build assets configuration consumed by `ruflet build`.
206
234
  # Paths are relative to this file unless absolute.
@@ -215,6 +243,11 @@ module Ruflet
215
243
  icon_background: "#FFFFFF"
216
244
  theme_color: "#FFFFFF"
217
245
  YAML
246
+
247
+ File.write(File.join(root, "services.yaml"), <<~YAML)
248
+ # Protected device access requested by this app.
249
+ services: []
250
+ YAML
218
251
  end
219
252
 
220
253
  def copy_default_project_assets(root)
@@ -349,13 +349,30 @@ module Ruflet
349
349
  end
350
350
 
351
351
  def project_run_requires_managed_client?
352
- return false unless defined?(Ruflet::CLI::BuildCommand::CLIENT_EXTENSION_MAP)
352
+ extensions = Array(project_run_config["extensions"]).map { |value| normalize_run_extension_key(value) }.compact
353
+ protected_extensions =
354
+ if defined?(Ruflet::CLI::BuildCommand::SERVICE_EXTENSION_MAP)
355
+ Ruflet::CLI::BuildCommand::SERVICE_EXTENSION_MAP.values.flatten
356
+ else
357
+ []
358
+ end
359
+ return true if (extensions - protected_extensions).any?
353
360
 
354
- services = Array(project_run_config["services"]).map { |value| normalize_run_extension_key(value) }.compact
361
+ services =
362
+ if respond_to?(:load_service_definitions, true)
363
+ send(:load_service_definitions).keys
364
+ else
365
+ []
366
+ end
355
367
  return false if services.empty?
356
368
 
357
- extension_keys = Ruflet::CLI::BuildCommand::CLIENT_EXTENSION_MAP.keys
358
- (services & extension_keys).any?
369
+ known_services =
370
+ if defined?(Ruflet::CLI::BuildCommand::DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
371
+ Ruflet::CLI::BuildCommand::DEFAULT_SERVICE_NATIVE_REQUIREMENTS.keys
372
+ else
373
+ []
374
+ end
375
+ (services & known_services).any?
359
376
  end
360
377
 
361
378
  def project_run_config
data/lib/ruflet/cli.rb CHANGED
@@ -2,10 +2,16 @@
2
2
 
3
3
  require "optparse"
4
4
 
5
+ # Project files (pbxproj, plists, embedded runtime sources) are UTF-8; never
6
+ # depend on the caller's locale for reading them.
7
+ Encoding.default_external = Encoding::UTF_8 if Encoding.default_external == Encoding::US_ASCII
8
+
5
9
  require_relative "version"
6
10
  require_relative "cli/templates"
7
11
  require_relative "cli/new_command"
8
12
  require_relative "cli/flutter_sdk"
13
+ require_relative "cli/environment_setup"
14
+ require_relative "cli/android_sdk"
9
15
  require_relative "cli/run_command"
10
16
  require_relative "cli/update_command"
11
17
  require_relative "cli/build_command"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.14" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.15" unless const_defined?(:VERSION)
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -37,7 +37,9 @@ files:
37
37
  - assets/splash.png
38
38
  - bin/ruflet
39
39
  - lib/ruflet/cli.rb
40
+ - lib/ruflet/cli/android_sdk.rb
40
41
  - lib/ruflet/cli/build_command.rb
42
+ - lib/ruflet/cli/environment_setup.rb
41
43
  - lib/ruflet/cli/extra_command.rb
42
44
  - lib/ruflet/cli/flutter_sdk.rb
43
45
  - lib/ruflet/cli/new_command.rb