ruby_native 0.15.2 → 0.16.0

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: 34a06223f80d171207b1eb5e0e56d67f3f02ba2737037fa06b7c16f4f3fbecad
4
- data.tar.gz: 26c59be4e4848abb167cf45d39249609b888167aad3835eb367c70f4fc41f1ed
3
+ metadata.gz: f7c62d39c66e463239ff2c5cc4c66b868271758aa1e19fccdbc0fb25e446854b
4
+ data.tar.gz: 3c52d0ce183cedb9cfeced27ec2a504abbbc0a1675f0109d7f7e557b1d32dff7
5
5
  SHA512:
6
- metadata.gz: b6a051041b02d7d87d2881b4ff279beed81d0f5291746611e601c1f68f8c46eb85539250fb34bb567394c4395b3ed99d44078132d6e0f6d894beb52f91694e25
7
- data.tar.gz: 0cad8e6fac5dce1596ec40009a9e01f9ce1549b6ca3a89b3dee70205d40ff98ef5a6e99d112202e197d25d27ffb37fda4d268681e6137e8e0aa016b07b494414
6
+ metadata.gz: fee84930a92e36cc9602a00daeaeb184990becdcb5e3dcffd0baa12d1454a5c5f5228844ca90a9fd1b5f3c27b47902ef5f0d587056d83bcbd1021f5074158cee
7
+ data.tar.gz: f9e4c4741bb48cb5bb7b287bc56d3253c6f91825a9e028f52b1c2b36e7b94ae7bf3a63184327f5a5d7c20b71ee5956ed9f96709e37faf31cfe100cce35cc1bc4
@@ -0,0 +1,134 @@
1
+ # The signal vocabulary: every data-native-* attribute Ruby Native accepts.
2
+ #
3
+ # `ruby_native check` validates your views against this file, and the native
4
+ # shells assert their own vocabulary matches it. `since` is the gem version
5
+ # that introduced the signal; a signal older than the shell running it is
6
+ # ignored silently on device, which is what `check` exists to catch.
7
+ #
8
+ # `owner: shell` marks a signal the app writes into the page rather than one
9
+ # your views emit.
10
+ ---
11
+ signals:
12
+ data-native-action:
13
+ since: "0.11.2"
14
+ data-native-anchor:
15
+ since: "0.14.0"
16
+ helper: native_menu_tag
17
+ data-native-app:
18
+ owner: shell
19
+ data-native-badge:
20
+ since: "0.3.2"
21
+ helper: native_badge_tag
22
+ singleton: true
23
+ data-native-badge-home:
24
+ since: "0.3.2"
25
+ helper: native_badge_tag
26
+ data-native-badge-tab:
27
+ since: "0.3.2"
28
+ helper: native_badge_tag
29
+ data-native-button:
30
+ since: "0.4.0"
31
+ data-native-click:
32
+ since: "0.4.0"
33
+ helper: native_fab_tag
34
+ data-native-color:
35
+ since: "0.14.0"
36
+ helper: native_fab_tag
37
+ data-native-destructive:
38
+ since: "0.14.0"
39
+ data-native-fab:
40
+ since: "0.8.0"
41
+ helper: native_fab_tag
42
+ singleton: true
43
+ data-native-form:
44
+ since: "0.1.2"
45
+ helper: native_form_tag
46
+ singleton: true
47
+ data-native-haptic:
48
+ since: "0.2.7"
49
+ helper: native_haptic_data
50
+ data-native-href:
51
+ since: "0.4.0"
52
+ helper: native_fab_tag
53
+ data-native-icon:
54
+ since: "0.4.0"
55
+ helper: native_fab_tag
56
+ data-native-identity:
57
+ since: "0.12.0"
58
+ helper: native_identity_tag
59
+ singleton: true
60
+ data-native-keyboard-toolbar:
61
+ since: "0.15.0"
62
+ helper: native_keyboard_tag
63
+ singleton: true
64
+ data-native-menu:
65
+ since: "0.14.0"
66
+ helper: native_menu_tag
67
+ data-native-menu-item:
68
+ since: "0.4.0"
69
+ data-native-navbar:
70
+ since: "0.4.0"
71
+ helper: native_navbar_tag
72
+ singleton: true
73
+ data-native-overscroll-bottom:
74
+ since: "0.5.0"
75
+ helper: native_overscroll_tag
76
+ singleton: true
77
+ data-native-overscroll-top:
78
+ since: "0.5.0"
79
+ helper: native_overscroll_tag
80
+ singleton: true
81
+ data-native-position:
82
+ since: "0.4.0"
83
+ data-native-presentation:
84
+ since: "0.11.2"
85
+ helper: native_presentation_tag
86
+ singleton: true
87
+ data-native-price:
88
+ since: "0.5.3"
89
+ data-native-pull-to-refresh:
90
+ since: "0.10.0"
91
+ helper: native_navbar_tag
92
+ data-native-purchase:
93
+ since: "0.5.3"
94
+ data-native-push:
95
+ since: "0.1.2"
96
+ helper: native_push_tag
97
+ singleton: true
98
+ data-native-restore:
99
+ since: "0.5.3"
100
+ data-native-review:
101
+ since: "0.9.3"
102
+ helper: native_review_tag
103
+ data-native-segment:
104
+ since: "0.10.12"
105
+ data-native-selected:
106
+ since: "0.4.0"
107
+ data-native-share:
108
+ since: "0.10.9"
109
+ data-native-share-url:
110
+ since: "0.10.9"
111
+ data-native-submit-button:
112
+ since: "0.4.0"
113
+ singleton: true
114
+ data-native-tabs:
115
+ since: "0.1.2"
116
+ helper: native_tabs_tag
117
+ singleton: true
118
+ data-native-title:
119
+ since: "0.4.0"
120
+ data-native-toast:
121
+ since: "0.12.1"
122
+ helper: native_toast_tag
123
+ data-native-toast-appearance:
124
+ since: "0.12.1"
125
+ helper: native_toast_tag
126
+ data-native-toast-duration:
127
+ since: "0.12.1"
128
+ helper: native_toast_tag
129
+ data-native-toast-icon:
130
+ since: "0.12.1"
131
+ helper: native_toast_tag
132
+ data-native-toast-message:
133
+ since: "0.12.1"
134
+ helper: native_toast_tag
@@ -102,6 +102,23 @@ tabs:
102
102
 
