ruby_native 0.15.3 → 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: 1be33272a96d8436b0384a2723f6661e349c873c8df954fadba80472bbfe565c
4
- data.tar.gz: c547853c962c66b1c588185b54ca584ef2d9796fa735fcbe7ad560ba0644f159
3
+ metadata.gz: f7c62d39c66e463239ff2c5cc4c66b868271758aa1e19fccdbc0fb25e446854b
4
+ data.tar.gz: 3c52d0ce183cedb9cfeced27ec2a504abbbc0a1675f0109d7f7e557b1d32dff7
5
5
  SHA512:
6
- metadata.gz: 5dda4cf2375e87449b8e64e81c07fe02cf2d671fdea7182fafd7f34436674bb633ff7823a19c7e2efd7414ff888af17c7d646e9652437e807f0c67458dd97972
7
- data.tar.gz: ba5e2c1ee978774a81e7292cf6cd7b8e96fba7f80820d81483f0c64721e478205afc4167de8a8bc7064d96fc908a194699f9ebe03a35c1c4bad9e1a9da1c2a8f
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
@@ -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.3"
2
+ VERSION = "0.16.0"
3
3
  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.3
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