ruflet 0.0.13 → 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: 877c730a76717b1a9623d285666db0cb3b5a47fb08eafe0e9d3bb49c681c0b88
4
- data.tar.gz: a649878ca4f0a7fc8d752e6ba2e0f3eaa1963213fa96dfee3f4a52d2905e4b9a
3
+ metadata.gz: 629de90fa5d63db66ce7f96b69fcd83da284621d1bf8f3c179d2ea7b54f1591b
4
+ data.tar.gz: 4f91dfe6d31fe054b683f9838120fb6476501ac959e981eff19ef1809c880aa9
5
5
  SHA512:
6
- metadata.gz: a1a15a38fa5973a84e921d3b57fd54a135d5d4090fdd996294991f0fede09a89e1553037388f7a07a9dace52b9b4e753273d4da2b0940ca6f326344b708b0de2
7
- data.tar.gz: d10353a23bb436679d2a82226f490ad7c2ef1d470b33808ff3d523a94432f871982a1ed998a2c9fc80a9321ed07fbf2ad8ba23633dbe492e2ebc20c1b03fea96
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,7 +33,99 @@ module Ruflet
31
33
  "webview" => { package: "flet_webview", alias: "ruflet_webview" }
32
34
  }.freeze
33
35
 
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" => {
60
+ android_permissions: ["android.permission.RECORD_AUDIO"],
61
+ ios_info: {
62
+ "NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
63
+ },
64
+ macos_info: {
65
+ "NSMicrophoneUsageDescription" => "Microphone access is required for audio recording."
66
+ },
67
+ macos_entitlements: {
68
+ "com.apple.security.device.audio-input" => true
69
+ },
70
+ ios_permission_definitions: %w[
71
+ PERMISSION_MICROPHONE=1
72
+ ]
73
+ },
74
+ "motion" => {
75
+ ios_info: {
76
+ "NSMotionUsageDescription" => "Motion access is required for motion and sensor readings."
77
+ },
78
+ ios_permission_definitions: %w[
79
+ PERMISSION_SENSORS=1
80
+ ]
81
+ },
82
+ "location" => {
83
+ android_permissions: [
84
+ "android.permission.ACCESS_FINE_LOCATION",
85
+ "android.permission.ACCESS_COARSE_LOCATION"
86
+ ],
87
+ ios_info: {
88
+ "NSLocationWhenInUseUsageDescription" => "Location access is required for location-aware experiences."
89
+ },
90
+ macos_info: {
91
+ "NSLocationUsageDescription" => "Location access is required for location-aware experiences."
92
+ },
93
+ macos_entitlements: {
94
+ "com.apple.security.personal-information.location" => true
95
+ },
96
+ ios_permission_definitions: %w[
97
+ PERMISSION_LOCATION=1
98
+ ]
99
+ }
100
+ }.freeze
101
+
34
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)
35
129
  self_contained = args.delete("--self")
36
130
  verbose = args.delete("--verbose") || args.delete("-v")
37
131
  platform = (args.shift || "").downcase
@@ -159,6 +253,9 @@ module Ruflet
159
253
  end
160
254
 
161
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
162
259
  return env unless %w[ios macos].include?(platform)
163
260
 
164
261
  apple_env = unbundled_command_env(env)
@@ -476,7 +573,20 @@ module Ruflet
476
573
  end
477
574
 
478
575
  def unbundled_command_env(env)
479
- 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
480
590
  end
481
591
 
482
592
  def run_external_command(env, *cmd, chdir:, unbundled: false)
@@ -989,8 +1099,11 @@ module Ruflet
989
1099
  end
990
1100
 
991
1101
  def apply_service_extension_config(client_dir, config = {}, self_contained: @ruflet_self_contained_build)
992
- services = Array(config["services"])
993
- 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
994
1107
  extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
995
1108
  extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
996
1109
 
@@ -1003,6 +1116,193 @@ module Ruflet
1003
1116
  sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
1004
1117
  prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
1005
1118
  end