103
103
  # Per-tab options (add to any tab above):
104
104
  #
105
+ # # Localize this tab's title. Put the copy in your app's own locale files
106
+ # # under `ruby_native.tabs.<key>.title`, the same way the error screen uses
107
+ # # `ruby_native.errors.*`, and the device's language picks the translation:
108
+ # #
109
+ # # # config/locales/it.yml
110
+ # # it:
111
+ # # ruby_native:
112
+ # # tabs:
113
+ # # home:
114
+ # # title: Casa
115
+ # #
116
+ # # `title:` above stays the fallback for a language you have not
117
+ # # translated. Once every tab has a key you can drop `title:` entirely and
118
+ # # keep all of your copy in the locale files.
119
+ # # https://rubynative.com/docs/tabs#localization
120
+ # key: home
121
+ #
105
122
  # # Load this tab on launch instead of lazily.
106
123
  # # Default: false.
107
124
  # eager: true
@@ -0,0 +1,30 @@
1
+ module RubyNative
2
+ class CLI
3
+ class Check
4
+ # Walks a parsed template and records every data-native-* attribute with
5
+ # the lines it appears on. Attribute names built out of ERB cannot be
6
+ # resolved statically and are skipped rather than guessed at.
7
+ #
8
+ # Subclasses Herb::Visitor, so this file is only loaded once `herb` is
9
+ # known to be present.
10
+ class SignalCollector < Herb::Visitor
11
+ attr_reader :signals
12
+
13
+ def initialize
14
+ @signals = {}
15
+ super
16
+ end
17
+
18
+ def visit_html_attribute_name_node(node)
19
+ name = node.children.filter_map { |child| child.content if child.respond_to?(:content) }.join
20
+
21
+ if name.start_with?("data-native-")
22
+ (@signals[name] ||= []) << node.location.start.line
23
+ end
24
+
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,254 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "yaml"
5
+ require "ruby_native/cli/credentials"
6
+ require "ruby_native/signals"
7
+ require "ruby_native/version"
8
+
9
+ module RubyNative
10
+ class CLI
11
+ # Static validation of a Rails app's views against the signal vocabulary.
12
+ #
13
+ # Signals fail silently by design: a shell that does not understand one
14
+ # ignores it. That is right for progressive enhancement and miserable for
15
+ # debugging, so this moves the diagnostics the bridge already runs per page
16
+ # visit to a whole-app pass that can fail CI before a build goes out.
17
+ class Check
18
+ CONFIG_PATH = "config/ruby_native.yml"
19
+ HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
20
+ DEFAULT_PATHS = ["app/views"].freeze
21
+ PLATFORMS = %w[ios android].freeze
22
+
23
+ Offense = Struct.new(:file, :line, :severity, :message, keyword_init: true)
24
+
25
+ # The same checks `run` reports, for `deploy` to run as a preflight.
26
+ # Returns nil when herb is missing rather than failing: a deploy must not
27
+ # start depending on a gem the app never asked for.
28
+ def self.signal_offenses(paths: DEFAULT_PATHS)
29
+ return nil unless herb_available?
30
+
31
+ check = new([], paths: paths)
32
+
33
+ check.send(:template_files).flat_map { |file| check.send(:check_file, file) }
34
+ end
35
+
36
+ def self.herb_available?
37
+ require "herb"
38
+ require "ruby_native/cli/check/signal_collector"
39
+ true
40
+ rescue LoadError
41
+ false
42
+ end
43
+
44
+ def initialize(argv, paths: nil)
45
+ @deployed = argv.include?("--deployed")
46
+ @paths = paths || parse_paths(argv)
47
+ end
48
+
49
+ def run
50
+ require_herb!
51
+
52
+ files = template_files
53
+ if files.empty?
54
+ puts "No .html.erb templates found in #{@paths.join(", ")}."
55
+ return
56
+ end
57
+
58
+ offenses = files.flat_map { |file| check_file(file) }
59
+ offenses += deployed_offenses(files) if @deployed
60
+
61
+ report(files, offenses)
62
+ exit 1 if offenses.any? { |offense| offense.severity == :error }
63
+ end
64
+
65
+ private
66
+
67
+ # --- Loading ---
68
+
69
+ def require_herb!
70
+ return if self.class.herb_available?
71
+
72
+ puts "`ruby_native check` needs the herb gem, which parses HTML and ERB together."
73
+ puts ""
74
+ puts "Add it to your Gemfile:"
75
+ puts " gem \"herb\""
76
+ puts ""
77
+ puts "Rails 8.2 and later already ship it as a dependency of Action View."
78
+ exit 1
79
+ end
80
+
81
+ def template_files
82
+ @paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.html.erb")) }.sort
83
+ end
84
+
85
+ # --- Per-file checks ---
86
+
87
+ # Herb's parser tolerates markup its compiler rejects, so every template
88
+ # gets scanned whether or not it would build under Rails 8.2. Whether
89
+ # their ERB is Herb-clean is their upgrade to make, and `herb lint` is
90
+ # the tool for it.
91
+ def check_file(file)
92
+ signal_offenses(file, File.read(file, encoding: "UTF-8"))
93
+ rescue ArgumentError => error
94
+ [Offense.new(file: file, line: 1, severity: :error, message: "Could not read the file: #{error.message}")]
95
+ end
96
+
97
+ def signal_offenses(file, source)
98
+ result = Herb.parse(source)
99
+ collector = SignalCollector.new
100
+ result.value.accept(collector)
101
+
102
+ offenses = []
103
+
104
+ collector.signals.each do |name, lines|
105
+ unless RubyNative::Signals.known?(name)
106
+ suggestion = RubyNative::Signals.nearest(name)
107
+ message = "Unknown signal `#{name}`."
108
+ message += " Did you mean `#{suggestion}`?" if suggestion
109
+ offenses << Offense.new(file: file, line: lines.first, severity: :error, message: message)
110
+ next
111
+ end
112
+
113
+ if lines.size > 1 && RubyNative::Signals.singleton?(name)
114
+ offenses << Offense.new(
115
+ file: file,
116
+ line: lines[1],
117
+ severity: :warning,
118
+ message: "#{lines.size} elements carry `#{name}`; only the first one is used."
119
+ )
120
+ end
121
+
122
+ offenses.concat(version_offenses(file, name, lines.first))
123
+ end
124
+
125
+ offenses
126
+ end
127
+
128
+ # A signal newer than the installed gem is one the app was never told
129
+ # about, so it is inert on device however correct it looks in the markup.
130
+ def version_offenses(file, name, line)
131
+ since = RubyNative::Signals.since(name)
132
+ return [] unless since
133
+ return [] if Gem::Version.new(RubyNative::VERSION) >= Gem::Version.new(since)
134
+
135
+ [Offense.new(
136
+ file: file,
137
+ line: line,
138
+ severity: :error,
139
+ message: "`#{name}` needs Ruby Native #{since}, but this app is on #{RubyNative::VERSION}."
140
+ )]
141
+ end
142
+
143
+ # --- Deployed build check ---
144
+
145
+ # The gem version in the Gemfile says what the views may emit. The build
146
+ # in the store says what the app can actually honor, and those drift
147
+ # apart the moment someone bumps the gem without deploying.
148
+ def deployed_offenses(files)
149
+ app_id = config.dig(:ruby_native, :app_id)
150
+ unless app_id
151
+ puts "Skipping --deployed: no app_id in #{CONFIG_PATH}. Run `ruby_native deploy` once to link this app."
152
+ return []
153
+ end
154
+
155
+ unless Credentials.token
156
+ puts "Skipping --deployed: not logged in. Run `ruby_native login` first."
157
+ return []
158
+ end
159
+
160
+ used = signals_in(files)
161
+ PLATFORMS.flat_map { |platform| deployed_offenses_for(platform, app_id, used) }
162
+ end
163
+
164
+ def deployed_offenses_for(platform, app_id, used)
165
+ built = latest_build_version(app_id, platform)
166
+ return [] unless built
167
+
168
+ used.filter_map do |name, (file, line)|
169
+ since = RubyNative::Signals.since(name)
170
+ next unless since
171
+ next if Gem::Version.new(built) >= Gem::Version.new(since)
172
+
173
+ Offense.new(
174
+ file: file,
175
+ line: line,
176
+ severity: :error,
177
+ message: "`#{name}` needs #{since}, but the #{platform} build users have was made on #{built}."
178
+ )
179
+ end
180
+ end
181
+
182
+ def signals_in(files)
183
+ files.each_with_object({}) do |file, used|
184
+ collector = SignalCollector.new
185
+ Herb.parse(File.read(file, encoding: "UTF-8")).value.accept(collector)
186
+ collector.signals.each { |name, lines| used[name] ||= [file, lines.first] }
187
+ rescue StandardError
188
+ next
189
+ end
190
+ end
191
+
192
+ def latest_build_version(app_id, platform)
193
+ uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest?platform=#{platform}")
194
+ request = Net::HTTP::Get.new(uri)
195
+ request["Authorization"] = "Token #{Credentials.token}"
196
+
197
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
198
+ http.request(request)
199
+ end
200
+
201
+ return nil unless response.is_a?(Net::HTTPSuccess)
202
+ return nil if response.body.to_s.empty?
203
+
204
+ JSON.parse(response.body)["gem_version"]
205
+ rescue StandardError
206
+ # A check that cannot reach the API should not fail the build; the
207
+ # local checks above still ran and still mean something.
208
+ puts "Skipping --deployed for #{platform}: could not reach #{HOST}."
209
+ nil
210
+ end
211
+
212
+ def config
213
+ @config ||= if File.exist?(CONFIG_PATH)
214
+ YAML.load_file(CONFIG_PATH, symbolize_names: true, aliases: true) || {}
215
+ else
216
+ {}
217
+ end
218
+ rescue Psych::SyntaxError
219
+ {}
220
+ end
221
+
222
+ # --- Reporting ---
223
+
224
+ def report(files, offenses)
225
+ errors, warnings = offenses.partition { |offense| offense.severity == :error }
226
+
227
+ offenses.group_by(&:file).sort.each do |file, file_offenses|
228
+ puts file
229
+ file_offenses.sort_by(&:line).each do |offense|
230
+ location = "#{offense.line}:".ljust(5)
231
+ marker = offense.severity.to_s.ljust(8)
232
+ puts " #{location}#{marker}#{offense.message}"
233
+ end
234
+ puts ""
235
+ end
236
+
237
+ summary = "Checked #{files.size} #{files.size == 1 ? "template" : "templates"}"
238
+ if offenses.empty?
239
+ puts "#{summary}. No problems found."
240
+ else
241
+ puts "#{summary}: #{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
242
+ "#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
243
+ end
244
+ end
245
+
246
+ def parse_paths(argv)
247
+ flag = argv.find { |argument| argument.start_with?("--paths=") }
248
+ return DEFAULT_PATHS unless flag
249
+
250
+ flag.split("=", 2).last.split(",").map(&:strip).reject(&:empty?)
251
+ end
252
+ end
253
+ end
254
+ end
@@ -2,6 +2,7 @@ require "json"
2
2
  require "net/http"
