ruflet 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,20 +16,23 @@ module Ruflet
16
16
  verbose = args.delete("--verbose") || args.delete("-v")
17
17
  fix = args.delete("--fix")
18
18
  client_dir = detect_client_dir
19
- template_root = resolve_ruflet_client_template_root
19
+ template_root =
20
+ if fix
21
+ ensure_cached_ruflet_client_template!(force: true, verbose: !!verbose)
22
+ else
23
+ resolve_ruflet_client_template_root
24
+ end
20
25
  puts "Ruflet doctor"
21
26
  puts " Ruby: #{RUBY_VERSION}"
22
27
  puts " Flutter host target: #{flutter_host || 'unsupported'}"
23
28
  if template_root
24
29
  puts " Template: #{template_root}"
30
+ revision = cached_template_revision if respond_to?(:cached_template_revision, true)
31
+ puts " Template revision: #{revision}" if revision
25
32
  elsif fix
26
- template_root = ensure_cached_ruflet_client_template!(force: true, verbose: !!verbose)
27
- unless template_root
28
- warn " Template: missing"
29
- warn "Failed to fetch the Ruflet Flutter template from GitHub."
30
- return 1
31
- end
32
- puts " Template: #{template_root}"
33
+ warn " Template: missing"
34
+ warn "Failed to fetch the Ruflet Flutter template from GitHub."
35
+ return 1
33
36
  else
34
37
  warn " Template: missing"
35
38
  warn "Run `ruflet doctor --fix` to fetch the Flutter template from GitHub."
@@ -47,9 +47,18 @@ module Ruflet
47
47
  private
48
48
 
49
49
  def flutter_tools(client_dir: nil, auto_install: true)
50
- # Prefer FVM when available so Flutter/Dart match a pinned SDK.
51
- fvm_tools = flutter_tools_via_fvm(client_dir: client_dir, auto_install: auto_install)
52
- return fvm_tools if fvm_tools
50
+ desired = desired_flutter_spec(client_dir: client_dir)
51
+
52
+ # Environment and .fvmrc versions are intentional pins. Flutter's
53
+ # generated .metadata revision only records which SDK created the
54
+ # project; it must not force every build to download that old SDK.
55
+ if %i[env fvmrc].include?(desired[:source])
56
+ fvm_tools = flutter_tools_via_fvm(client_dir: client_dir, auto_install: auto_install)
57
+ return fvm_tools if fvm_tools
58
+ else
59
+ system_tools = flutter_tools_via_system
60
+ return system_tools if system_tools
61
+ end
53
62
 
54
63
  managed_tools = flutter_tools_via_managed_sdk(client_dir: client_dir, auto_install: auto_install)
55
64
  return managed_tools if managed_tools
@@ -57,6 +66,13 @@ module Ruflet
57
66
  nil
58
67
  end
59
68
 
69
+ def flutter_tools_via_system
70
+ flutter = which_command("flutter")
71
+ return nil unless flutter
72
+
73
+ tools_from_flutter_bin(flutter)
74
+ end
75
+
60
76
  def flutter_tools_via_managed_sdk(client_dir: nil, auto_install: true)
61
77
  return nil unless auto_install
62
78
 
@@ -115,7 +131,21 @@ module Ruflet
115
131
  end
116
132
 
117
133
  def fvm_env
118
- { "PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" }
134
+ utf8_locale_env.merge("PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}")
135
+ end
136
+
137
+ # CocoaPods aborts with "Unicode Normalization not appropriate for
138
+ # ASCII-8BIT" when it inherits a non-UTF-8 locale, which takes the whole
139
+ # iOS and macOS build with it. Hand the toolchain a UTF-8 locale unless
140
+ # the environment already has one.
141
+ def utf8_locale_env
142
+ return {} if utf8_locale?(ENV["LC_ALL"]) || utf8_locale?(ENV["LANG"])
143
+
144
+ { "LANG" => "en_US.UTF-8", "LC_ALL" => "en_US.UTF-8" }
145
+ end
146
+
147
+ def utf8_locale?(value)
148
+ value.to_s.match?(/utf-?8/i)
119
149
  end
120
150
 
121
151
  def tools_from_flutter_bin(flutter_bin)
