ruby_native 0.12.4 → 0.14.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 +4 -4
- data/README.md +6 -0
- data/app/assets/stylesheets/ruby_native.css +34 -6
- data/app/controllers/ruby_native/aasa_controller.rb +11 -1
- data/app/controllers/ruby_native/assetlinks_controller.rb +30 -0
- data/app/controllers/ruby_native/config_controller.rb +9 -1
- data/lib/generators/ruby_native/install_generator.rb +14 -4
- data/lib/generators/ruby_native/templates/ruby_native.yml +24 -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 +104 -14
- data/lib/ruby_native/cli.rb +24 -6
- data/lib/ruby_native/engine.rb +1 -0
- data/lib/ruby_native/helper.rb +31 -2
- data/lib/ruby_native/native_detection.rb +27 -1
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +25 -2
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ba0a91ec78db490b4baf13e77f7b412d4f2f344cddbb2ad5ef8f09b28254459
|
|
4
|
+
data.tar.gz: 25ca1ff3cecb136ba1f98146fd6f3a21447462c4e92167b55d4bd6bd940ded44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb3a1b0cf45efe647d2cc5794bfafb6c1aec60549ace2a0e8109118d573b4ec72ebf2d5fdfd4ea090c12b32049feed77de6b8992a529b1da4d4f80024e260fe8
|
|
7
|
+
data.tar.gz: 99c67fa20a4ee36f5a311d5fe43e3ba0b4ae6cc5df867ff3decdc41681da30a79c319290821b06903b60d12467a897bcf5909db00fdbf651c41e463254a42ea0
|
data/README.md
CHANGED
|
@@ -72,12 +72,18 @@ This starts a Cloudflare tunnel and shows a QR code for the Ruby Native app to s
|
|
|
72
72
|
brew install cloudflare/cloudflare/cloudflared
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
The tunnel points at port 3000, or at `PORT` when it is set, the same way `rails server` picks its port. Pass `--port 4000` to override, or `--url` for an upstream that isn't on localhost.
|
|
76
|
+
|
|
75
77
|
See [rubynative.com/docs/cli](https://rubynative.com/docs/cli) for `deploy`, `login`, and auto-deploying from CI.
|
|
76
78
|
|
|
77
79
|
## React and Vue
|
|
78
80
|
|
|
79
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.
|
|
80
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
|
+
|
|
81
87
|
## License
|
|
82
88
|
|
|
83
89
|
MIT.
|
|
@@ -2,22 +2,50 @@
|
|
|
2
2
|
display: none !important;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
/* Android's WebView doesn't populate
|
|
6
|
-
(only display cutouts), so the
|
|
7
|
-
|
|
8
|
-
undefined and falls back to env()
|
|
5
|
+
/* Public API for app CSS. Android's WebView doesn't populate
|
|
6
|
+
env(safe-area-inset-*) from system bars (only display cutouts), so the
|
|
7
|
+
native shell pushes the real heights into the internal -inset- variables;
|
|
8
|
+
iOS leaves them undefined and falls back to env(). */
|
|
9
|
+
:root {
|
|
10
|
+
--ruby-native-safe-area-top: var(--ruby-native-safe-area-inset-top, env(safe-area-inset-top, 0px));
|
|
11
|
+
--ruby-native-safe-area-bottom: var(--ruby-native-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
.native-inset-top::before,
|
|
10
15
|
.native-inset::before {
|
|
11
16
|
content: "";
|
|
12
17
|
display: block;
|
|
13
|
-
height: var(--ruby-native-safe-area-
|
|
18
|
+
height: var(--ruby-native-safe-area-top);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
.native-inset-bottom::after,
|
|
17
22
|
.native-inset::after {
|
|
18
23
|
content: "";
|
|
19
24
|
display: block;
|
|
20
|
-
height: var(--ruby-native-safe-area-
|
|
25
|
+
height: var(--ruby-native-safe-area-bottom);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* In-app defaults so pages feel native without any setup: no gray tap flash,
|
|
29
|
+
no selecting button labels on long-press, no double-tap zoom on controls.
|
|
30
|
+
Pair with :active styles for pressed feedback; the bridge makes them apply
|
|
31
|
+
instantly on touch. */
|
|
32
|
+
[data-native-app] * {
|
|
33
|
+
-webkit-tap-highlight-color: transparent;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[data-native-app] button,
|
|
37
|
+
[data-native-app] [role="button"] {
|
|
38
|
+
-webkit-user-select: none;
|
|
39
|
+
user-select: none;
|
|
40
|
+
-webkit-touch-callout: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
[data-native-app] a,
|
|
44
|
+
[data-native-app] button,
|
|
45
|
+
[data-native-app] input,
|
|
46
|
+
[data-native-app] label,
|
|
47
|
+
[data-native-app] [role="button"] {
|
|
48
|
+
touch-action: manipulation;
|
|
21
49
|
}
|
|
22
50
|
|
|
23
51
|
.native-back-button {
|
|
@@ -12,7 +12,7 @@ module RubyNative
|
|
|
12
12
|
render json: {
|
|
13
13
|
applinks: {
|
|
14
14
|
details: [
|
|
15
|
-
{ appIDs: [ app_id ], components: oauth_exclusions +
|
|
15
|
+
{ appIDs: [ app_id ], components: oauth_exclusions + linked_components }
|
|
16
16
|
]
|
|
17
17
|
},
|
|
18
18
|
webcredentials: {
|
|
@@ -37,5 +37,15 @@ module RubyNative
|
|
|
37
37
|
{ "/": "#{path}*", exclude: true }
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# `linked_paths` scopes Universal Links to those prefixes; without it every
|
|
42
|
+
# URL on the domain is linked. Password sharing (webcredentials) stays
|
|
43
|
+
# domain-wide either way; there is no path concept for it.
|
|
44
|
+
def linked_components
|
|
45
|
+
paths = Array(RubyNative.config&.dig(:linked_paths))
|
|
46
|
+
return [ { "/": "*" } ] if paths.empty?
|
|
47
|
+
|
|
48
|
+
paths.map { |path| { "/": "#{path}*" } }
|
|
49
|
+
end
|
|
40
50
|
end
|
|
41
51
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RubyNative
|
|
2
|
+
class AssetlinksController < ::ActionController::Base
|
|
3
|
+
def show
|
|
4
|
+
RubyNative.load_config if Rails.env.development?
|
|
5
|
+
|
|
6
|
+
package = RubyNative.config&.dig(:android, :package)
|
|
7
|
+
# The Digital Asset Links spec wants uppercase colon-separated hex, so a
|
|
8
|
+
# lowercase paste from keytool still verifies.
|
|
9
|
+
fingerprints = Array(RubyNative.config&.dig(:android, :cert_fingerprint))
|
|
10
|
+
.map { |fingerprint| fingerprint.to_s.strip.upcase }
|
|
11
|
+
.reject(&:empty?)
|
|
12
|
+
return head :not_found unless package.present? && fingerprints.present?
|
|
13
|
+
|
|
14
|
+
response.set_header("Cache-Control", "no-cache")
|
|
15
|
+
render json: [
|
|
16
|
+
{
|
|
17
|
+
relation: [
|
|
18
|
+
"delegate_permission/common.handle_all_urls",
|
|
19
|
+
"delegate_permission/common.get_login_creds"
|
|
20
|
+
],
|
|
21
|
+
target: {
|
|
22
|
+
namespace: "android_app",
|
|
23
|
+
package_name: package,
|
|
24
|
+
sha256_cert_fingerprints: fingerprints
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -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
|
|
@@ -10,14 +10,24 @@ module RubyNative
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def add_allowed_host
|
|
13
|
-
host_line =
|
|
13
|
+
host_line = %( config.hosts << ".trycloudflare.com" if config.hosts.is_a?(Array)\n)
|
|
14
14
|
dev_config = "config/environments/development.rb"
|
|
15
15
|
|
|
16
16
|
return unless File.exist?(File.join(destination_root, dev_config))
|
|
17
|
-
|
|
17
|
+
contents = File.read(File.join(destination_root, dev_config))
|
|
18
|
+
return if contents.include?("trycloudflare")
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
# Match any configure block, not just Rails.application.configure; some
|
|
21
|
+
# apps use MyApp::Application.configure and Thor's environment helper
|
|
22
|
+
# silently skips those.
|
|
23
|
+
configure_block = /^[\w:.]+\.configure do\s*\n/
|
|
24
|
+
if contents.match?(configure_block)
|
|
25
|
+
inject_into_file dev_config, host_line, after: configure_block, verbose: false
|
|
26
|
+
say " Added .trycloudflare.com to allowed hosts in development.rb", :green
|
|
27
|
+
else
|
|
28
|
+
say " Couldn't find a configure block in development.rb. Add this line inside it:", :yellow
|
|
29
|
+
say " #{host_line.strip}", :yellow
|
|
30
|
+
end
|
|
21
31
|
end
|
|
22
32
|
|
|
23
33
|
def add_gitignore
|
|
@@ -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.
|
|
@@ -109,6 +123,16 @@ tabs:
|
|
|
109
123
|
# ios:
|
|
110
124
|
# bundle_id: com.example.myapp
|
|
111
125
|
# team_id: ABCD123456
|
|
126
|
+
# android:
|
|
127
|
+
# package: com.example.myapp
|
|
128
|
+
# # Play Console -> Setup -> App integrity -> App signing key certificate.
|
|
129
|
+
# cert_fingerprint: "AA:BB:CC:..."
|
|
130
|
+
|
|
131
|
+
# Link only these path prefixes instead of the whole domain. Links outside
|
|
132
|
+
# them keep opening in the browser. Applies to iOS on your next deploy and
|
|
133
|
+
# to Android on your next build.
|
|
134
|
+
# linked_paths:
|
|
135
|
+
# - /pair/
|
|
112
136
|
|
|
113
137
|
# Enable OAuth. Each path triggers a native ASWebAuthenticationSession
|
|
114
138
|
# on iOS instead of an in-app web view. List only the authorize path for each
|
|
@@ -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
|
|
@@ -11,10 +11,13 @@ module RubyNative
|
|
|
11
11
|
TUNNEL_READY_TIMEOUT = 60
|
|
12
12
|
TUNNEL_POLL_INTERVAL = 1
|
|
13
13
|
PUBLIC_NAMESERVERS = ["1.1.1.1", "8.8.8.8"].freeze
|
|
14
|
+
DEFAULT_PORT = 3000
|
|
15
|
+
PORT_RANGE = (1..65_535)
|
|
16
|
+
OUTPUT_TAIL_LINES = 5
|
|
14
17
|
|
|
15
18
|
def initialize(argv)
|
|
16
19
|
@url = parse_option(argv, "--url")
|
|
17
|
-
@port =
|
|
20
|
+
@port, @port_from_env = resolve_port(argv)
|
|
18
21
|
@upstream = @url || "http://localhost:#{@port}"
|
|
19
22
|
end
|
|
20
23
|
|
|
@@ -30,9 +33,19 @@ module RubyNative
|
|
|
30
33
|
uri = URI("#{@upstream}#{CONFIG_PATH}")
|
|
31
34
|
response = fetch_config_response(uri)
|
|
32
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
|
+
|
|
33
46
|
return if response.is_a?(Net::HTTPSuccess)
|
|
34
47
|
|
|
35
|
-
puts "Rails server is reachable at #{
|
|
48
|
+
puts "Rails server is reachable at #{upstream_description}, but #{CONFIG_PATH} returned #{response.code}."
|
|
36
49
|
puts ""
|
|
37
50
|
|
|
38
51
|
# 404 is what an unmounted engine returns; any other code means the app
|
|
@@ -57,7 +70,7 @@ module RubyNative
|
|
|
57
70
|
puts ""
|
|
58
71
|
puts "Make sure your Rails server is reachable at that URL."
|
|
59
72
|
else
|
|
60
|
-
puts "Nothing is running on port #{
|
|
73
|
+
puts "Nothing is running on port #{port_description}."
|
|
61
74
|
puts ""
|
|
62
75
|
puts "Start your Rails server in another terminal:"
|
|
63
76
|
puts " bin/rails server -p #{@port}"
|
|
@@ -68,6 +81,20 @@ module RubyNative
|
|
|
68
81
|
exit 1
|
|
69
82
|
end
|
|
70
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
|
+
|
|
71
98
|
def fetch_config_response(uri, ip: nil)
|
|
72
99
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
73
100
|
http.ipaddr = ip if ip
|
|
@@ -121,9 +148,36 @@ module RubyNative
|
|
|
121
148
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
149
|
end
|
|
123
150
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
# --port, then PORT, then 3000 is the precedence rails server itself uses,
|
|
152
|
+
# so a preview lands on the port the app is already serving. Anything
|
|
153
|
+
# unusable in PORT falls back rather than failing: the variable gets set
|
|
154
|
+
# for plenty of reasons that have nothing to do with a local Rails server.
|
|
155
|
+
def resolve_port(argv)
|
|
156
|
+
flag = parse_option(argv, "--port")
|
|
157
|
+
return [flag.to_i, false] if flag
|
|
158
|
+
|
|
159
|
+
env = env_port
|
|
160
|
+
env ? [env, true] : [DEFAULT_PORT, false]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def env_port
|
|
164
|
+
value = ENV["PORT"].to_s.strip
|
|
165
|
+
return unless value.match?(/\A\d+\z/)
|
|
166
|
+
|
|
167
|
+
port = value.to_i
|
|
168
|
+
port if PORT_RANGE.cover?(port)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Naming the source keeps a port nobody typed from reading as a bug.
|
|
172
|
+
def port_description
|
|
173
|
+
@port_from_env ? "#{@port} (from PORT)" : @port.to_s
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @port_from_env can be true alongside --url, which ignores the port.
|
|
177
|
+
def upstream_description
|
|
178
|
+
return @upstream if @url || !@port_from_env
|
|
179
|
+
|
|
180
|
+
"#{@upstream} (port from PORT)"
|
|
127
181
|
end
|
|
128
182
|
|
|
129
183
|
def parse_option(argv, flag)
|
|
@@ -148,33 +202,69 @@ module RubyNative
|
|
|
148
202
|
if @url
|
|
149
203
|
puts "Make sure your Rails server is reachable at #{@url}."
|
|
150
204
|
else
|
|
151
|
-
puts "Make sure your Rails server is running on port #{
|
|
205
|
+
puts "Make sure your Rails server is running on port #{port_description} in another terminal."
|
|
152
206
|
end
|
|
153
207
|
puts ""
|
|
154
208
|
|
|
155
|
-
|
|
156
|
-
"cloudflared", "tunnel", "--url", @upstream
|
|
157
|
-
)
|
|
158
|
-
stdin.close
|
|
209
|
+
output, wait_thread = spawn_tunnel
|
|
159
210
|
|
|
160
211
|
@tunnel_pid = wait_thread.pid
|
|
161
212
|
trap_interrupt
|
|
162
213
|
|
|
163
214
|
tunnel_url = nil
|
|
215
|
+
recent_output = []
|
|
164
216
|
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
167
221
|
tunnel_url = line[TUNNEL_URL_PATTERN]
|
|
168
222
|
wait_for_tunnel(tunnel_url)
|
|
169
223
|
display_qr(tunnel_url)
|
|
170
224
|
end
|
|
171
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)
|
|
172
230
|
rescue Interrupt
|
|
173
231
|
# Handled by trap
|
|
174
232
|
ensure
|
|
175
233
|
kill_tunnel
|
|
176
234
|
end
|
|
177
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
|
+
|
|
178
268
|
def display_qr(url)
|
|
179
269
|
require "rqrcode"
|
|
180
270
|
|
|
@@ -201,7 +291,7 @@ module RubyNative
|
|
|
201
291
|
if @url
|
|
202
292
|
puts "Keep this running and your Rails server reachable at #{@url}."
|
|
203
293
|
else
|
|
204
|
-
puts "Keep this running and your Rails server on port #{
|
|
294
|
+
puts "Keep this running and your Rails server on port #{port_description} in another terminal."
|
|
205
295
|
end
|
|
206
296
|
puts "Press Ctrl+C to stop."
|
|
207
297
|
end
|
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/engine.rb
CHANGED
|
@@ -51,6 +51,7 @@ module RubyNative
|
|
|
51
51
|
initializer "ruby_native.routes" do |app|
|
|
52
52
|
app.routes.prepend do
|
|
53
53
|
get "/.well-known/apple-app-site-association", to: "ruby_native/aasa#show"
|
|
54
|
+
get "/.well-known/assetlinks.json", to: "ruby_native/assetlinks#show"
|
|
54
55
|
mount RubyNative::Engine, at: "/native"
|
|
55
56
|
end
|
|
56
57
|
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,8 +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.]+))?}
|
|
10
|
+
|
|
5
11
|
included do
|
|
6
|
-
|
|
12
|
+
if respond_to?(:helper_method)
|
|
13
|
+
helper_method :native_app?, :native_version, :native_platform,
|
|
14
|
+
:native_app_version, :native_app_build, :native_os_version
|
|
15
|
+
end
|
|
7
16
|
end
|
|
8
17
|
|
|
9
18
|
def native_app?
|
|
@@ -23,5 +32,22 @@ module RubyNative
|
|
|
23
32
|
return "android" if ua.include?("Ruby Native Android")
|
|
24
33
|
nil
|
|
25
34
|
end
|
|
35
|
+
|
|
36
|
+
# The app's marketing version, like "5.2". Nil for web browsers.
|
|
37
|
+
def native_app_version
|
|
38
|
+
request.user_agent.to_s[SIGNATURE, 1]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The app's build number (CFBundleVersion / versionCode), like "35". Nil for
|
|
42
|
+
# web browsers and apps built before the User-Agent carried it.
|
|
43
|
+
def native_app_build
|
|
44
|
+
request.user_agent.to_s[SIGNATURE, 2]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The device's OS version, like "26.5.2". Nil for web browsers and apps built
|
|
48
|
+
# before the User-Agent carried it.
|
|
49
|
+
def native_os_version
|
|
50
|
+
request.user_agent.to_s[SIGNATURE, 3]
|
|
51
|
+
end
|
|
26
52
|
end
|
|
27
53
|
end
|
data/lib/ruby_native/version.rb
CHANGED
data/lib/ruby_native.rb
CHANGED
|
@@ -44,18 +44,28 @@ 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] ||= {}
|
|
51
60
|
normalize_oauth_paths
|
|
61
|
+
normalize_linked_paths
|
|
52
62
|
backfill_tab_icons
|
|
53
63
|
backfill_error_icons
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
# config/ruby_native.yml is rendered as ERB before it is parsed, so a
|
|
57
67
|
# developer can interpolate Rails helpers into it. The motivating case is the
|
|
58
|
-
# navbar logo: `logo:
|
|
68
|
+
# navbar logo: `logo: '<%= image_url("logo.png") %>'` resolves to a
|
|
59
69
|
# fingerprinted asset URL the native app downloads and caches, and because the
|
|
60
70
|
# digest changes whenever the asset changes, the cache busts itself. A full
|
|
61
71
|
# URL (a CDN, say) works just as well; the app only ever sees a URL to fetch.
|
|
@@ -183,4 +193,17 @@ module RubyNative
|
|
|
183
193
|
)
|
|
184
194
|
self.config[:auth][:oauth_paths] = paths - callbacks
|
|
185
195
|
end
|
|
196
|
+
|
|
197
|
+
# Entries are path prefixes: "/pair/" links every URL under it. A leading
|
|
198
|
+
# slash is added when missing, and a trailing "*" (an easy slip, since the
|
|
199
|
+
# AASA file uses one) is stripped so both platforms receive a plain prefix.
|
|
200
|
+
# Left absent when unconfigured so config.json doesn't grow an empty key.
|
|
201
|
+
def self.normalize_linked_paths
|
|
202
|
+
return unless self.config.key?(:linked_paths)
|
|
203
|
+
|
|
204
|
+
self.config[:linked_paths] = Array(self.config[:linked_paths])
|
|
205
|
+
.map { |path| path.to_s.strip.sub(/\*+\z/, "") }
|
|
206
|
+
.reject(&:empty?)
|
|
207
|
+
.map { |path| path.start_with?("/") ? path : "/#{path}" }
|
|
208
|
+
end
|
|
186
209
|
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.
|
|
4
|
+
version: 0.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- README.md
|
|
71
71
|
- app/assets/stylesheets/ruby_native.css
|
|
72
72
|
- app/controllers/ruby_native/aasa_controller.rb
|
|
73
|
+
- app/controllers/ruby_native/assetlinks_controller.rb
|
|
73
74
|
- app/controllers/ruby_native/auth/sessions_controller.rb
|
|
74
75
|
- app/controllers/ruby_native/auth/start_controller.rb
|
|
75
76
|
- app/controllers/ruby_native/config_controller.rb
|
|
@@ -110,10 +111,14 @@ files:
|
|
|
110
111
|
- lib/ruby_native/screenshots/sign_in_helper.rb
|
|
111
112
|
- lib/ruby_native/tunnel_cookie_middleware.rb
|
|
112
113
|
- lib/ruby_native/version.rb
|
|
113
|
-
homepage: https://
|
|
114
|
+
homepage: https://rubynative.com
|
|
114
115
|
licenses:
|
|
115
116
|
- MIT
|
|
116
|
-
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'
|
|
117
122
|
rdoc_options: []
|
|
118
123
|
require_paths:
|
|
119
124
|
- lib
|