3
3
  require "uri"
4
4
  require "openssl"
5
+ require "ruby_native/cli/check"
5
6
  require "ruby_native/cli/credentials"
6
7
  require "ruby_native/version"
7
8
 
@@ -12,7 +13,7 @@ module RubyNative
12
13
  HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
13
14
  POLL_INTERVAL = 5
14
15
  POLL_TIMEOUT = 600
15
- PLATFORMS = %w[ios android].freeze
16
+ PLATFORMS = %w[ios android all].freeze
16
17
  # Consecutive failures tolerated mid-poll; one blip must not kill a
17
18
  # deploy whose build is succeeding server-side.
18
19
  MAX_POLL_FAILURES = 3
@@ -31,6 +32,7 @@ module RubyNative
31
32
 
32
33
  def initialize(argv)
33
34
  @if_needed = argv.include?("--if-needed")
35
+ @skip_check = argv.include?("--skip-check")
34
36
  @platform = parse_platform(argv)
35
37
  end
36
38
 
@@ -44,10 +46,14 @@ module RubyNative
44
46
  return
45
47
  end
46
48
 
49
+ check_signals! unless @skip_check
50
+
47
51
  build = trigger_build(app_id)
48
52
  return if @if_needed
49
53
 
50
- poll_build_status(app_id, build)
54
+ # `builds` is present when one deploy started more than one; an older
55
+ # server omits it and the primary build is the whole story.
56
+ poll_build_status(app_id, build, Array(build["builds"]).drop(1))
51
57
  rescue TokenExpiredError