@@ -127,11 +157,11 @@ module Ruflet
127
157
  {
128
158
  flutter: flutter_bin,
129
159
  dart: (File.executable?(dart) ? dart : "dart"),
130
- env: {
160
+ env: utf8_locale_env.merge(
131
161
  "PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}",
132
162
  "FLUTTER_ROOT" => sdk_root,
133
163
  "FVM_FLUTTER_SDK" => sdk_root
134
- }
164
+ )
135
165
  }
136
166
  end
137
167
 
@@ -199,7 +229,12 @@ module Ruflet
199
229
  return { version: fvm, source: :fvmrc } if fvm
200
230
 
201
231
  metadata = parse_flutter_metadata(find_flutter_metadata(client_dir))
202
- return metadata.merge(source: :metadata) if metadata
232
+ if metadata
233
+ return {
234
+ channel: metadata[:channel] || DEFAULT_FLUTTER_CHANNEL,
235
+ source: :metadata
236
+ }
237
+ end
203
238
 
204
239
  { channel: DEFAULT_FLUTTER_CHANNEL, version: DEFAULT_FLUTTER_CHANNEL, source: :default }
205
240
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "open3"
4
5
  require "tmpdir"
5
6
  require "yaml"
6
7
 
@@ -8,10 +9,10 @@ module Ruflet
8
9
  module CLI
9
10
  module NewCommand
10
11
  TEMPLATE_REPO_URL = ENV.fetch("RUFLET_TEMPLATE_REPO_URL", "https://github.com/AdamMusa/ruflet-template.git")
12
+ TEMPLATE_REPO_REF = ENV.fetch("RUFLET_TEMPLATE_REPO_REF", "main")
11
13
  RUNTIME_REPO_URL = ENV.fetch("RUFLET_RUNTIME_REPO_URL", "https://github.com/AdamMusa/ruflet.git")
12
14
 
