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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c596379ed0170d7784e8273e1ac3c10023ebc6115a7d8a7d1edb2633f7325449
4
- data.tar.gz: b966e8aeb40899fa32ff8f34f85dffc031b073f0637cf1917083f43ce504e06a
3
+ metadata.gz: 9febf470fc9b33fbbfe136f4d7dc916f002ce532707281a65c1d9733cab09559
4
+ data.tar.gz: 9789db1959e99b53cc8a5d40dcf8a093032553569c854bcfcb5d54fa3610f54f
5
5
  SHA512:
6
- metadata.gz: fbddcd87a18dc14cd8eacdf1b4456c6cacc41c2f26c76e3dc0d6d2383b8c40a76de274901d3456d2ee40a79f56edd3b17d0a9510918e7a67adc9d6fa675f57d3
7
- data.tar.gz: 1d9bbb26a648e42952fd11de6d63cb65286d8247e5c847fbd2007760de213ed371f5ff6a61372f582a9cfe75b26fb10f8b11f6c5187187f415af4c511021a748
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 an iOS app, with Android in public beta. 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).
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
- customer_id = params[:customer_id]
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 do |signed_transaction|
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
- Time.now.utc.strftime("%Y%m%d%H%M%S")
14
+ ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
13
15
  end
14
16
 
15
- def copy_migration
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 ""
@@ -0,0 +1,5 @@
1
+ class AddRestoredTransactionIdToRubyNativePurchaseIntents < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :ruby_native_purchase_intents, :restored_transaction_id, :string
4
+ end
5
+ end
@@ -79,17 +79,22 @@ module RubyNative
79
79
  end
80
80
 
81
81
  def fetch_latest_build(app_id)
82
- uri = URI("#{HOST}/api/v1/apps/#{app_id}/builds/latest")
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
- when Net::HTTPSuccess
90
- JSON.parse(response.body)
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
- code = SecureRandom.hex(20)
17
- url = "#{HOST}/cli/session/new?code=#{code}"
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(code)
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(code)
48
- uri = URI("#{HOST}/cli/session/poll?code=#{code}")
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
- (0...(size + quiet_v * 2)).each do |r|
178
- line = +""
179
- (0...(size + quiet_h * 2)).each do |c|
180
- mr = r - quiet_v
181
- mc = c - quiet_h
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 ""
@@ -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
- def share_button(url: nil, title: "Share", icon: "square.and.arrow.up", icons: nil, position: :trailing)
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: nil, selected: false)
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
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.11.1"
2
+ VERSION = "0.12.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
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