52
58
  abort_token_expired!
53
59
  rescue ConnectionError => error
@@ -58,6 +64,25 @@ module RubyNative
58
64
 
59
65
  private
60
66
 
67
+ # A broken signal is invisible until someone opens the build, so it is
68
+ # worth a second here rather than a TestFlight round trip. Skipped
69
+ # silently when herb is missing: this is a courtesy, not a dependency the
70
+ # gem gets to impose on a deploy.
71
+ def check_signals!
72
+ offenses = Check.signal_offenses
73
+ return if offenses.nil?
74
+
75
+ errors = offenses.select { |offense| offense.severity == :error }
76
+ return if errors.empty?
77
+
78
+ puts "Found #{errors.size} signal #{errors.size == 1 ? "problem" : "problems"} in your views:"
79
+ errors.first(10).each { |error| puts " #{error.file}:#{error.line} #{error.message}" }
80
+ puts " ...and #{errors.size - 10} more." if errors.size > 10
81
+ puts
82
+ puts "These will not work in the build. Fix them, or deploy anyway with --skip-check."
83
+ exit 1
84
+ end
85
+
61
86
  def ensure_authenticated!
62
87
  return if Credentials.token
63
88
 
@@ -135,14 +160,30 @@ module RubyNative
135
160
  latest = fetch_latest_build(app_id)
136
161
  return false unless latest
137
162
 
138
- latest_gem_version = latest["gem_version"]
139
- return false unless latest_gem_version
163
+ return skip_all?(latest) if @platform == "all"
140
164
 
141
- Gem::Version.new(latest_gem_version) >= Gem::Version.new(RubyNative::VERSION)
165
+ current?(latest["gem_version"])
142
166
  rescue ArgumentError
143
167
  false
144
168
  end
145
169
 
170
+ # Skip only when every platform that could build is already on this gem
171
+ # version. A track that is set up but has never built is exactly the case
172
+ # that must not skip, which is why the server reports `deployable`
173
+ # separately from whether a build exists.
174
+ def skip_all?(latest)
175
+ deployable = latest.select { |_platform, state| state.is_a?(Hash) && state["deployable"] }
176
+ return false if deployable.empty?
177
+
178
+ deployable.all? { |_platform, state| current?(state["gem_version"]) }
179
+ end
180
+
181
+ def current?(gem_version)
182
+ return false unless gem_version
183
+
184
+ Gem::Version.new(gem_version) >= Gem::Version.new(RubyNative::VERSION)
185
+ end
186
+
146
187
  def fetch_latest_build(app_id)