13
15
  CLIENT_EXTENSION_MAP = {
14
- "ads" => { package: "flet_ads", alias: "ruflet_ads" },
15
16
  "audio" => { package: "flet_audio", alias: "ruflet_audio" },
16
17
  "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
17
18
  "camera" => { package: "flet_camera", alias: "ruflet_camera" },
@@ -24,6 +25,7 @@ module Ruflet
24
25
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
25
26
  "map" => { package: "flet_map", alias: "ruflet_map" },
26
27
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
28
+ "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
27
29
  "rive" => { package: "flet_rive", alias: "ruflet_rive" },
28
30
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
29
31
  "video" => { package: "flet_video", alias: "ruflet_video" },
@@ -53,11 +55,11 @@ module Ruflet
53
55
  puts "Run:"
54
56
  puts " cd #{project_name}"
55
57
  puts " bundle install"
56
- puts " bundle exec ruflet run main.rb"
58
+ puts " ruflet run main.rb"
57
59
  puts
58
60
  puts "Build:"
59
- puts " bundle exec ruflet build android --self"
60
- puts " bundle exec ruflet build ios --self"
61
+ puts " ruflet build android --self"
62
+ puts " ruflet build ios --self"
61
63
  0
62
64
  end
63
65
 
@@ -69,8 +71,10 @@ module Ruflet
69
71
 
70
72
  target = hidden_flutter_client_dir(root)
71
73
  FileUtils.mkdir_p(File.dirname(target))
74
+ FileUtils.rm_rf(target)
72
75
  FileUtils.cp_r(template_root, target)
73
76
  prune_client_template(target)
77
+ write_client_template_revision(target, installed_template_revision(template_root))
74
78
  end
75
79
 
76
80
  def hidden_flutter_client_dir(root = Dir.pwd)
@@ -78,29 +82,29 @@ module Ruflet
78
82
  end
79
83
 
80
84
  def resolve_ruflet_client_template_root
81
- [
82
- File.expand_path("../../../../../templates/ruflet_flutter_template", __dir__)
83
- ].each do |template|
84
- return template if Dir.exist?(template)
85
+ explicit = ENV["RUFLET_TEMPLATE_ROOT"].to_s.strip
86
+ unless explicit.empty?
87
+ explicit_template = [
88
+ explicit,
89
+ File.join(explicit, "templates", "ruflet_flutter_template")
90
+ ].find { |path| valid_ruflet_template?(path) }
91
+ return explicit_template if explicit_template
85
92
  end
86
93
 
87
- cached_template = cached_ruflet_client_template_root
88
- return cached_template if Dir.exist?(cached_template)
94
+ sibling_template = File.expand_path(
95
+ "../../../../../../ruflet-template/templates/ruflet_flutter_template",
96
+ __dir__
97
+ )
98
+ return sibling_template if valid_ruflet_template?(sibling_template)
89
99
 
90
- [
91
- File.expand_path("../../../ruflet_client", __dir__),
92
- File.expand_path("../../../../../ruflet_client", __dir__)
93
- ].each do |fallback|
94
- return fallback if Dir.exist?(fallback)
95
- end
100
+ cached_template = cached_ruflet_client_template_root
101
+ return cached_template if valid_ruflet_template?(cached_template)
96
102
 
97
103
  nil
98
104
  end
99
105
 
100
106
  def ensure_cached_ruflet_client_template!(force: false, verbose: false)
101
107
  cached_template = cached_ruflet_client_template_root
102
- return cached_template if !force && Dir.exist?(cached_template)
103
-
104
108
  download_ruflet_template(force: force, verbose: verbose)
105
109
  Dir.exist?(cached_template) ? cached_template : nil
106
110
  end
@@ -113,6 +117,10 @@ module Ruflet
113
117
  File.join(template_cache_root, "ruflet_flutter_template")
114
118
  end
115
119
 
120
+ def cached_ruflet_client_template_revision_path
121
+ File.join(template_cache_root, "ruflet_flutter_template.revision")
122
+ end
123
+
116
124
  def cached_ruby_runtime_root
117
125
  File.join(cache_root, "ruby_runtime")
118
126
  end
@@ -126,40 +134,104 @@ module Ruflet
126
134
  end
127
135
 
128
136
  def download_ruflet_assets(force: false, verbose: false)
129
- template_target = cached_ruflet_client_template_root
130
- return true if !force && Dir.exist?(template_target)
131
-
132
- Dir.exist?(template_target) || download_ruflet_template(force: force, verbose: verbose)
137
+ !ensure_cached_ruflet_client_template!(force: force, verbose: verbose).nil?
133
138
  end
134
139
 
135
140
  def download_ruflet_template(force: false, verbose: false)
136
141
  target = cached_ruflet_client_template_root
137
- return target if !force && Dir.exist?(target)
142
+ remote_revision = remote_template_revision(verbose: verbose)
143
+ current_revision = cached_template_revision
144
+ cache_available = valid_ruflet_template?(target)
145
+ if !force && cache_available && remote_revision && current_revision == remote_revision
146
+ return target
147
+ end
148
+ if !force && cache_available && remote_revision.nil?
149
+ build_log(verbose, "Unable to check the Ruflet template revision; using cached #{current_revision || 'template'}") if respond_to?(:build_log, true)
150
+ return target
151
+ end
138
152
 
139
153
  FileUtils.mkdir_p(template_cache_root)
140
154
 
141
155
  Dir.mktmpdir("ruflet-assets") do |tmp|
142
156
  repo_dir = File.join(tmp, "Ruflet")
143
- clone_cmd = ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", TEMPLATE_REPO_URL, repo_dir]
144
- return nil unless run_template_command(clone_cmd, verbose: verbose)
145
- return nil unless run_template_command(["git", "-C", repo_dir, "sparse-checkout", "set", "templates/ruflet_flutter_template"], verbose: verbose)
157
+ clone_cmd = ["git", "clone", "--depth", "1", "--branch", TEMPLATE_REPO_REF, "--filter=blob:none", "--sparse", TEMPLATE_REPO_URL, repo_dir]
158
+ return target_if_present(target) unless run_template_command(clone_cmd, verbose: verbose)
159
+ return target_if_present(target) unless run_template_command(["git", "-C", repo_dir, "sparse-checkout", "set", "templates/ruflet_flutter_template"], verbose: verbose)
146
160
 
147
161
  source = File.join(repo_dir, "templates", "ruflet_flutter_template")
148
- return nil unless Dir.exist?(source)
162
+ return target_if_present(target) unless valid_ruflet_template?(source)
149
163
 
164
+ fetched_revision = git_revision(repo_dir) || remote_revision
165
+ staged_target = File.join(tmp, "ruflet_flutter_template")
166
+ FileUtils.cp_r(source, staged_target)
150
167
  FileUtils.rm_rf(target)
151
- FileUtils.cp_r(source, target)
168
+ FileUtils.mv(staged_target, target)
169
+ File.write(cached_ruflet_client_template_revision_path, "#{fetched_revision}\n") if fetched_revision
152
170
  end
153
171
 
154
172
  target
155
173
  rescue StandardError => e
156
174
  warn "Failed to fetch Ruflet template: #{e.class}: #{e.message}"
157
- nil
175
+ target_if_present(target)
158
176
  end
159
177
 
160
178
  def run_template_command(cmd, verbose: false)
161
179
  output = verbose ? $stdout : File::NULL
162
- system(*cmd, out: output, err: verbose ? $stderr : File::NULL)
180
+ env = { "GIT_TERMINAL_PROMPT" => "0" }
181
+ system(env, *cmd, out: output, err: verbose ? $stderr : File::NULL)
182
+ end
183
+
184
+ def remote_template_revision(verbose: false)
185
+ return @ruflet_template_remote_revision if defined?(@ruflet_template_remote_revision)
186
+
187
+ env = { "GIT_TERMINAL_PROMPT" => "0" }
188
+ stdout, stderr, status = Open3.capture3(env, "git", "ls-remote", TEMPLATE_REPO_URL, "refs/heads/#{TEMPLATE_REPO_REF}")
189
+ warn stderr unless status.success? || !verbose || stderr.empty?
190
+ @ruflet_template_remote_revision = status.success? ? stdout.split.first : nil
191
+ rescue StandardError => e
192
+ warn "Failed to check Ruflet template revision: #{e.message}" if verbose
193
+ @ruflet_template_remote_revision = nil
194
+ end
195
+
196
+ def cached_template_revision
197
+ return nil unless File.file?(cached_ruflet_client_template_revision_path)
198
+
199
+ File.read(cached_ruflet_client_template_revision_path).strip.then { |value| value.empty? ? nil : value }
200
+ end
201
+
202
+ def installed_template_revision(template_root)
203
+ return cached_template_revision if File.expand_path(template_root) == File.expand_path(cached_ruflet_client_template_root)
204
+
205
+ git_revision(File.expand_path("../..", template_root))
206
+ end
207
+
208
+ def write_client_template_revision(target, revision)
209
+ return unless revision
210
+
211
+ File.write(File.join(target, ".ruflet-template-revision"), "#{revision}\n")
212
+ end
213
+
214
+ def client_template_current?(target, template_root)
215
+ expected = installed_template_revision(template_root)
216
+ return false unless expected
217
+
218
+ marker = File.join(target, ".ruflet-template-revision")
219
+ File.file?(marker) && File.read(marker).strip == expected
220
+ end
221
+
222
+ def git_revision(repo_dir)
223
+ stdout, _stderr, status = Open3.capture3("git", "-C", repo_dir, "rev-parse", "HEAD")
224
+ status.success? ? stdout.strip : nil
225
+ rescue StandardError
226
+ nil
227
+ end
228
+
229
+ def valid_ruflet_template?(path)
230
+ Dir.exist?(path) && File.file?(File.join(path, "pubspec.yaml")) && File.file?(File.join(path, "lib", "main.dart"))
231
+ end
232
+
233
+ def target_if_present(target)
234
+ valid_ruflet_template?(target) ? target : nil
163
235
  end
164
236
 
165
237
  def prune_client_template(target)
@@ -186,12 +258,6 @@ module Ruflet
186
258
  def write_default_ruflet_config(root, app_name)
187
259
  File.write(File.join(root, "ruflet.yaml"), <<~YAML)
188
260
  app:
189
- name: #{app_name}
190
- display_name: #{humanize_name(app_name)}
191
- package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
192
- organization: com.example
193
- version: 1.0.0+1
194
- description: A new Ruflet app.
195
261
  # Required for server-driven builds: `ruflet build ios`, `apk`, `web`, etc. without `--self`.
196
262
  # Example: https://api.example.com
197
263
  backend_url: ""
@@ -215,6 +281,16 @@ module Ruflet
215
281
  YAML
216
282
 
217
283
  File.write(File.join(root, "services.yaml"), <<~YAML)
284
+ # Application identity used by the Flutter build pipeline. Ruflet derives
285
+ # the mobile identifier as organization.package_name and passes it to
286
+ # change_app_package_name for Android and iOS builds.
287
+ app:
288
+ app_name: #{humanize_name(app_name)}
289
+ package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
290
+ organization: com.example
291
+ version: 1.0.0+1
292
+ description: A new Ruflet app.
293
+
218
294
  # Native capabilities that require platform permissions. Ruflet activates
219
295
  # the matching client extensions and writes Android/iOS permissions.
220
296
  # Supported services: camera, microphone, location, motion