ruby_native 0.13.0 → 0.14.2

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: c31d1051712c237ce11a48f7ab2681ac6083c81f8fc1bb155c918f7283390d79
4
- data.tar.gz: 7f3f059d502a92a389b5110cd3e8f0028adfece442dee9e44f29f2d6d5629bff
3
+ metadata.gz: 50767dda7ff4152200308b0a1fedfe0718a37f2dc44d30a826c2a5002931b466
4
+ data.tar.gz: 389fca631075081cd61d842992cf656ce86869d49b1edce7dd4fcf00b21893df
5
5
  SHA512:
6
- metadata.gz: 15d77625c818cd4a63f82b1e03490a7365b3a91cd1c75e41ca893f64d9813c197683f27198e9aaa7fc96a2eb25f702f042e55cd8e333fa9812749cea9d16839c
7
- data.tar.gz: b7cca8fcc74f7151a6eabfe2706f5644cb6e51ccf7cb9f375c60a029c036565005a9d9d1dd698dd517f08529ba98ad8126006f39e6540b2e43b2df68957c3b46
6
+ metadata.gz: 8c6e2010a14b01628306fc6be5c22168a1f6077276d98ccaed5c96c271c9cdf9b8cf101719a2458cfb1fe844211ef3fc1845ebc7eb8b02a5a0ed168bbe59b4da
7
+ data.tar.gz: 53f833324974af66b48e44caec11ba90b259cb40e32a0ff670b37b92ca02f887f20ebe18a415fa731f390ae9a344647e43580173c9c27250dea86d7060bc46fc
data/README.md CHANGED
@@ -80,6 +80,10 @@ See [rubynative.com/docs/cli](https://rubynative.com/docs/cli) for `deploy`, `lo
80
80
 
81
81
  Building with Inertia instead of ERB? `@ruby-native/react` and `@ruby-native/vue` provide the same helpers as npm packages, versioned alongside this gem. See [rubynative.com/docs/inertia](https://rubynative.com/docs/inertia) for setup.
82
82
 
83
+ ## AI agents
84
+
85
+ Every docs page serves markdown: append `.md` to the URL or request `Accept: text/markdown`. For an index of everything, fetch [rubynative.com/llms.txt](https://rubynative.com/llms.txt), or [llms-full.txt](https://rubynative.com/llms-full.txt) for the complete docs as a single file.
86
+
83
87
  ## License
84
88
 
85
89
  MIT.
@@ -2,8 +2,16 @@ module RubyNative
2
2
  class ConfigController < ::ActionController::Base
3
3
  def show
4
4
  RubyNative.load_config if Rails.env.local?
5
+ # The version header stays on the 404 so `ruby_native preview` can tell
6
+ # missing config apart from an unmounted engine.
5
7
  response.set_header("X-Ruby-Native-Version", RubyNative::VERSION)
6
- render json: RubyNative.config_as_json
8
+
9
+ config = RubyNative.config_as_json
10
+ if config.nil?
11
+ render json: { error: "config/ruby_native.yml is missing or empty" }, status: :not_found
12
+ else
13
+ render json: config
14
+ end
7
15
  end
8
16
  end
9
17
  end
@@ -56,6 +56,20 @@ appearance:
56
56
  # # Omit to let the system decide.
57
57
  # status_bar: light
58
58
 
59
+ # On Android, the bars are a neutral gray, light or dark to match the theme,
60
+ # with tint_color on the selected tab, matching iOS. Override either bar
61
+ # here; iOS keeps the system Liquid Glass bars. (The floating action button
62
+ # is colored per page from native_fab_tag instead.)
63
+ # https://rubynative.com/docs/appearance#android-chrome-colors
64
+ # android:
65
+ # navbar:
66
+ # # Each accepts a hex string, or { light:, dark: } for dark mode support.
67
+ # background_color: "#1E293B"
68
+ # # Title and icon color. Defaults to black or white, picked for contrast.
69
+ # foreground_color: "#FFFFFF"
70
+ # tab_bar:
71
+ # background_color: "#1E293B"
72
+
59
73
  # Show a launch splash while the first screen loads. It holds your launch icon
60
74
  # and background_color in frame, with a spinner, until the web content paints.
61
75
  # Off by default; set enabled: true to turn it on.
@@ -4,28 +4,42 @@ require "fileutils"
4
4
  module RubyNative
5
5
  class CLI
6
6
  class Credentials
7
- PATH = File.join(Dir.home, ".ruby_native", "credentials")
7
+ class << self
8
+ attr_writer :path
9
+ end
10
+
11
+ # Lazy: Dir.home raises in HOME-less containers, and that must not kill
12
+ # commands that only use RUBY_NATIVE_TOKEN.
13
+ def self.path
14
+ @path ||= File.join(Dir.home, ".ruby_native", "credentials")
15
+ end
8
16
 
9
17
  def self.token
10
- ENV["RUBY_NATIVE_TOKEN"] || file_token
18
+ env_token || file_token
19
+ end
20
+
21
+ # A misspelled CI secret interpolates to "", which must not shadow the
22
+ # credentials file or `ruby_native login` can never fix auth.
23
+ def self.env_token
24
+ value = ENV["RUBY_NATIVE_TOKEN"].to_s.strip
25
+ value unless value.empty?
11
26
  end
12
27
 
13
28
  def self.file_token
14
- return unless File.exist?(PATH)
15
- JSON.parse(File.read(PATH))["token"]
16
- rescue JSON::ParserError
29
+ return unless File.exist?(path)
30
+ JSON.parse(File.read(path))["token"]
31
+ rescue JSON::ParserError, ArgumentError
17
32
  nil
18
33
  end
19
34
 
20
35
  def self.save(token)
21
- dir = File.dirname(PATH)
22
- FileUtils.mkdir_p(dir)
23
- File.write(PATH, JSON.generate(token: token))
24
- File.chmod(0600, PATH)
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ File.write(path, JSON.generate(token: token))
38
+ File.chmod(0600, path)
25
39
  end
26
40
 
27
41
  def self.clear
28
- File.delete(PATH) if File.exist?(PATH)
42
+ File.delete(path) if File.exist?(path)
29
43
  end
30
44
  end
31
45
  end
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require "net/http"
3
3
  require "uri"
4
+ require "openssl"
4
5
  require "ruby_native/cli/credentials"
5
6
  require "ruby_native/version"
6
7
 
@@ -11,8 +12,22 @@ module RubyNative
11
12
  HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
12
13
  POLL_INTERVAL = 5
13
14
  POLL_TIMEOUT = 600
15
+ PLATFORMS = %w[ios android].freeze
16
+ # Consecutive failures tolerated mid-poll; one blip must not kill a
17
+ # deploy whose build is succeeding server-side.
18
+ MAX_POLL_FAILURES = 3
14
19
 
15
20
  TokenExpiredError = Class.new(StandardError)
21
+ ConnectionError = Class.new(StandardError)
22
+
23
+ NETWORK_ERRORS = [
24
+ SocketError,
25
+ Errno::ECONNREFUSED,
26
+ Errno::ECONNRESET,
27
+ Net::OpenTimeout,
28
+ Net::ReadTimeout,
29
+ OpenSSL::SSL::SSLError
30
+ ].freeze
16
31
 
17
32
  def initialize(argv)
18
33
  @if_needed = argv.include?("--if-needed")
@@ -33,15 +48,37 @@ module RubyNative
33
48
  return if @if_needed
34
49
 
35
50
  poll_build_status(app_id, build)
51
+ rescue TokenExpiredError
52
+ abort_token_expired!
53
+ rescue ConnectionError => error
54
+ puts error.message
55
+ puts "Check your internet connection and run `ruby_native deploy` again."
56
+ exit 1
36
57
  end
37
58
 
38
59
  private
39
60
 
40
61
  def ensure_authenticated!
41
- unless Credentials.token
62
+ return if Credentials.token
63
+
64
+ if ENV.key?("RUBY_NATIVE_TOKEN")
65
+ puts "RUBY_NATIVE_TOKEN is set but empty, so there is no usable token."
66
+ puts "Check the CI secret it references, or unset it and run `ruby_native login`."
67
+ else
42
68
  puts "Not logged in. Run `ruby_native login` first."
43
- exit 1
44
69
  end
70
+ exit 1
71
+ end
72
+
73
+ def abort_token_expired!
74
+ if Credentials.env_token
75
+ puts "The server rejected your token."
76
+ puts "RUBY_NATIVE_TOKEN is set and overrides `ruby_native login`, so update"
77
+ puts "that environment variable with a fresh token."
78
+ else
79
+ puts "Token expired. Run `ruby_native login` again."
80
+ end
81
+ exit 1
45
82
  end
46
83
 
47
84
  def load_config!
@@ -50,8 +87,36 @@ module RubyNative
50
87
  exit 1
51
88
  end
52
89
 
90
+ @config = read_config
91
+ end
92
+
93
+ def read_config
53
94
  require "yaml"
54
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
95
+ YAML.load(rendered_config, filename: CONFIG_PATH, symbolize_names: true, aliases: true) || {}
96
+ rescue Psych::SyntaxError => error
97
+ puts "config/ruby_native.yml has a YAML syntax error:"
98
+ puts " #{error.message}"
99
+ puts "Fix it and run `ruby_native deploy` again."
100
+ exit 1
101
+ end
102
+
103
+ # Rails renders this file as ERB before parsing, so the CLI must too or
104
+ # any <% %> tag breaks deploys while the app works fine.
105
+ def rendered_config
106
+ require "erb"
107
+ ERB.new(File.read(CONFIG_PATH), trim_mode: "-").result(erb_stub_binding)
108
+ rescue StandardError, SyntaxError
109
+ # A template only Rails can render still deploys; the CLI needs just app_id.
110
+ File.read(CONFIG_PATH)
111
+ end
112
+
113
+ # View helpers like image_url do not exist outside Rails; render them to
114
+ # "" rather than failing the deploy over values the CLI never reads.
115
+ def erb_stub_binding
116
+ stub = Object.new
117
+ def stub.method_missing(*, **) = ""
118
+ def stub.respond_to_missing?(*) = true
119
+ stub.instance_eval { binding }
55
120
  end
56
121
 
57
122
  def resolve_app_id!
@@ -94,7 +159,7 @@ module RubyNative
94
159
  when Net::HTTPNoContent
95
160
  nil
96
161
  when Net::HTTPSuccess
97
- JSON.parse(response.body)
162
+ parse_json(response)
98
163
  when Net::HTTPUnauthorized
99
164
  raise TokenExpiredError
100
165
  else
@@ -119,30 +184,36 @@ module RubyNative
119
184
  when Net::HTTPUnauthorized
120
185
  raise TokenExpiredError
121
186
  when Net::HTTPCreated
122
- build = JSON.parse(response.body)
187
+ build = parse_json(response)
188
+ unless build
189
+ puts "The build was queued, but its details could not be read."
190
+ puts "Check the Ruby Native dashboard for progress: #{HOST}/dashboard"
191
+ exit 0
192
+ end
123
193
  puts "Build ##{build["number"]} (v#{build["version"]}) queued."
194
+ print_notice(build)
124
195
  build
125
196
  when Net::HTTPTooManyRequests
126
197
  puts "Build limit reached. Try again later."
127
198
  exit 1
128
199
  when Net::HTTPConflict
129
- data = JSON.parse(response.body)
130
- puts data["error"]
200
+ data = parse_json(response)
201
+ puts data&.dig("error") || "A build is already in progress. Check the Ruby Native dashboard."
131
202
  exit 1
132
203
  when Net::HTTPUnprocessableEntity
133
- data = JSON.parse(response.body)
134
- puts "Cannot build: #{data["error"]}"
204
+ data = parse_json(response)
205
+ puts "Cannot build: #{data&.dig("error") || "the server rejected the request (422)."}"
135
206
  exit 1
136
207
  when Net::HTTPNotFound
137
- puts "App not found. Remove ruby_native.app_id from config/ruby_native.yml and run `ruby_native deploy` again to re-link."
208
+ puts "The app linked in config/ruby_native.yml was not found on your account."
209
+ puts "It may have been archived. Check your apps first: #{HOST}/dashboard"
210
+ puts "If you meant to link a different app, remove `app_id` from"
211
+ puts "config/ruby_native.yml and run `ruby_native deploy` again."
138
212
  exit 1
139
213
  else
140
214
  puts "Failed to trigger build: #{response.code} #{response.message}"
141
215
  exit 1
142
216
  end
143
- rescue TokenExpiredError
144
- puts "Token expired. Run `ruby_native login` again."
145
- exit 1
146
217
  end
147
218
 
148
219
  # --- Polling ---
@@ -155,6 +226,9 @@ module RubyNative
155
226
  print_status(last_status)
156
227
 
157
228
  started_at = Time.now
229
+ connection_failures = 0
230
+ not_found_count = 0
231
+ reported_error = nil
158
232
 
159
233
  loop do
160
234
  sleep POLL_INTERVAL
@@ -165,8 +239,43 @@ module RubyNative
165
239
  exit 1
166
240
  end
167
241
 
168
- data = fetch_build_status(app_id, build_id)
169
- next unless data
242
+ begin
243
+ state, payload = fetch_build_status(app_id, build_id)
244
+ connection_failures = 0
245
+ rescue ConnectionError => error
246
+ connection_failures += 1
247
+ next if connection_failures < MAX_POLL_FAILURES
248
+
249
+ puts ""
250
+ puts error.message
251
+ puts "Your build is most likely still running server-side. Check the"
252
+ puts "Ruby Native dashboard for the result: #{HOST}/dashboard"
253
+ exit 1
254
+ end
255
+
256
+ case state
257
+ when :not_found
258
+ not_found_count += 1
259
+ next if not_found_count < MAX_POLL_FAILURES
260
+
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
275
+
276
+ not_found_count = 0
277
+ data = payload
278
+ print_notice(data)
170
279
 
171
280
  if data["status"] != last_status
172
281
  last_status = data["status"]
@@ -196,6 +305,8 @@ module RubyNative
196
305
  puts "Check the Ruby Native dashboard for status."
197
306
  end
198
307
 
308
+ # Returns [:ok, data], [:not_found, nil], or [:error, description].
309
+ # Raises ConnectionError when the request never completed.
199
310
  def fetch_build_status(app_id, build_id)
200
311
  uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/#{build_id}")
201
312
  req = Net::HTTP::Get.new(uri)
@@ -205,11 +316,18 @@ module RubyNative
205
316
 
206
317
  case response
207
318
  when Net::HTTPSuccess
208
- JSON.parse(response.body)
319
+ begin
320
+ [:ok, JSON.parse(response.body)]
321
+ rescue JSON::ParserError
322
+ [:error, "an unreadable response (HTTP #{response.code})"]
323
+ end
209
324
  when Net::HTTPUnauthorized
210
325
  puts ""
211
- puts "Token expired. Run `ruby_native login` again."
212
- exit 1
326
+ abort_token_expired!
327
+ when Net::HTTPNotFound
328
+ [:not_found, nil]
329
+ else
330
+ [:error, "HTTP #{response.code}"]
213
331
  end
214
332
  end
215
333
 
@@ -218,6 +336,17 @@ module RubyNative
218
336
  puts " #{label}..." if label
219
337
  end
220
338
 
339
+ # Server-provided warnings (a lapsed payment, say) reach customers with
340
+ # no gem release. Deduped, since every status poll repeats the string.
341
+ def print_notice(data)
342
+ notice = data["notice"]
343
+ return unless notice.is_a?(String) && !notice.strip.empty?
344
+ return if notice == @printed_notice
345
+
346
+ @printed_notice = notice
347
+ puts "Notice: #{notice}"
348
+ end
349
+
221
350
  def status_labels
222
351
  if android?
223
352
  {
@@ -248,9 +377,14 @@ module RubyNative
248
377
  return "android" if argv.include?("--android")
249
378
 
250
379
  flag = argv.find { |a| a.start_with?("--platform=") }
251
- return flag.split("=", 2).last if flag
380
+ return "ios" unless flag
252
381
 
253
- "ios"
382
+ value = flag.split("=", 2).last
383
+ unless PLATFORMS.include?(value)
384
+ puts "Unknown platform #{value.inspect}. Use --platform=ios or --platform=android."
385
+ exit 1
386
+ end
387
+ value
254
388
  end
255
389
 
256
390
  def requested_platform
@@ -311,7 +445,7 @@ module RubyNative
311
445
  when Net::HTTPUnauthorized
312
446
  raise TokenExpiredError
313
447
  when Net::HTTPSuccess
314
- JSON.parse(response.body)
448
+ parse_json(response)
315
449
  else
316
450
  puts "Failed to fetch apps: #{response.code}"
317
451
  nil
@@ -329,8 +463,7 @@ module RubyNative
329
463
 
330
464
  File.write(CONFIG_PATH, raw)
331
465
 
332
- require "yaml"
333
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
466
+ @config = read_config
334
467
  end
335
468
 
336
469
  # --- HTTP ---
@@ -341,6 +474,17 @@ module RubyNative
341
474
  http.open_timeout = 10
342
475
  http.read_timeout = 30
343
476
  http.request(req)
477
+ rescue *NETWORK_ERRORS => error
478
+ raise ConnectionError, "Could not connect to #{uri.host} (#{error.class}: #{error.message})."
479
+ end
480
+
481
+ # Proxies and WAFs answer with HTML error pages; nil instead of a
482
+ # JSON::ParserError backtrace.
483
+ def parse_json(response)
484
+ JSON.parse(response.body)
485
+ rescue JSON::ParserError
486
+ puts "Unexpected response from #{URI(HOST).host}: HTTP #{response.code} with a body that is not JSON."
487
+ nil
344
488
  end
345
489
  end
346
490
  end
@@ -3,12 +3,24 @@ require "securerandom"
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "openssl"
6
7
  require "ruby_native/cli/credentials"
7
8
 
8
9
  module RubyNative
9
10
  class CLI
10
11
  class Login
11
12
  HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
13
+ POLL_INTERVAL = 2
14
+ MAX_ATTEMPTS = 60
15
+
16
+ NETWORK_ERRORS = [
17
+ SocketError,
18
+ Errno::ECONNREFUSED,
19
+ Errno::ECONNRESET,
20
+ Net::OpenTimeout,
21
+ Net::ReadTimeout,
22
+ OpenSSL::SSL::SSLError
23
+ ].freeze
12
24
 
13
25
  def initialize(argv = [])
14
26
  end
@@ -21,7 +33,8 @@ module RubyNative
21
33
  challenge = Digest::SHA256.hexdigest(verifier)
22
34
  url = "#{HOST}/cli/session/new?challenge=#{challenge}"
23
35
 
24
- puts "Opening browser to authorize..."
36
+ puts "Opening your browser to authorize. If nothing opens, visit:"
37
+ puts " #{url}"
25
38
  open_browser(url)
26
39
  puts "Waiting for authorization..."
27
40
 
@@ -31,7 +44,7 @@ module RubyNative
31
44
  Credentials.save(token)
32
45
  puts "Logged in to Ruby Native."
33
46
  else
34
- puts "Authorization timed out. Please try again."
47
+ print_poll_failure
35
48
  exit 1
36
49
  end
37
50
  end
@@ -52,23 +65,64 @@ module RubyNative
52
65
  def poll_for_token(verifier)
53
66
  uri = URI("#{HOST}/cli/session/poll?verifier=#{verifier}")
54
67
  attempts = 0
55
- max_attempts = 60
56
68
 
57
69
  loop do
58
70
  attempts += 1
59
- return nil if attempts > max_attempts
71
+ return nil if attempts > MAX_ATTEMPTS
72
+
73
+ sleep POLL_INTERVAL
60
74
 
61
- sleep 2
75
+ response = poll_once(uri)
76
+ next unless response
62
77
 
63
- response = Net::HTTP.get_response(uri)
64
- if response.is_a?(Net::HTTPSuccess)
65
- data = JSON.parse(response.body)
66
- return data["token"]
78
+ @network_error = nil
79
+
80
+ case response
81
+ when Net::HTTPSuccess
82
+ token = parse_token(response)
83
+ return token if token
84
+ @unexpected = "HTTP #{response.code} with a body that is not JSON"
85
+ when Net::HTTPNotFound
86
+ # Pending: the server answers 404 until the browser flow completes.
87
+ @unexpected = nil
88
+ else
89
+ @unexpected = "HTTP #{response.code}"
67
90
  end
68
91
  end
69
92
  rescue Interrupt
70
93
  nil
71
94
  end
95
+
96
+ def poll_once(uri)
97
+ http = Net::HTTP.new(uri.host, uri.port)
98
+ http.use_ssl = uri.scheme == "https"
99
+ http.open_timeout = 5
100
+ http.read_timeout = 5
101
+ http.request(Net::HTTP::Get.new(uri))
102
+ rescue *NETWORK_ERRORS => error
103
+ @network_error = "#{error.class}: #{error.message}"
104
+ nil
105
+ end
106
+
107
+ def parse_token(response)
108
+ JSON.parse(response.body)["token"]
109
+ rescue JSON::ParserError
110
+ nil
111
+ end
112
+
113
+ def print_poll_failure
114
+ if @unexpected
115
+ puts "Authorization did not complete. While waiting, #{HOST} kept responding"
116
+ puts "with #{@unexpected}."
117
+ puts "Check that #{HOST} loads in a browser, then run `ruby_native login` again."
118
+ elsif @network_error
119
+ puts "Could not reach #{HOST} (#{@network_error})."
120
+ puts "Check your internet connection and run `ruby_native login` again."
121
+ else
122
+ puts "Authorization timed out. Run `ruby_native login` again and approve"
123
+ puts "the request in your browser."
124
+ end
125
+ end
72
126
  end
73
127
  end
74
128
  end
@@ -13,6 +13,7 @@ module RubyNative
13
13
  PUBLIC_NAMESERVERS = ["1.1.1.1", "8.8.8.8"].freeze
14
14
  DEFAULT_PORT = 3000
15
15
  PORT_RANGE = (1..65_535)
16
+ OUTPUT_TAIL_LINES = 5
16
17
 
17
18
  def initialize(argv)
18
19
  @url = parse_option(argv, "--url")
@@ -32,6 +33,16 @@ module RubyNative
32
33
  uri = URI("#{@upstream}#{CONFIG_PATH}")
33
34
  response = fetch_config_response(uri)
34
35
 
36
+ if config_missing?(response)
37
+ puts "Rails server is reachable at #{upstream_description}, but it has no Ruby Native config."
38
+ puts ""
39
+ puts "config/ruby_native.yml is missing or empty. Create it with:"
40
+ puts " bin/rails generate ruby_native:install"
41
+ puts ""
42
+ puts "Then restart your Rails server and run `ruby_native preview` again."
43
+ exit 1
44
+ end
45
+
35
46
  return if response.is_a?(Net::HTTPSuccess)
36
47
 
37
48
  puts "Rails server is reachable at #{upstream_description}, but #{CONFIG_PATH} returned #{response.code}."
@@ -70,6 +81,20 @@ module RubyNative
70
81
  exit 1
71
82
  end
72
83
 
84
+ # An older gem serves 200 "null" when config/ruby_native.yml is missing;
85
+ # current gems serve 404 with the version header still set. Both mean the
86
+ # same thing: mounted, but nothing to serve.
87
+ def config_missing?(response)
88
+ case response
89
+ when Net::HTTPSuccess
90
+ response.body.to_s.strip == "null"
91
+ when Net::HTTPNotFound
92
+ !response["X-Ruby-Native-Version"].nil?
93
+ else
94
+ false
95
+ end
96
+ end
97
+
73
98
  def fetch_config_response(uri, ip: nil)
74
99
  http = Net::HTTP.new(uri.host, uri.port)
75
100
  http.ipaddr = ip if ip
@@ -181,29 +206,65 @@ module RubyNative
181
206
  end
182
207
  puts ""
183
208
 
184
- stdin, stdout_err, wait_thread = Open3.popen2e(
185
- "cloudflared", "tunnel", "--url", @upstream
186
- )
187
- stdin.close
209
+ output, wait_thread = spawn_tunnel
188
210
 
189
211
  @tunnel_pid = wait_thread.pid
190
212
  trap_interrupt
191
213
 
192
214
  tunnel_url = nil
215
+ recent_output = []
193
216
 
194
- stdout_err.each_line do |line|
195
- if line =~ TUNNEL_URL_PATTERN
217
+ output.each_line do |line|
218
+ recent_output << line.strip
219
+ recent_output.shift if recent_output.length > OUTPUT_TAIL_LINES
220
+ if tunnel_url.nil? && line =~ TUNNEL_URL_PATTERN
196
221
  tunnel_url = line[TUNNEL_URL_PATTERN]
197
222
  wait_for_tunnel(tunnel_url)
198
223
  display_qr(tunnel_url)
199
224
  end
200
225
  end
226
+
227
+ # Ctrl+C exits inside the trap, so reaching here means cloudflared
228
+ # itself stopped. Silence looked like either a hang or a clean exit.
229
+ report_tunnel_exit(tunnel_url, wait_thread.value, recent_output)
201
230
  rescue Interrupt
202
231
  # Handled by trap
203
232
  ensure
204
233
  kill_tunnel
205
234
  end
206
235
 
236
+ def spawn_tunnel
237
+ stdin, stdout_err, wait_thread = Open3.popen2e(
238
+ "cloudflared", "tunnel", "--url", @upstream
239
+ )
240
+ stdin.close
241
+ [stdout_err, wait_thread]
242
+ end
243
+
244
+ def report_tunnel_exit(tunnel_url, status, recent_output)
245
+ puts ""
246
+ if tunnel_url
247
+ puts "The tunnel stopped: cloudflared exited (#{describe_exit(status)})."
248
+ else
249
+ puts "cloudflared exited (#{describe_exit(status)}) before the tunnel came up."
250
+ end
251
+
252
+ unless recent_output.empty?
253
+ puts ""
254
+ puts "Last output from cloudflared:"
255
+ recent_output.each { |line| puts " #{line}" }
256
+ end
257
+
258
+ puts ""
259
+ puts "This usually means cloudflared could not reach Cloudflare. Check your"
260
+ puts "internet connection and run `ruby_native preview` again."
261
+ exit 1
262
+ end
263
+
264
+ def describe_exit(status)
265
+ status.exitstatus ? "status #{status.exitstatus}" : status.to_s
266
+ end
267
+
207
268
  def display_qr(url)
208
269
  require "rqrcode"
209
270
 
@@ -22,15 +22,33 @@ module RubyNative
22
22
  warn "Screenshots are now captured by rubynative.com against your deployed site."
23
23
  warn "See https://rubynative.com/docs/screenshots for the new flow."
24
24
  exit 1
25
+ when nil
26
+ print_usage
25
27
  else
26
- puts "Usage: ruby_native <command>"
28
+ puts "Unknown command: #{command}"
27
29
  puts ""
28
- puts "Commands:"
29
- puts " deploy Trigger an iOS build (use --android for Android)"
30
- puts " login Authenticate with Ruby Native"
31
- puts " logout Remove stored credentials"
32
- puts " preview Start a tunnel and display a QR code"
30
+ print_usage
31
+ exit 1
33
32
  end
33
+ rescue => error
34
+ puts "Something went wrong: #{error.class}: #{error.message}"
35
+ puts "If this keeps happening, report it: https://github.com/ruby-native/gem/issues"
36
+ exit 1
37
+ end
38
+
39
+ def self.print_usage
40
+ puts "Usage: ruby_native <command> [options]"
41
+ puts ""
42
+ 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 " --if-needed Skip when this gem version is already built"
47
+ puts " login Authenticate with Ruby Native"
48
+ puts " logout Remove stored credentials"
49
+ puts " preview Start a tunnel and display a QR code"
50
+ puts " --port PORT Rails server port (default: PORT or 3000)"
51
+ puts " --url URL Tunnel to this URL instead of localhost"
34
52
  end
35
53
  end
36
54
  end
@@ -189,15 +189,43 @@ module RubyNative
189
189
  tag.div(data: data, hidden: true) { builder.to_html }
190
190
  end
191
191
 
192
- def native_fab_tag(icon: nil, icons: nil, href: nil, click: nil)
192
+ # `color:` tints the button: tinted Liquid Glass on iOS, a colored
193
+ # Material FAB on Android. The icon color is derived automatically to
194
+ # stay readable against it. Pass `:tint` instead of a hex to inherit
195
+ # `appearance.tint_color`, keeping the button in sync with the app accent.
196
+ def native_fab_tag(icon: nil, icons: nil, href: nil, click: nil, color: nil)
193
197
  resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: try(:native_platform))
194
198
  raise ArgumentError, "native_fab_tag requires an icon" if resolved.nil?
195
199
  data = { native_fab: true, native_icon: resolved }
196
200
  data[:native_href] = href if href
197
201
  data[:native_click] = click if click
202
+ data[:native_color] = color if color
198
203
  tag.div(data: data, hidden: true)
199
204
  end
200
205
 
206
+ # Attaches a native menu to an element already on the page. `anchor:` is a
207
+ # CSS selector for that element; tapping it in the app opens a native menu
208
+ # anchored to it with the block's items, and picking one navigates or
209
+ # clicks a web element exactly like a nav bar menu item does.
210
+ #
211
+ # <span id="status-pill">Want to read</span>
212
+ #
213
+ # <%= native_menu_tag anchor: "#status-pill" do |menu| %>
214
+ # <% menu.item "Currently reading", click: "#status-reading" %>
215
+ # <% menu.item "Finished", click: "#status-finished" %>
216
+ # <% end %>
217
+ #
218
+ # The anchor element stays an ordinary element on the web, so give it its
219
+ # own web behavior (or a `native-hidden` fallback) if it needs one there.
220
+ def native_menu_tag(anchor:, &block)
221
+ anchor = anchor.to_s
222
+ raise ArgumentError, "native_menu_tag requires an anchor CSS selector" if anchor.strip.empty?
223
+
224
+ builder = NavbarMenuBuilder.new(self)
225
+ capture(builder, &block) if block
226
+ tag.div(data: { native_menu: "", native_anchor: anchor }, hidden: true) { builder.to_html }
227
+ end
228
+
201
229
  def native_overscroll_tag(top:, bottom: nil)
202
230
  tag.div(data: { native_overscroll_top: top, native_overscroll_bottom: bottom || top }, hidden: true)
203
231
  end
@@ -392,13 +420,14 @@ module RubyNative
392
420
  @items = []
393
421
  end
394
422
 
395
- def item(title, href: nil, click: nil, icon: nil, icons: nil, selected: false, action: nil)
423
+ def item(title, href: nil, click: nil, icon: nil, icons: nil, selected: false, action: nil, destructive: false)
396
424
  resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
397
425
  data = { native_menu_item: "", native_title: title }
398
426
  data[:native_href] = href if href
399
427
  data[:native_click] = click if click
400
428
  data[:native_icon] = resolved if resolved
401
429
  data[:native_selected] = "" if selected
430
+ data[:native_destructive] = "" if destructive
402
431
  data[:native_action] = RubyNative::Helper.validate_action(action) if action
403
432
  add(@context.tag.div(data: data))
404
433
  end
@@ -2,16 +2,17 @@ module RubyNative
2
2
  module NativeDetection
3
3
  extend ActiveSupport::Concern
4
4
 
5
- # Matches "Ruby Native iOS/5.2/35 iOS/26.5.2 RubyNative/0.12.5". The build and
6
- # OS groups are optional: apps built before the UA carried them send only
7
- # "Ruby Native iOS/5.2", and the optional group keeps the OS capture from
8
- # swallowing that app version.
9
- SIGNATURE = %r{Ruby Native (?:iOS|Android)/([\d.]+)(?:/([\d.]+) (?:iOS|Android)/([\d.]+))?}
5
+ # Matches "Ruby Native iOS/5.2/35 iOS/26.5.2/23F79 RubyNative/0.12.5". The
6
+ # build, OS, and OS build groups are optional: apps built before the UA
7
+ # carried them send only "Ruby Native iOS/5.2" (or later "... iOS/26.5.2"
8
+ # without the OS build), and the optional groups keep the OS capture from
9
+ # swallowing the app version.
10
+ SIGNATURE = %r{Ruby Native (?:iOS|Android)/([\d.]+)(?:/([\d.]+) (?:iOS|Android)/([\d.]+)(?:/([\w.]+))?)?}
10
11
 
11
12
  included do
12
13
  if respond_to?(:helper_method)
13
14
  helper_method :native_app?, :native_version, :native_platform,
14
- :native_app_version, :native_app_build, :native_os_version
15
+ :native_app_version, :native_app_build, :native_os_version, :native_os_build
15
16
  end
16
17
  end
17
18
 
@@ -49,5 +50,11 @@ module RubyNative
49
50
  def native_os_version
50
51
  request.user_agent.to_s[SIGNATURE, 3]
51
52
  end
53
+
54
+ # The device's OS build, like "23F79" (iOS) or "UQ1A.240205.004" (Android).
55
+ # Nil for web browsers and apps built before the User-Agent carried it.
56
+ def native_os_build
57
+ request.user_agent.to_s[SIGNATURE, 4]
58
+ end
52
59
  end
53
60
  end
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.13.0"
2
+ VERSION = "0.14.2"
3
3
  end
data/lib/ruby_native.rb CHANGED
@@ -44,7 +44,16 @@ module RubyNative
44
44
  path = Rails.root.join("config", "ruby_native.yml")
45
45
  return unless path.exist?
46
46
 
47
- self.config = YAML.load(render_config(path)).deep_symbolize_keys
47
+ parsed = YAML.load(render_config(path), filename: path.to_s, aliases: true)
48
+
49
+ # An empty file, comments only, or ERB rendering to nothing parses to nil;
50
+ # treat it like a missing file instead of crashing Rails boot.
51
+ unless parsed.is_a?(Hash)
52
+ Rails.logger.warn("[RubyNative] #{path} is empty or not a YAML mapping; ignoring it.")
53
+ return
54
+ end
55
+
56
+ self.config = parsed.deep_symbolize_keys
48
57
  self.config[:app] ||= {}
49
58
  self.config[:app][:entry_path] ||= self.config.dig(:tabs, 0, :path) || "/"
50
59
  self.config[:auth] ||= {}
@@ -56,7 +65,7 @@ module RubyNative
56
65
 
57
66
  # config/ruby_native.yml is rendered as ERB before it is parsed, so a
58
67
  # developer can interpolate Rails helpers into it. The motivating case is the
59
- # navbar logo: `logo: "<%= image_url("logo.png") %>"` resolves to a
68
+ # navbar logo: `logo: '<%= image_url("logo.png") %>'` resolves to a
60
69
  # fingerprinted asset URL the native app downloads and caches, and because the
61
70
  # digest changes whenever the asset changes, the cache busts itself. A full
62
71
  # URL (a CDN, say) works just as well; the app only ever sees a URL to fetch.
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.13.0
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Masilotti
@@ -111,10 +111,14 @@ files:
111
111
  - lib/ruby_native/screenshots/sign_in_helper.rb
112
112
  - lib/ruby_native/tunnel_cookie_middleware.rb
113
113
  - lib/ruby_native/version.rb
114
- homepage: https://github.com/ruby-native/gem
114
+ homepage: https://rubynative.com
115
115
  licenses:
116
116
  - MIT
117
- metadata: {}
117
+ metadata:
118
+ documentation_uri: https://rubynative.com/docs
119
+ source_code_uri: https://github.com/ruby-native/gem
120
+ changelog_uri: https://github.com/ruby-native/gem/blob/main/CHANGELOG.md
121
+ rubygems_mfa_required: 'true'
118
122
  rdoc_options: []
119
123
  require_paths:
120
124
  - lib