147
188
  # Platform-scoped, or --android --if-needed compares against the
148
189
  # latest iOS build and skips a build Android never got.
@@ -218,17 +259,21 @@ module RubyNative
218
259
 
219
260
  # --- Polling ---
220
261
 
221
- def poll_build_status(app_id, build)
222
- build_id = build["id"]
223
- last_status = build["status"]
262
+ # Polls every build this deploy started, not just the first. With both
263
+ # platforms dispatched at once, watching only one meant a failed Android
264
+ # build exited 0 and left CI green over a broken release.
265
+ def poll_build_status(app_id, build, others = [])
266
+ states = ([build] + Array(others)).compact.uniq { |candidate| candidate["id"] }.map do |candidate|
267
+ { id: candidate["id"], platform: candidate["platform"], status: candidate["status"], not_found: 0, error: nil, result: nil }
268
+ end
269
+ many = states.size > 1
270
+
224
271
  puts ""
225
- puts "Waiting for build to complete. Ctrl+C to exit (your build will continue)."
226
- print_status(last_status)
272
+ puts "Waiting for #{many ? "builds" : "build"} to complete. Ctrl+C to exit (your #{many ? "builds" : "build"} will continue)."
273
+ states.each { |state| print_status(state[:status], label_for(state, many)) }
227
274
 
228
275
  started_at = Time.now
229
276
  connection_failures = 0
230
- not_found_count = 0
231
- reported_error = nil
232
277
 
233
278
  loop do
234
279
  sleep POLL_INTERVAL
@@ -239,70 +284,106 @@ module RubyNative
239
284
  exit 1
240
285
  end
241
286
 
242
- begin
243
- state, payload = fetch_build_status(app_id, build_id)
244
- connection_failures = 0
245
- rescue ConnectionError => error
287
+ connection_error = nil
288
+ reached = 0
289
+
290
+ states.reject { |state| state[:result] }.each do |state|
291
+ begin
292
+ api_state, payload = fetch_build_status(app_id, state[:id])
293
+ reached += 1
294
+ rescue ConnectionError => error
295
+ connection_error = error
296
+ next
297
+ end
298
+
299
+ advance_build(state, api_state, payload, many)
300
+ end
301
+
302
+ # Only a tick where nothing was reachable counts against the budget,
303
+ # so one platform answering keeps the deploy alive.
304
+ if reached.zero?
246
305
  connection_failures += 1
247
306
  next if connection_failures < MAX_POLL_FAILURES
248
307
 
249
308
  puts ""
250
- puts error.message
309
+ puts connection_error.message
251
310
  puts "Your build is most likely still running server-side. Check the"
252
311
  puts "Ruby Native dashboard for the result: #{HOST}/dashboard"
253
312
  exit 1
254
313
  end
255
314
 
256
- case state
257
- when :not_found
258
- not_found_count += 1
259
- next if not_found_count < MAX_POLL_FAILURES
315
+ connection_failures = 0
316
+ break if states.all? { |state| state[:result] }
317
+ end
260
318
 
261
- puts ""
262
- puts "The server can no longer find this build (404). The app or build"
263
- puts "may have been deleted or archived. Check the Ruby Native"
264
- puts "dashboard: #{HOST}/dashboard"
265
- exit 1
266
- when :error
267
- # The build keeps running server-side through a 500, so keep polling.
268
- if payload != reported_error
269
- reported_error = payload
270
- puts ""
271
- puts "#{URI(HOST).host} returned #{payload} while checking the build. Still trying..."
272
- end
273
- next
274
- end
319
+ exit 1 if states.any? { |state| state[:result] == :failed }
320
+ rescue Interrupt
321
+ puts ""
322
+ puts ""
323
+ puts "Stopped polling. Your build is still running."
324
+ puts "Check the Ruby Native dashboard for status."
325
+ end
275
326
 
276
- not_found_count = 0
277
- data = payload
278
- print_notice(data)
327
+ def advance_build(state, api_state, payload, many)
328
+ label = label_for(state, many)
279
329
 
280
- if data["status"] != last_status
281
- last_status = data["status"]
282
- print_status(last_status)
283
- end
330
+ case api_state
331
+ when :not_found
332
+ state[:not_found] += 1
333
+ return if state[:not_found] < MAX_POLL_FAILURES
284
334
 
285
- case last_status
286
- when "success", "ready"
287
- puts ""
288
- puts "Build succeeded!"
289
- puts " Version: v#{data["version"]} (#{data["number"]})"
290
- puts " Ruby Native: #{data["native_version"]}" if data["native_version"]
291
- puts ""
292
- puts success_destination_message(data)
293
- break
294
- when "failure", "failed", "cancelled"
335
+ puts ""
336
+ puts "#{label}The server can no longer find this build (404). The app or build"
337
+ puts "may have been deleted or archived. Check the Ruby Native"
338
+ puts "dashboard: #{HOST}/dashboard"
339
+ state[:result] = :failed
340
+ return
341
+ when :error
342
+ # The build keeps running server-side through a 500, so keep polling.
343
+ if payload != state[:error]
344
+ state[:error] = payload
295
345
  puts ""
296
- puts "Build failed."
297
- puts " Error: #{data["error_message"]}" if data["error_message"]
298
- exit 1
346
+ puts "#{label}#{URI(HOST).host} returned #{payload} while checking the build. Still trying..."
299
347
  end