1119
+ apply_service_native_requirements(client_dir, service_definitions.keys, service_definitions)
1120
+ end
1121
+
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)
1126
+
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
+ )
1132
+ return if requirements.empty?
1133
+
1134
+ android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
1135
+ Array(requirements[:android_permissions]).each do |permission|
1136
+ ensure_android_permission(android_manifest, permission)
1137
+ end
1138
+
1139
+ ios_info = File.join(client_dir, "ios", "Runner", "Info.plist")
1140
+ Hash(requirements[:ios_info]).each do |key, value|
1141
+ ensure_plist_string(ios_info, key, value)
1142
+ end
1143
+
1144
+ macos_info = File.join(client_dir, "macos", "Runner", "Info.plist")
1145
+ Hash(requirements[:macos_info]).each do |key, value|
1146
+ ensure_plist_string(macos_info, key, value)
1147
+ end
1148
+
1149
+ %w[DebugProfile Release].each do |name|
1150
+ entitlements_path = File.join(client_dir, "macos", "Runner", "#{name}.entitlements")
1151
+ Hash(requirements[:macos_entitlements]).each do |key, value|
1152
+ ensure_plist_boolean(entitlements_path, key, value)
1153
+ end
1154
+ end
1155
+ end
1156
+
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)
1159
+ return if requirements.empty?
1160
+
1161
+ android_manifest = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")
1162
+ Array(requirements[:android_permissions]).each do |permission|
1163
+ remove_android_permission(android_manifest, permission)
1164
+ end
1165
+
1166
+ [File.join(client_dir, "ios", "Runner", "Info.plist"), File.join(client_dir, "macos", "Runner", "Info.plist")].each do |path|
1167
+ (Hash(requirements[:ios_info]).keys + Hash(requirements[:macos_info]).keys).uniq.each do |key|
1168
+ remove_plist_entry(path, key)
1169
+ end
1170
+ end
1171
+
1172
+ %w[DebugProfile Release].each do |name|
1173
+ entitlements_path = File.join(client_dir, "macos", "Runner", "#{name}.entitlements")
1174
+ Hash(requirements[:macos_entitlements]).each_key do |key|
1175
+ remove_plist_entry(entitlements_path, key)
1176
+ end
1177
+ end
1178
+ end
1179
+
1180
+ def merge_service_native_requirements(extension_keys, service_requirements = DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
1181
+ extension_keys.each_with_object({}) do |key, memo|
1182
+ requirements = service_requirements[key]
1183
+ next unless requirements
1184
+
1185
+ memo[:android_permissions] ||= []
1186
+ memo[:android_permissions] |= Array(requirements[:android_permissions])
1187
+ memo[:ios_permission_definitions] ||= []
1188
+ memo[:ios_permission_definitions] |= Array(requirements[:ios_permission_definitions])
1189
+ %i[ios_info macos_info macos_entitlements].each do |section|
1190
+ memo[section] ||= {}
1191
+ memo[section].merge!(requirements[section] || {})
1192
+ end
1193
+ end
1194
+ end
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
+
1262
+ def ensure_android_permission(path, permission)
1263
+ return unless File.file?(path)
1264
+
1265
+ content = File.read(path)
1266
+ return if content.include?(permission)
1267
+
1268
+ permission_line = %( <uses-permission android:name="#{xml_escape(permission)}"/>\n)
1269
+ updated = content.sub(/(<manifest\b[^>]*>\s*)/m) { "#{Regexp.last_match(1)}#{permission_line}" }
1270
+ File.write(path, updated == content ? "#{permission_line}#{content}" : updated)
1271
+ end
1272
+
1273
+ def remove_android_permission(path, permission)
1274
+ return unless File.file?(path)
1275
+
1276
+ content = File.read(path)
1277
+ updated = content.gsub(%r{^\s*<uses-permission\s+android:name="#{Regexp.escape(permission)}"\s*/>\s*\n?}, "")
1278
+ File.write(path, updated) unless updated == content
1279
+ end
1280
+
1281
+ def ensure_plist_string(path, key, value)
1282
+ ensure_plist_entry(path, key, "<string>#{xml_escape(value)}</string>")
1283
+ end
1284
+
1285
+ def ensure_plist_boolean(path, key, value)
1286
+ ensure_plist_entry(path, key, value ? "<true/>" : "<false/>")
1287
+ end
1288
+
1289
+ def ensure_plist_entry(path, key, value_xml)
1290
+ return unless File.file?(path)
1291
+
1292
+ content = File.read(path)
1293
+ return if content.include?("<key>#{key}</key>")
1294
+
1295
+ entry = "\t<key>#{key}</key>\n\t#{value_xml}\n"
1296
+ updated = content.sub(%r{</dict>}, "#{entry}</dict>")
1297
+ File.write(path, updated == content ? "#{content}\n#{entry}" : updated)
1298
+ end
1299
+
1300
+ def remove_plist_entry(path, key)
1301
+ return unless File.file?(path)
1302
+
1303
+ content = File.read(path)
1304
+ updated = content.gsub(%r{\s*<key>#{Regexp.escape(key)}</key>\s*<(?:string>.*?</string|true/|false/)>\s*}m, "\n")
1305
+ File.write(path, updated) unless updated == content
1006
1306
  end
1007
1307
 
1008
1308
  def clear_flutter_build_state(client_dir, verbose: false)
@@ -1077,11 +1377,46 @@ module Ruflet
1077
1377
  write_pubspec_yaml(pubspec_path, data)
1078
1378
  end
1079
1379
 
1380
+ RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.5"
1381
+
1080
1382
  def ruby_runtime_dependency(current_dependency = nil)
1081
- local_path = explicit_local_ruby_runtime_path
1383
+ local_path = explicit_local_ruby_runtime_path || repo_checkout_ruby_runtime_path
1082
1384
  return { "path" => local_path } if local_path
1083
1385
 
1084
- 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
1085
1420
  end
1086
1421
 
1087
1422
  def explicit_local_ruby_runtime_path
@@ -1122,6 +1457,27 @@ module Ruflet
1122
1457
  FileUtils.cp(source, destination)
1123
1458
  build_log(verbose, "refreshed template file #{relative_path}")
1124
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")
1125
1481
  end
1126
1482
 
1127
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
 
@@ -289,6 +289,8 @@ module Ruflet
289
289
  return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
290
290
 
291
291
  nil
292
+ rescue OpenSSL::SSL::SSLError
293
+ JSON.parse(curl_get(url, headers: ["User-Agent: ruflet-cli"]))
292
294
  end
293
295
 
294
296
  def pick_release(manifest, version: nil, revision: nil, channel: nil)
@@ -320,7 +322,10 @@ module Ruflet
320
322
  if os.match?(/darwin/i)
321
323
  return machine_arch.include?("arm") ? "macos_arm64" : "macos"
322
324
  end
323
- 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
324
329
  return "windows" if os.match?(/mswin|mingw|cygwin/i)
325
330
 
326
331
  nil
@@ -380,6 +385,8 @@ module Ruflet
380
385
  end
381
386
  end
382
387
  end
388
+ rescue OpenSSL::SSL::SSLError
389
+ curl_download(url, destination)
383
390
  end
384
391
 
385
392
  def extract_archive(archive, destination)
@@ -387,14 +394,25 @@ module Ruflet
387
394
  if windows_host?
388
395
  return system("powershell", "-NoProfile", "-Command", "Expand-Archive -Path '#{archive}' -DestinationPath '#{destination}' -Force")
389
396
  end
397
+
398
+ require_extract_tool!("unzip")
390
399
  return system("unzip", "-oq", archive, "-d", destination)
391
400
  end
392
401
 
393
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?
394
405
  return system("tar", "-xf", archive, "-C", destination)
395
406
  end
396
407
 
397
- 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."
398
416
  end
399
417
  end
400
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
@@ -653,6 +670,8 @@ module Ruflet
653
670
  return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
654
671
 
655
672
  raise "GitHub API failed (#{response.code})"
673
+ rescue OpenSSL::SSL::SSLError
674
+ JSON.parse(curl_get(url, headers: ["Accept: application/vnd.github+json", "User-Agent: ruflet-cli"]))
656
675
  end
657
676
 
658
677
  def download_file(url, destination, limit: 5)
@@ -674,6 +693,31 @@ module Ruflet
674
693
  end
675
694
  end
676
695
  end
696
+ rescue OpenSSL::SSL::SSLError
697
+ curl_download(url, destination)
698
+ end
699
+
700
+ def curl_get(url, headers: [])
701
+ args = ["curl", "-fsSL"]
702
+ headers.each do |header|
703
+ args += ["-H", header]
704
+ end
705
+ args << url
706
+ output = IO.popen(args, err: File::NULL, &:read)
707
+ return output if $?.success?
708
+
709
+ raise "curl failed while requesting #{url}"
710
+ rescue Errno::ENOENT
711
+ raise "Ruby SSL verification failed and curl is not available"
712
+ end
713
+
714
+ def curl_download(url, destination)
715
+ ok = system("curl", "-fL", "--retry", "2", "--connect-timeout", "20", "-o", destination, url, out: File::NULL, err: File::NULL)
716
+ return destination if ok
717
+
718
+ raise "curl failed while downloading #{url}"
719
+ rescue Errno::ENOENT
720
+ raise "Ruby SSL verification failed and curl is not available"
677
721
  end
678
722
 
679
723
  def extract_archive(archive, destination)
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.13" 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.13
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