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 +4 -4
- data/README.md +4 -0
- data/app/controllers/ruby_native/config_controller.rb +9 -1
- data/lib/generators/ruby_native/templates/ruby_native.yml +14 -0
- data/lib/ruby_native/cli/credentials.rb +24 -10
- data/lib/ruby_native/cli/deploy.rb +167 -23
- data/lib/ruby_native/cli/login.rb +63 -9
- data/lib/ruby_native/cli/preview.rb +67 -6
- data/lib/ruby_native/cli.rb +24 -6
- data/lib/ruby_native/helper.rb +31 -2
- data/lib/ruby_native/native_detection.rb +13 -6
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +11 -2
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 50767dda7ff4152200308b0a1fedfe0718a37f2dc44d30a826c2a5002931b466
|
|
4
|
+
data.tar.gz: 389fca631075081cd61d842992cf656ce86869d49b1edce7dd4fcf00b21893df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?(
|
|
15
|
-
JSON.parse(File.read(
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
File.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
130
|
-
puts data
|
|
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 =
|
|
134
|
-
puts "Cannot build: #{data
|
|
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 "
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
|
380
|
+
return "ios" unless flag
|
|
252
381
|
|
|
253
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
71
|
+
return nil if attempts > MAX_ATTEMPTS
|
|
72
|
+
|
|
73
|
+
sleep POLL_INTERVAL
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
response = poll_once(uri)
|
|
76
|
+
next unless response
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
data/lib/ruby_native/cli.rb
CHANGED
|
@@ -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 "
|
|
28
|
+
puts "Unknown command: #{command}"
|
|
27
29
|
puts ""
|
|
28
|
-
|
|
29
|
-
|
|
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
|
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -189,15 +189,43 @@ module RubyNative
|
|
|
189
189
|
tag.div(data: data, hidden: true) { builder.to_html }
|
|
190
190
|
end
|
|
191
191
|
|
|
192
|
-
|
|
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
|
|
6
|
-
# OS groups are optional: apps built before the UA
|
|
7
|
-
# "Ruby Native iOS/5.2"
|
|
8
|
-
#
|
|
9
|
-
|
|
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
|
data/lib/ruby_native/version.rb
CHANGED
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
|
-
|
|
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:
|
|
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.
|
|
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://
|
|
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
|