348
+ return
349
+ end
350
+
351
+ state[:not_found] = 0
352
+ data = payload
353
+ print_notice(data)
354
+
355
+ if data["status"] != state[:status]
356
+ state[:status] = data["status"]
357
+ print_status(state[:status], label)
358
+ end
359
+
360
+ case state[:status]
361
+ when "success", "ready"
362
+ puts ""
363
+ puts "#{label}Build succeeded!"
364
+ puts " Version: v#{data["version"]} (#{data["number"]})"
365
+ puts " Ruby Native: #{data["native_version"]}" if data["native_version"]
366
+ puts ""
367
+ puts success_destination_message(data)
368
+ state[:result] = :succeeded
369
+ when "failure", "failed", "cancelled"
370
+ puts ""
371
+ puts "#{label}Build failed."
372
+ puts " Error: #{data["error_message"]}" if data["error_message"]
373
+ state[:result] = :failed
374
+ end
375
+ end
376
+
377
+ # Platform names only appear when there is more than one build to tell
378
+ # apart, so single-platform output is unchanged.
379
+ def label_for(state, many)
380
+ return "" unless many
381
+
382
+ case state[:platform]
383
+ when "ios" then "iOS: "
384
+ when "android" then "Android: "
385
+ else ""
300
386
  end
301
- rescue Interrupt
302
- puts ""
303
- puts ""
304
- puts "Stopped polling. Your build is still running."
305
- puts "Check the Ruby Native dashboard for status."
306
387
  end
307
388
 
308
389
  # Returns [:ok, data], [:not_found, nil], or [:error, description].
@@ -331,9 +412,9 @@ module RubyNative
331
412
  end
332
413
  end
333
414
 
334
- def print_status(status)
415
+ def print_status(status, prefix = "")
335
416
  label = status_labels[status]
336
- puts " #{label}..." if label
417
+ puts " #{prefix}#{label}..." if label
337
418
  end
338
419
 
339
420
  # Server-provided warnings (a lapsed payment, say) reach customers with
@@ -373,15 +454,19 @@ module RubyNative
373
454
  end
374
455
  end
375
456
 
457
+ # Every configured platform by default: "deploy my app" means the app, not
458
+ # whichever half was written first. The server decides what is actually
459
+ # ready, so an iOS-only app still just builds iOS.
376
460
  def parse_platform(argv)
377
461
  return "android" if argv.include?("--android")
462
+ return "ios" if argv.include?("--ios")
378
463
 
379
464
  flag = argv.find { |a| a.start_with?("--platform=") }
380
- return "ios" unless flag
465
+ return "all" unless flag
381
466
 
382
467
  value = flag.split("=", 2).last
383
468
  unless PLATFORMS.include?(value)
384
- puts "Unknown platform #{value.inspect}. Use --platform=ios or --platform=android."
469
+ puts "Unknown platform #{value.inspect}. Use --platform=ios, --platform=android, or --platform=all."
385
470
  exit 1
386
471
  end
387
472
  value
@@ -1,3 +1,4 @@
1
+ require "ruby_native/cli/check"
1
2
  require "ruby_native/cli/credentials"
2
3
  require "ruby_native/cli/deploy"
3
4
  require "ruby_native/cli/login"
@@ -8,6 +9,8 @@ module RubyNative
8
9
  def self.start(argv)
9
10
  command = argv.shift
10
11
  case command
12
+ when "check"
13
+ RubyNative::CLI::Check.new(argv).run
11
14
  when "deploy"
12
15
  RubyNative::CLI::Deploy.new(argv).run
13
16
  when "preview"
@@ -40,10 +43,15 @@ module RubyNative
40
43
  puts "Usage: ruby_native <command> [options]"
41
44
  puts ""
42
45
  puts "Commands:"
43
- puts " deploy Trigger a build and wait for it to finish"
44
- puts " --android Build for Android instead of iOS"
45
- puts " --platform=NAME Build for ios or android"
46
+ puts " check Validate your views against the signal vocabulary"
47
+ puts " --deployed Also compare against the build your users have"
48
+ puts " --paths=A,B Directories to scan (default: app/views)"
49
+ puts " deploy Build every configured platform and wait for it to finish"
50
+ puts " --ios Build iOS only"
51
+ puts " --android Build Android only"
52
+ puts " --platform=NAME Build for ios, android, or all"
46
53
  puts " --if-needed Skip when this gem version is already built"
54
+ puts " --skip-check Deploy without checking your views for broken signals"
47
55
  puts " login Authenticate with Ruby Native"
48
56
  puts " logout Remove stored credentials"
49
57
  puts " preview Start a tunnel and display a QR code"
@@ -347,6 +347,13 @@ module RubyNative
347
347
  @items = []
348
348
  end
349
349
 
350
+ # Adds a button to the nav bar. `position:` is :leading, :trailing (the
351
+ # default; repeat it and the buttons sit side by side), or :title, which
352
+ # turns the bar's title into a dropdown menu instead of adding a bar item.
353
+ # A :title button takes a menu block and no icon or label of its own: keep
354
+ # a title on `native_navbar_tag` and it labels the dropdown, or omit it and
355
+ # the `selected:` item's text does.
356
+ # https://rubynative.com/docs/navbar#title-menu
350
357
  def button(title = nil, icon: nil, icons: nil, href: nil, click: nil, position: :trailing, selected: false, &block)
