ruby_native 0.11.1 → 0.12.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 +1 -1
- data/app/controllers/ruby_native/auth/start_controller.rb +7 -1
- data/app/controllers/ruby_native/iap/restores_controller.rb +70 -21
- data/lib/generators/ruby_native/iap_generator.rb +17 -2
- data/lib/generators/ruby_native/install_generator.rb +1 -0
- data/lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb +5 -0
- data/lib/ruby_native/cli/deploy.rb +8 -3
- data/lib/ruby_native/cli/login.rb +10 -5
- data/lib/ruby_native/cli/preview.rb +12 -17
- data/lib/ruby_native/helper.rb +101 -4
- data/lib/ruby_native/oauth_middleware.rb +24 -2
- data/lib/ruby_native/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9febf470fc9b33fbbfe136f4d7dc916f002ce532707281a65c1d9733cab09559
|
|
4
|
+
data.tar.gz: 9789db1959e99b53cc8a5d40dcf8a093032553569c854bcfcb5d54fa3610f54f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c94d0798d18f68e60d599cb59aa3296fffecb307cf907a1f0519efe61387be1c8255d46f50938c5e1f3541d3795dee4030f45a52a806aa1d19138a5e76eed964
|
|
7
|
+
data.tar.gz: 0a4387e5123e7e372521bc4eefefbbaf940d321ba09bf8e5f216471ace3f81f731f98d6f7df759fb380064b72cf96b887a4b72b433815124f503e969866f355c
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ruby_native
|
|
2
2
|
|
|
3
|
-
[Ruby Native](https://rubynative.com) turns a Rails app into
|
|
3
|
+
[Ruby Native](https://rubynative.com) turns a Rails app into iOS and Android apps. This gem is the Rails-side integration: view helpers, a YAML config file, endpoints auto-mounted at `/native`, and a CLI for previewing and deploying builds. Full docs, including the complete helper reference and config schema, live at [rubynative.com/docs](https://rubynative.com/docs).
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -9,9 +9,15 @@ module RubyNative
|
|
|
9
9
|
return
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
@callback_scheme = params[:callback_scheme].to_s
|
|
13
|
+
|
|
14
|
+
unless @callback_scheme.match?(OAuthMiddleware::CALLBACK_SCHEME)
|
|
15
|
+
head :bad_request
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
oauth_paths = RubyNative.config&.dig(:auth, :oauth_paths) || []
|
|
13
20
|
@oauth_path = oauth_paths.find { |p| p.end_with?(@provider) } || "/auth/#{@provider}"
|
|
14
|
-
@callback_scheme = params[:callback_scheme]
|
|
15
21
|
end
|
|
16
22
|
end
|
|
17
23
|
end
|
|
@@ -7,37 +7,86 @@ module RubyNative
|
|
|
7
7
|
skip_forgery_protection
|
|
8
8
|
|
|
9
9
|
def create
|
|
10
|
-
|
|
11
|
-
return head :bad_request if customer_id.blank?
|
|
10
|
+
return head :bad_request if params[:customer_id].blank?
|
|
12
11
|
|
|
13
12
|
transactions = Array(params[:signed_transactions])
|
|
14
13
|
return head :bad_request if transactions.empty?
|
|
15
14
|
|
|
16
|
-
transactions.each
|
|
17
|
-
transaction = decode_and_verify_jws(signed_transaction)
|
|
18
|
-
|
|
19
|
-
event = Event.new(
|
|
20
|
-
type: "subscription.created",
|
|
21
|
-
status: "active",
|
|
22
|
-
owner_token: customer_id,
|
|
23
|
-
product_id: transaction["productId"],
|
|
24
|
-
original_transaction_id: transaction["originalTransactionId"],
|
|
25
|
-
transaction_id: transaction["transactionId"],
|
|
26
|
-
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
27
|
-
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
28
|
-
environment: transaction["environment"]&.downcase,
|
|
29
|
-
notification_uuid: SecureRandom.uuid,
|
|
30
|
-
success_path: params[:success_path]
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
RubyNative.fire_subscription_callbacks(event)
|
|
34
|
-
end
|
|
15
|
+
transactions.each { |signed_transaction| restore(decode_and_verify_jws(signed_transaction)) }
|
|
35
16
|
|
|
36
17
|
head :ok
|
|
37
18
|
rescue VerificationError, JWT::DecodeError => e
|
|
38
19
|
Rails.logger.warn "[RubyNative] Restore verification failed: #{e.message}"
|
|
39
20
|
head :unprocessable_entity
|
|
40
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The account being restored to comes from the purchase intent the
|
|
26
|
+
# transaction was made against, never from `customer_id` in the request. A
|
|
27
|
+
# signed transaction is only proof that Apple sold it, not proof of who is
|
|
28
|
+
# asking, so honoring a caller-supplied id let one purchased subscription
|
|
29
|
+
# be replayed to grant entitlement to any number of accounts.
|
|
30
|
+
def restore(transaction)
|
|
31
|
+
transaction_id = transaction["transactionId"]
|
|
32
|
+
intent = PurchaseIntent.find_by(uuid: transaction["appAccountToken"])
|
|
33
|
+
|
|
34
|
+
unless intent
|
|
35
|
+
Rails.logger.warn "[RubyNative] Ignoring restored transaction #{transaction_id.inspect}: " \
|
|
36
|
+
"no purchase intent matches its appAccountToken"
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if params[:customer_id] != intent.customer_id
|
|
41
|
+
Rails.logger.warn "[RubyNative] Restoring transaction #{transaction_id.inspect} to " \
|
|
42
|
+
"#{intent.customer_id.inspect} from its purchase intent, not the requested " \
|
|
43
|
+
"#{params[:customer_id].inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Apple reissues a transaction id on every renewal, so a restore of the
|
|
47
|
+
# same one is a repeat and must not fire the callback again.
|
|
48
|
+
return if dedup_available? && intent.restored_transaction_id == transaction_id
|
|
49
|
+
|
|
50
|
+
intent.update!(intent_attributes(transaction))
|
|
51
|
+
|
|
52
|
+
event = Event.new(
|
|
53
|
+
type: "subscription.created",
|
|
54
|
+
status: "active",
|
|
55
|
+
owner_token: intent.customer_id,
|
|
56
|
+
product_id: transaction["productId"],
|
|
57
|
+
original_transaction_id: transaction["originalTransactionId"],
|
|
58
|
+
transaction_id: transaction_id,
|
|
59
|
+
purchase_date: parse_timestamp(transaction["purchaseDate"]),
|
|
60
|
+
expires_date: parse_timestamp(transaction["expiresDate"]),
|
|
61
|
+
environment: transaction["environment"]&.downcase,
|
|
62
|
+
notification_uuid: SecureRandom.uuid,
|
|
63
|
+
success_path: params[:success_path]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
RubyNative.fire_subscription_callbacks(event)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A restore is the recovery path for a purchase whose completion POST never
|
|
70
|
+
# landed, so it settles the intent too. An unrecognized environment is
|
|
71
|
+
# dropped rather than raising on the enum.
|
|
72
|
+
def intent_attributes(transaction)
|
|
73
|
+
attributes = {status: :completed}
|
|
74
|
+
environment = transaction["environment"]&.downcase
|
|
75
|
+
attributes[:environment] = environment if PurchaseIntent.environments.key?(environment)
|
|
76
|
+
attributes[:restored_transaction_id] = transaction["transactionId"] if dedup_available?
|
|
77
|
+
attributes
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Deduping needs a column that arrives in a migration, and an app can be on
|
|
81
|
+
# this version of the gem before it has been run. Restoring to the right
|
|
82
|
+
# account is the part that matters and needs no column, so a missing one
|
|
83
|
+
# costs the repeat-callback guard and nothing else -- which is how restore
|
|
84
|
+
# behaved before the column existed anyway. Writing to it regardless would
|
|
85
|
+
# raise, and the app reports a restore as successful without reading the
|
|
86
|
+
# response, so the customer would be told "Restored!" over a 500.
|
|
87
|
+
def dedup_available?
|
|
88
|
+
PurchaseIntent.column_names.include?("restored_transaction_id")
|
|
89
|
+
end
|
|
41
90
|
end
|
|
42
91
|
end
|
|
43
92
|
end
|
|
@@ -8,13 +8,28 @@ module RubyNative
|
|
|
8
8
|
|
|
9
9
|
source_root File.expand_path("templates", __dir__)
|
|
10
10
|
|
|
11
|
+
# Must increment within a single run, or the second migration below collides
|
|
12
|
+
# with the first on a bare `Time.now` timestamp.
|
|
11
13
|
def self.next_migration_number(dirname)
|
|
12
|
-
|
|
14
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
# Re-running this generator on an app that already has the first migration
|
|
18
|
+
# reports it identical and creates only what is missing, so it doubles as
|
|
19
|
+
# the upgrade path.
|
|
20
|
+
#
|
|
21
|
+
# That rests on `create_ruby_native_purchase_intents.rb` never changing.
|
|
22
|
+
# Rails compares the template against the copy the app already has, and
|
|
23
|
+
# only calls it identical when they match byte for byte; edit one character
|
|
24
|
+
# and it reports a conflict instead, which raises and aborts the run before
|
|
25
|
+
# the migrations added after it are copied. So an app upgrading would get
|
|
26
|
+
# none of them. Add a new template alongside it rather than editing it --
|
|
27
|
+
# `iap_generator_test.rb` fails if that file is touched.
|
|
28
|
+
def copy_migrations
|
|
16
29
|
migration_template "create_ruby_native_purchase_intents.rb",
|
|
17
30
|
"db/migrate/create_ruby_native_purchase_intents.rb"
|
|
31
|
+
migration_template "add_restored_transaction_id_to_ruby_native_purchase_intents.rb",
|
|
32
|
+
"db/migrate/add_restored_transaction_id_to_ruby_native_purchase_intents.rb"
|
|
18
33
|
end
|
|
19
34
|
|
|
20
35
|
def print_next_steps
|
|
@@ -40,6 +40,7 @@ module RubyNative
|
|
|
40
40
|
say " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,viewport-fit=cover\">"
|
|
41
41
|
say " 4. Add to your layout <body>:"
|
|
42
42
|
say " <%= native_tabs_tag %>"
|
|
43
|
+
say " <%= native_identity_tag(current_user&.id) %>"
|
|
43
44
|
say " 5. Preview on your device:"
|
|
44
45
|
say " bundle exec ruby_native preview"
|
|
45
46
|
say ""
|
|
@@ -79,17 +79,22 @@ module RubyNative
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def fetch_latest_build(app_id)
|
|
82
|
-
|
|
82
|
+
# Platform-scoped, or --android --if-needed compares against the
|
|
83
|
+
# latest iOS build and skips a build Android never got.
|
|
84
|
+
uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest?platform=#{@platform}")
|
|
83
85
|
req = Net::HTTP::Get.new(uri)
|
|
84
86
|
req["Authorization"] = "Token #{Credentials.token}"
|
|
85
87
|
|
|
86
88
|
response = make_request(uri, req)
|
|
87
89
|
|
|
88
90
|
case response
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
# Before HTTPSuccess, its superclass: a 204 has no body to parse, and
|
|
92
|
+
# matching Success first crashed --if-needed before an app's first
|
|
93
|
+
# successful build.
|
|
91
94
|
when Net::HTTPNoContent
|
|
92
95
|
nil
|
|
96
|
+
when Net::HTTPSuccess
|
|
97
|
+
JSON.parse(response.body)
|
|
93
98
|
when Net::HTTPUnauthorized
|
|
94
99
|
raise TokenExpiredError
|
|
95
100
|
else
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "digest"
|
|
1
2
|
require "securerandom"
|
|
2
3
|
require "net/http"
|
|
3
4
|
require "uri"
|
|
@@ -13,14 +14,18 @@ module RubyNative
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Only the hash goes through the browser. Claiming the token needs this
|
|
18
|
+
# verifier, which never leaves the machine, so a copy of the authorize
|
|
19
|
+
# URL is not enough for anyone else to collect the session.
|
|
20
|
+
verifier = SecureRandom.hex(32)
|
|
21
|
+
challenge = Digest::SHA256.hexdigest(verifier)
|
|
22
|
+
url = "#{HOST}/cli/session/new?challenge=#{challenge}"
|
|
18
23
|
|
|
19
24
|
puts "Opening browser to authorize..."
|
|
20
25
|
open_browser(url)
|
|
21
26
|
puts "Waiting for authorization..."
|
|
22
27
|
|
|
23
|
-
token = poll_for_token(
|
|
28
|
+
token = poll_for_token(verifier)
|
|
24
29
|
|
|
25
30
|
if token
|
|
26
31
|
Credentials.save(token)
|
|
@@ -44,8 +49,8 @@ module RubyNative
|
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
def poll_for_token(
|
|
48
|
-
uri = URI("#{HOST}/cli/session/poll?
|
|
52
|
+
def poll_for_token(verifier)
|
|
53
|
+
uri = URI("#{HOST}/cli/session/poll?verifier=#{verifier}")
|
|
49
54
|
attempts = 0
|
|
50
55
|
max_attempts = 60
|
|
51
56
|
|
|
@@ -165,25 +165,20 @@ module RubyNative
|
|
|
165
165
|
require "rqrcode"
|
|
166
166
|
|
|
167
167
|
qr = RQRCode::QRCode.new(url, level: :l)
|
|
168
|
-
modules = qr.modules
|
|
169
|
-
size = modules.length
|
|
170
|
-
quiet_h = 4
|
|
171
|
-
quiet_v = 2
|
|
172
|
-
|
|
173
|
-
dark = "██"
|
|
174
|
-
light = " "
|
|
175
168
|
|
|
169
|
+
# Painted as background colors, not block glyphs: a glyph takes the terminal's
|
|
170
|
+
# foreground color, which prints the code inverted on a dark theme, and Android
|
|
171
|
+
# (unlike iOS) will not decode an inverted code. Truecolor rather than any
|
|
172
|
+
# palette index, because terminals remap both the 16 basic colors and the 256
|
|
173
|
+
# cube, which is how "white" came out as a mid-gray in one terminal and as
|
|
174
|
+
# orange in another. 24-bit RGB is a literal value with no lookup, so the
|
|
175
|
+
# contrast and the polarity are the same everywhere.
|
|
176
176
|
puts ""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
inside = mr >= 0 && mr < size && mc >= 0 && mc < size
|
|
183
|
-
line << (inside && modules[mr][mc] ? dark : light)
|
|
184
|
-
end
|
|
185
|
-
puts line
|
|
186
|
-
end
|
|
177
|
+
print qr.as_ansi(
|
|
178
|
+
light: "\e[48;2;255;255;255m",
|
|
179
|
+
dark: "\e[48;2;0;0;0m",
|
|
180
|
+
quiet_zone_size: 4
|
|
181
|
+
)
|
|
187
182
|
puts ""
|
|
188
183
|
puts url
|
|
189
184
|
puts ""
|
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -26,6 +26,18 @@ module RubyNative
|
|
|
26
26
|
tag.div(data: { native_push: true }, hidden: true)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def native_identity_tag(value)
|
|
30
|
+
tag.div(data: { native_identity: native_identity_token(value) }, hidden: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# HMAC, not a bare digest: a digest of a small integer id space is enumerable,
|
|
34
|
+
# so the DOM would effectively still carry the id it was meant to hide.
|
|
35
|
+
def native_identity_token(value)
|
|
36
|
+
parts = Array(value).compact
|
|
37
|
+
return "" if parts.empty?
|
|
38
|
+
OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, parts.join(":"))[0, 16]
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def native_back_button_tag(text = nil, **options)
|
|
30
42
|
options[:class] = [options[:class], "native-back-button"].compact.join(" ")
|
|
31
43
|
default_content = tag.svg(
|
|
@@ -85,6 +97,89 @@ module RubyNative
|
|
|
85
97
|
tag.div(data: data, hidden: true)
|
|
86
98
|
end
|
|
87
99
|
|
|
100
|
+
# No :root. Landing a page as the tab root — dropping what is behind it —
|
|
101
|
+
# is a distinct operation from replacing the current entry, and no shell
|
|
102
|
+
# implements it: every one of them mapped :root onto replace, which unwinds
|
|
103
|
+
# nothing. Offering the word without the behavior is worse than not
|
|
104
|
+
# offering it, so it is out until a shell can honor it.
|
|
105
|
+
ACTIONS = %w[push replace].freeze
|
|
106
|
+
|
|
107
|
+
# Validates a push/replace landing value. Returns the value as a string,
|
|
108
|
+
# raises otherwise.
|
|
109
|
+
def self.validate_action(value, label: "action")
|
|
110
|
+
value = value.to_s
|
|
111
|
+
unless ACTIONS.include?(value)
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"#{label} must be :push or :replace, got #{value.inspect}"
|
|
114
|
+
end
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The landing intents a page can declare about itself. Only :root so far.
|
|
119
|
+
# :modal is the obvious second member (both shells hardcode a /new + /edit
|
|
120
|
+
# rule today that no app can reach), which is why this is a vocabulary
|
|
121
|
+
# rather than a boolean tag.
|
|
122
|
+
PRESENTATION_INTENTS = %w[root].freeze
|
|
123
|
+
|
|
124
|
+
# Declares that this page is a root: it lands with nothing behind it, and no
|
|
125
|
+
# back affordance, wherever it lands.
|
|
126
|
+
#
|
|
127
|
+
# <%= native_presentation_tag :root %>
|
|
128
|
+
#
|
|
129
|
+
# Emits the fact on two channels, because the shells need it at two
|
|
130
|
+
# different moments and neither channel reaches both:
|
|
131
|
+
#
|
|
132
|
+
# 1. A `data-native-presentation` element, reported with every other signal
|
|
133
|
+
# once the page has rendered. This is what Normal Mode reads, and Normal
|
|
134
|
+
# Mode can act on it late: it has no push stack, so suppressing back is
|
|
135
|
+
# not a navigation and nothing refetches.
|
|
136
|
+
#
|
|
137
|
+
# 2. A `Native-Presentation` response header. Advanced Mode has to decide
|
|
138
|
+
# before the navigation commits, or it pushes and then visibly corrects
|
|
139
|
+
# itself. On a form submission Turbo has already fetched the destination
|
|
140
|
+
# by the time it proposes the visit, so the header is readable at
|
|
141
|
+
# `turbo:before-fetch-response` — before the proposal — and the header is
|
|
142
|
+
# the only part of that response readable synchronously, since the body
|
|
143
|
+
# arrives as a promise that consuming would take from Turbo.
|
|
144
|
+
#
|
|
145
|
+
# Nothing is declared at the origin. Both channels ride the response for the
|
|
146
|
+
# page itself, so a redirect chain carries the intent to wherever it actually
|
|
147
|
+
# lands rather than to wherever the link pointed.
|
|
148
|
+
#
|
|
149
|
+
# In Advanced Mode this takes effect before the navigation commits when the
|
|
150
|
+
# page arrives from a form submission, because that is the only case where
|
|
151
|
+
# Turbo has already fetched the destination by the time it proposes the
|
|
152
|
+
# visit. A link tap, a deep link and a cold boot are proposed before anything
|
|
153
|
+
# is fetched, so those fall back to the element and the shell corrects after
|
|
154
|
+
# the page renders. Normal Mode always uses the element.
|
|
155
|
+
#
|
|
156
|
+
# Do not call this inside a `cache` block. On a cache hit the element comes
|
|
157
|
+
# back from the cache and the header is never set, which silently leaves
|
|
158
|
+
# Advanced Mode with only the slower path.
|
|
159
|
+
def native_presentation_tag(presentation)
|
|
160
|
+
value = presentation.to_s
|
|
161
|
+
unless PRESENTATION_INTENTS.include?(value)
|
|
162
|
+
raise ArgumentError,
|
|
163
|
+
"native_presentation_tag must be #{PRESENTATION_INTENTS.map { |i| ":#{i}" }.join(" or ")}, " \
|
|
164
|
+
"got #{value.inspect}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# `respond_to?` rather than a nil check alone: ActionView forwards it to
|
|
168
|
+
# the controller for the delegated methods, so a view rendered without one
|
|
169
|
+
# answers false here instead of raising a DelegationError.
|
|
170
|
+
if respond_to?(:response) && response
|
|
171
|
+
if Rails.env.development? && response.committed?
|
|
172
|
+
Rails.logger.warn(
|
|
173
|
+
"[ruby_native] native_presentation_tag rendered after the response was committed, " \
|
|
174
|
+
"so Advanced Mode will not see it before the navigation commits."
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
response.headers["Native-Presentation"] = value
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
tag.div(data: { native_presentation: value }, hidden: true)
|
|
181
|
+
end
|
|
182
|
+
|
|
88
183
|
def native_navbar_tag(title = nil, pull_to_refresh: true, &block)
|
|
89
184
|
builder = NavbarBuilder.new(self)
|
|
90
185
|
capture(builder, &block) if block
|
|
@@ -212,8 +307,9 @@ module RubyNative
|
|
|
212
307
|
# web side) so it works even where embedded web views don't support
|
|
213
308
|
# `navigator.share`. Icons follow the same rules as the other navbar
|
|
214
309
|
# buttons: `icon:` applies to every platform and `icons:` ({ ios:,
|
|
215
|
-
# android: }) overrides per platform.
|
|
216
|
-
|
|
310
|
+
# android: }) overrides per platform. Android defaults to the Material
|
|
311
|
+
# `share` glyph; the SF Symbol default renders as missing there.
|
|
312
|
+
def share_button(url: nil, title: "Share", icon: "square.and.arrow.up", icons: { android: "share" }, position: :trailing)
|
|
217
313
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
218
314
|
data = { native_button: "", native_share: "" }
|
|
219
315
|
data[:native_title] = title if title
|
|
@@ -254,13 +350,14 @@ module RubyNative
|
|
|
254
350
|
@items = []
|
|
255
351
|
end
|
|
256
352
|
|
|
257
|
-
def item(title, href: nil, click: nil, icon: nil, icons: nil, selected: false)
|
|
353
|
+
def item(title, href: nil, click: nil, icon: nil, icons: nil, selected: false, action: nil)
|
|
258
354
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
259
355
|
data = { native_menu_item: "", native_title: title }
|
|
260
356
|
data[:native_href] = href if href
|
|
261
357
|
data[:native_click] = click if click
|
|
262
358
|
data[:native_icon] = resolved if resolved
|
|
263
359
|
data[:native_selected] = "" if selected
|
|
360
|
+
data[:native_action] = RubyNative::Helper.validate_action(action) if action
|
|
264
361
|
add(@context.tag.div(data: data))
|
|
265
362
|
end
|
|
266
363
|
|
|
@@ -268,7 +365,7 @@ module RubyNative
|
|
|
268
365
|
# opens the platform share sheet for `url:` (defaults to the current
|
|
269
366
|
# page on the web side). Icons follow the same `icon:`/`icons:` rules
|
|
270
367
|
# as the other menu items.
|
|
271
|
-
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons:
|
|
368
|
+
def share_item(title = "Share", url: nil, icon: "square.and.arrow.up", icons: { android: "share" }, selected: false)
|
|
272
369
|
resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
|
|
273
370
|
data = { native_menu_item: "", native_title: title, native_share: "" }
|
|
274
371
|
data[:native_share_url] = url if url
|
|
@@ -2,6 +2,14 @@ module RubyNative
|
|
|
2
2
|
class OAuthMiddleware
|
|
3
3
|
COOKIE_NAME = "_ruby_native_oauth"
|
|
4
4
|
|
|
5
|
+
# The scheme the native app registers is always "rubynative-" plus its bundle
|
|
6
|
+
# identifier with dots as dashes, built by OAuthManager.callbackScheme on both
|
|
7
|
+
# platforms. The scheme arrives as a request param and ends up as the target of
|
|
8
|
+
# the redirect carrying the session token, so anything that could name a
|
|
9
|
+
# different origin -- a colon, slash, dot, "@", or percent escape -- is
|
|
10
|
+
# rejected outright rather than sanitized.
|
|
11
|
+
CALLBACK_SCHEME = /\Arubynative-[a-z0-9][a-z0-9_-]{0,127}\z/i
|
|
12
|
+
|
|
5
13
|
def initialize(app)
|
|
6
14
|
@app = app
|
|
7
15
|
end
|
|
@@ -10,7 +18,7 @@ module RubyNative
|
|
|
10
18
|
request = ActionDispatch::Request.new(env)
|
|
11
19
|
on_oauth_path = oauth_path?(request)
|
|
12
20
|
started_native_oauth = on_oauth_path && request.params["ruby_native"] == "1"
|
|
13
|
-
callback_scheme = request.params["callback_scheme"] if started_native_oauth
|
|
21
|
+
callback_scheme = permitted_scheme(request.params["callback_scheme"]) if started_native_oauth
|
|
14
22
|
|
|
15
23
|
status, headers, body = @app.call(env)
|
|
16
24
|
|
|
@@ -23,7 +31,7 @@ module RubyNative
|
|
|
23
31
|
set_cookie(headers, callback_scheme)
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
stored_scheme = read_cookie(request)
|
|
34
|
+
stored_scheme = permitted_scheme(read_cookie(request))
|
|
27
35
|
|
|
28
36
|
if stored_scheme && redirect?(status)
|
|
29
37
|
location = headers["location"] || headers["Location"]
|
|
@@ -70,6 +78,20 @@ module RubyNative
|
|
|
70
78
|
|
|
71
79
|
private
|
|
72
80
|
|
|
81
|
+
# Returns the scheme only when it matches CALLBACK_SCHEME. A rejected scheme
|
|
82
|
+
# reads as "no native app asked for this", so the flow falls through as an
|
|
83
|
+
# ordinary web sign-in and no token is ever minted.
|
|
84
|
+
def permitted_scheme(value)
|
|
85
|
+
return nil if value.blank?
|
|
86
|
+
return value if value.match?(CALLBACK_SCHEME)
|
|
87
|
+
|
|
88
|
+
Rails.logger.warn do
|
|
89
|
+
"[RubyNative] Rejected OAuth callback_scheme #{value.to_s.truncate(64).inspect}: " \
|
|
90
|
+
"expected the app's own rubynative-<bundle-id> scheme"
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
73
95
|
def oauth_path?(request)
|
|
74
96
|
oauth_paths.any? { |p| request.path == p }
|
|
75
97
|
end
|
data/lib/ruby_native/version.rb
CHANGED
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.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- exe/ruby_native
|
|
87
87
|
- lib/generators/ruby_native/iap_generator.rb
|
|
88
88
|
- lib/generators/ruby_native/install_generator.rb
|
|
89
|
+
- lib/generators/ruby_native/templates/add_restored_transaction_id_to_ruby_native_purchase_intents.rb
|
|
89
90
|
- lib/generators/ruby_native/templates/create_ruby_native_purchase_intents.rb
|
|
90
91
|
- lib/generators/ruby_native/templates/ruby_native.yml
|
|
91
92
|
- lib/ruby_native.rb
|