351
358
  resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
352
359
  data = { native_button: "" }
@@ -0,0 +1,78 @@
1
+ require "yaml"
2
+
3
+ module RubyNative
4
+ # The signal vocabulary, loaded from config/signals.yml. This is the single
5
+ # source of truth for what a valid data-native-* attribute is: `ruby_native
6
+ # check` validates views against it, and the native shells assert their own
7
+ # copy matches.
8
+ module Signals
9
+ PATH = File.expand_path("../../config/signals.yml", __dir__)
10
+
11
+ class << self
12
+ def all
13
+ @all ||= YAML.load_file(PATH).fetch("signals").freeze
14
+ end
15
+
16
+ def names
17
+ @names ||= all.keys.freeze
18
+ end
19
+
20
+ def known?(name)
21
+ all.key?(name)
22
+ end
23
+
24
+ # Signals read via querySelector, where only the first match wins, so a
25
+ # second one on the same page is silently ignored.
26
+ def singletons
27
+ @singletons ||= all.select { |_name, meta| meta["singleton"] }.keys.freeze
28
+ end
29
+
30
+ def singleton?(name)
31
+ singletons.include?(name)
32
+ end
33
+
34
+ # The gem version that introduced the signal. nil for signals the app
35
+ # writes into the page rather than ones your views emit.
36
+ def since(name)
37
+ all.dig(name, "since")
38
+ end
39
+
40
+ def helper(name)
41
+ all.dig(name, "helper")
42
+ end
43
+
44
+ # Closest known signal within one or two edits, for "did you mean". A
45
+ # genuinely unrelated attribute gets no suggestion rather than a
46
+ # confusing one.
47
+ def nearest(name, threshold: 2)
48
+ # Ties on edit distance break toward the longer shared prefix, so a
49
+ # typo'd `data-native-tab` suggests `data-native-tabs` rather than
50
+ # `data-native-fab`, which is just as close by raw distance.
51
+ candidate = names.min_by { |known| [distance(name, known), -common_prefix_length(name, known)] }
52
+ candidate if candidate && distance(name, candidate) <= threshold
53
+ end
54
+
55
+ private
56
+
57
+ def common_prefix_length(a, b)
58
+ length = 0
59
+ length += 1 while length < a.length && length < b.length && a[length] == b[length]
60
+ length
61
+ end
62
+
63
+ def distance(a, b)
64
+ rows = Array.new(a.length + 1) { |i| [i] + Array.new(b.length, 0) }
65
+ (0..b.length).each { |j| rows[0][j] = j }
66
+
67
+ (1..a.length).each do |i|
68
+ (1..b.length).each do |j|
69
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
70
+ rows[i][j] = [rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost].min
71
+ end
72
+ end
73
+
74
+ rows[a.length][b.length]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.15.2"
2
+ VERSION = "0.16.0"
3
3
  end
data/lib/ruby_native.rb CHANGED
@@ -62,6 +62,7 @@ module RubyNative
62
62
  normalize_linked_paths
63
63
  backfill_tab_icons
64
64
  backfill_error_icons
65
+ warn_on_duplicate_tab_keys
65
66
  end
66
67
 
67
68
  # config/ruby_native.yml is rendered as ERB before it is parsed, so a
@@ -95,6 +96,21 @@ module RubyNative
95
96
  end
96
97
  end
97
98
 
99
+ # A tab's `key` names its copy in the host app's locale files, so two tabs
100
+ # sharing one read the same translations and one of them is always
101
+ # mislabeled. Warn rather than raise: both tabs still render, and a typo in
102
+ # the config should never take down boot.
103
+ def self.warn_on_duplicate_tab_keys
104
+ keys = Array(self.config[:tabs]).filter_map { |tab| tab[:key] if tab.is_a?(Hash) }
105
+ duplicates = keys.tally.select { |_, count| count > 1 }.keys
106
+ return if duplicates.empty?
107
+
108
+ Rails.logger.warn(
109
+ "[RubyNative] Duplicate tab key(s) in config/ruby_native.yml " \
110
+ "(#{duplicates.join(", ")}). Each tab needs its own key, or they share a title."
111
+ )
112
+ end
113
+
98
114
  # Mirrors `backfill_tab_icons` for the error screen: fills a state's flat
99
115
  # `icon` from its per-platform `icons` (ios first, then android), so the iOS
100
116
  # app, which reads only the flat `icon`, still renders one. An explicit
@@ -134,6 +150,7 @@ module RubyNative
134
150
  # Shipped app binaries have required the `appearance` key at decode; keep
135
151
  # emitting it so they still boot when the YAML omits the block.
136
152
  payload[:appearance] ||= {}
153
+ localize_tab_titles(payload[:tabs])
137
154
  errors = error_screen_config(payload[:errors])
138
155
  if errors.empty?
139
156
  payload.delete(:errors)
@@ -143,6 +160,51 @@ module RubyNative
143
160
  payload
144
161
  end
145
162
 
163
+ # Fills each tab's `titles` from `ruby_native.tabs.<key>.title` in the host
164
+ # app's locale files, mirroring the `ruby_native.errors.*` namespace. The map
165
+ # is emitted beside the flat `title` rather than replacing it, the same
166
+ # additive shape as `icon`/`icons`: both platforms decode `title` as a plain
167
+ # String, so an app that does not read `titles` yet keeps rendering the YAML
168
+ # copy. Tabs without a `key` are left exactly as authored.
169
+ #
170
+ # `title:` is optional in the YAML so a localized app can keep all of its copy
171
+ # in locale files, but it is never optional on the wire: both platforms decode
172
+ # it as a required, non-optional String, so a tab missing one fails the whole
173
+ # config decode and drops the user on the error screen. Fill it from the
174
+ # default locale, then from any locale that was translated.
175
+ def self.localize_tab_titles(tabs)
176
+ Array(tabs).each do |tab|
177
+ next unless tab.is_a?(Hash)
178
+
179
+ key = tab[:key].to_s
180
+ titles = key.empty? ? {} : translations_for("tabs.#{key}.title")
181
+ tab[:titles] = titles unless titles.empty?
182
+ next unless tab[:title].to_s.empty?
183
+
184
+ tab[:title] = titles[I18n.default_locale] || titles.values.first || fallback_tab_title(key)
185
+ end
186
+ end
187
+
188
+ # Reached only when a tab has no `title:` and no translation in any locale,
189
+ # which would otherwise serve a payload that cannot decode. Names the tab
190
+ # after its key ("order_history" -> "Order history") so the bar still renders
191
+ # something recognizable, and says so, since the copy is missing everywhere.
192
+ def self.fallback_tab_title(key)
193
+ if key.empty?
194
+ Rails.logger.warn(
195
+ "[RubyNative] A tab in config/ruby_native.yml has no `title:`. Give it one, or a `key:` " \
196
+ "with copy under `ruby_native.tabs.<key>.title` in your locale files."
197
+ )
198
+ return "Untitled"
199
+ end
200
+
201
+ Rails.logger.warn(
202
+ "[RubyNative] Tab #{key.inspect} in config/ruby_native.yml has no `title:` and no " \
203
+ "`ruby_native.tabs.#{key}.title` translation in any locale. Falling back to a title from the key."
204
+ )
205
+ key.humanize
206
+ end
207
+
146
208
  # Merges per-state error-screen icons (from YAML) with localized copy (from
147
209
  # I18n) into the shape the native apps decode. Omits any state with neither an
148
210
  # icon nor copy, so an untouched app sends no `errors` block at all.
@@ -155,7 +217,7 @@ module RubyNative
155
217
  entry[:icons] = state_config[:icons] if state_config[:icons]
156
218
  end
157
219
  ERROR_SCREEN_COPY_KEYS.each do |key|
158
- translations = error_screen_translations("#{state}.#{key}")
220
+ translations = translations_for("errors.#{state}.#{key}")
159
221
  entry[key] = translations unless translations.empty?
160
222
  end
161
223
  result[state] = entry unless entry.empty?
@@ -163,17 +225,17 @@ module RubyNative
163
225
 
164
226
  # The Retry button label is shared by both states, so it sits at the top of
165
227
  # the block rather than under a state.
166
- retry_label = error_screen_translations("retry")
228
+ retry_label = translations_for("errors.retry")
167
229
  config[:retry] = retry_label unless retry_label.empty?
168
230
  config
169
231
  end
170
232
 
171
- # Reads `ruby_native.errors.<subkey>` for every available locale, keeping only
172
- # the locales the developer actually translated. Copy lives in the host app's
173
- # own locale files; the gem ships none.
174
- def self.error_screen_translations(subkey)
233
+ # Reads `ruby_native.<subkey>` for every available locale, keeping only the
234
+ # locales the developer actually translated. Copy lives in the host app's own
235
+ # locale files; the gem ships none.
236
+ def self.translations_for(subkey)
175
237
  I18n.available_locales.each_with_object({}) do |locale, result|
176
- value = I18n.t("ruby_native.errors.#{subkey}", locale: locale, default: nil)
238
+ value = I18n.t("ruby_native.#{subkey}", locale: locale, default: nil)
177
239
  result[locale] = value unless value.nil?
178
240
  end
179
241
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.2
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Masilotti
@@ -84,6 +84,7 @@ files:
84
84
  - app/views/ruby_native/auth/start/show.html.erb
85
85
  - config/importmap.rb
86
86
  - config/routes.rb
87
+ - config/signals.yml
87
88
  - exe/ruby_native
88
89
  - lib/generators/ruby_native/iap_generator.rb
89
90
  - lib/generators/ruby_native/install_generator.rb
@@ -93,6 +94,8 @@ files:
93
94
  - lib/ruby_native.rb
94
95
  - lib/ruby_native/certs/AppleRootCA-G3.cer
95
96
  - lib/ruby_native/cli.rb
97
+ - lib/ruby_native/cli/check.rb
98
+ - lib/ruby_native/cli/check/signal_collector.rb
96
99
  - lib/ruby_native/cli/credentials.rb
97
100
  - lib/ruby_native/cli/deploy.rb
98
101
  - lib/ruby_native/cli/login.rb
@@ -110,6 +113,7 @@ files:
110
113
  - lib/ruby_native/oauth_middleware.rb
111
114
  - lib/ruby_native/screenshots/sign_in_helper.rb
112
115
  - lib/ruby_native/set_cookie_header.rb
116
+ - lib/ruby_native/signals.rb
113
117
  - lib/ruby_native/tunnel_cookie_middleware.rb
114
118
  - lib/ruby_native/version.rb
115
119
  homepage: https://rubynative.com