@mpgd/capacitor-storekit 0.3.9 → 0.4.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.
- package/Package.swift +35 -5
- package/README.md +46 -11
- package/dist/store-sdk.d.ts +7 -2
- package/dist/store-sdk.js +33 -11
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +134 -120
- package/ios/Sources/MpgdStoreKitBridge/OpenIapAppleConnection.swift +68 -0
- package/ios/Sources/MpgdStoreKitBridge/RecoveryReadShim.swift +85 -0
- package/ios/Sources/MpgdStoreKitBridge/RevocationUpdatesShim.swift +57 -0
- package/ios/Sources/MpgdStoreKitBridge/StoreKitTransactionPolicy.swift +356 -0
- package/package.json +9 -9
package/Package.swift
CHANGED
|
@@ -1,19 +1,49 @@
|
|
|
1
1
|
// swift-tools-version: 5.9
|
|
2
2
|
import PackageDescription
|
|
3
3
|
|
|
4
|
+
// `MPGD_STOREKIT_BRIDGE_TESTS=1 swift test` builds only the store-neutral
|
|
5
|
+
// bridge and its XCTests on a Mac. Capacitor ships iOS-only binaries, so the
|
|
6
|
+
// plugin target is left out of that host build. Consumers never set it.
|
|
7
|
+
let bridgeTestsOnly = Context.environment["MPGD_STOREKIT_BRIDGE_TESTS"] == "1"
|
|
8
|
+
|
|
9
|
+
let openIap = Target.Dependency.product(name: "OpenIAP", package: "openiap")
|
|
10
|
+
|
|
11
|
+
let bridge = Target.target(
|
|
12
|
+
name: "MpgdStoreKitBridge",
|
|
13
|
+
dependencies: [openIap],
|
|
14
|
+
path: "ios/Sources/MpgdStoreKitBridge"
|
|
15
|
+
)
|
|
16
|
+
|
|
4
17
|
let package = Package(
|
|
5
18
|
name: "MpgdCapacitorStoreKit",
|
|
6
|
-
platforms: [.iOS(.v15)],
|
|
7
|
-
products: [
|
|
8
|
-
.library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"])
|
|
19
|
+
platforms: bridgeTestsOnly ? [.iOS(.v15), .macOS(.v14)] : [.iOS(.v15)],
|
|
20
|
+
products: bridgeTestsOnly ? [] : [
|
|
21
|
+
.library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"]),
|
|
22
|
+
// `cap sync ios` derives the product it links from the npm package
|
|
23
|
+
// name (`@mpgd/capacitor-storekit` -> `MpgdCapacitorStorekit`).
|
|
24
|
+
.library(name: "MpgdCapacitorStorekit", targets: ["MpgdCapacitorStoreKit"])
|
|
9
25
|
],
|
|
10
|
-
dependencies: [
|
|
26
|
+
dependencies: (bridgeTestsOnly ? [] : [
|
|
11
27
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2")
|
|
28
|
+
]) + [
|
|
29
|
+
// OpenIAP Apple core (StoreKit 2), pinned exactly. See
|
|
30
|
+
// docs/specs/commerce/openiap-migration.md before changing it.
|
|
31
|
+
.package(url: "https://github.com/hyodotdev/openiap.git", exact: "4.0.5")
|
|
12
32
|
],
|
|
13
|
-
targets: [
|
|
33
|
+
targets: bridgeTestsOnly ? [
|
|
34
|
+
bridge,
|
|
35
|
+
.testTarget(
|
|
36
|
+
name: "MpgdStoreKitBridgeTests",
|
|
37
|
+
dependencies: ["MpgdStoreKitBridge", openIap],
|
|
38
|
+
path: "ios/Tests/MpgdStoreKitBridgeTests"
|
|
39
|
+
)
|
|
40
|
+
] : [
|
|
41
|
+
bridge,
|
|
14
42
|
.target(
|
|
15
43
|
name: "MpgdCapacitorStoreKit",
|
|
16
44
|
dependencies: [
|
|
45
|
+
"MpgdStoreKitBridge",
|
|
46
|
+
openIap,
|
|
17
47
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
18
48
|
.product(name: "Cordova", package: "capacitor-swift-pm")
|
|
19
49
|
],
|
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @mpgd/capacitor-storekit
|
|
2
2
|
|
|
3
|
-
Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS
|
|
3
|
+
Opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS, built
|
|
4
|
+
on the [OpenIAP](https://github.com/hyodotdev/openiap) Apple core (4.0.5).
|
|
4
5
|
It does not enable native IAP in the base Capacitor shell. Install it only in a
|
|
5
6
|
game-owned iOS project and register `createCapacitorStoreKitServiceProvider(...)`
|
|
6
7
|
in that game's provider list.
|
|
@@ -93,7 +94,12 @@ type disagrees with the catalog kind is never listed or emitted, so the
|
|
|
93
94
|
reconciler never journals a purchase it cannot resolve or that belongs to
|
|
94
95
|
another player, and never blocks a valid sibling on one. Each exclusion is
|
|
95
96
|
reported through `onDiagnostic` (`unmapped-sku`, `account-mismatch`,
|
|
96
|
-
`kind-mismatch`); diagnostics are never a grant signal.
|
|
97
|
+
`kind-mismatch`); diagnostics are never a grant signal. One exception: the
|
|
98
|
+
player's Restore Purchases read (`getAvailablePurchases({ restore: true })`,
|
|
99
|
+
sent only by the reconciler's `restorePurchases`) also lists a durable bound
|
|
100
|
+
to another or no token, the state of every earlier purchase after a reinstall
|
|
101
|
+
or on a new iPhone or iPad, so the backend's restore sharing can decide;
|
|
102
|
+
consumables of another token stay excluded. The native
|
|
97
103
|
`transactionUpdated` listener is attached while either a `purchaseUpdated`
|
|
98
104
|
or a `purchaseError` subscriber exists.
|
|
99
105
|
|
|
@@ -146,8 +152,11 @@ C3): the service provider is registered for every catalog product whose
|
|
|
146
152
|
`platformProductIds.ios` names an App Store product id (subscriptions are
|
|
147
153
|
skipped), and `getAppAccountToken` is the reference binding in
|
|
148
154
|
`src/platform/commerceAccountBinding.ts`, a UUID (version 5 layout) over the
|
|
149
|
-
SHA-256 of `mpgd-commerce-account:<playerId>` for the
|
|
150
|
-
|
|
155
|
+
SHA-256 of `mpgd-commerce-account:<playerId>` for the server session's
|
|
156
|
+
player (the generated game's `guestIdentity.ts` signs in a server-issued
|
|
157
|
+
guest when `VITE_MPGD_IDENTITY_URL` is set) or an authenticated platform
|
|
158
|
+
player; the native shell's local player throws an error naming the missing
|
|
159
|
+
server session, so no sheet opens without a binding. The backend's
|
|
151
160
|
`resolveAppAccountToken` must derive the same UUID, or both are replaced
|
|
152
161
|
together.
|
|
153
162
|
|
|
@@ -172,10 +181,13 @@ of truth. The reconciler sends only transactions whose
|
|
|
172
181
|
each one on the backend under its original idempotency key or returns the
|
|
173
182
|
existing ledger grant, and calls `finishTransaction` only after a verified
|
|
174
183
|
response with a ledger entry id. The StoreKit SDK and the native plugin both
|
|
175
|
-
refuse to finish a transaction whose app account
|
|
176
|
-
not match that session. A caller who can read the token from the plugin can
|
|
184
|
+
refuse to call `transaction.finish()` on a transaction whose app account
|
|
185
|
+
token is missing or does not match that session. A caller who can read the token from the plugin can
|
|
177
186
|
still present it with a forged ledger entry id; that remaining hole needs a
|
|
178
|
-
signed server finish capability.
|
|
187
|
+
signed server finish capability. A listed, non-revoked durable StoreKit
|
|
188
|
+
already finished (`unfinished: false`) answers `{ finished: true }` without a
|
|
189
|
+
native call whatever its token, so a purchase shared to this guest from an
|
|
190
|
+
earlier guest's restore ends there. Permanently rejected and account-mismatched
|
|
179
191
|
transactions are not finished: the client cannot prove content delivery for
|
|
180
192
|
them, so persist that terminal result in the game-owned operation journal and
|
|
181
193
|
do not run an automatic retry loop for it. An unknown backend result stays
|
|
@@ -198,8 +210,31 @@ Commerce v2 PR C4 removed the v1 provider and its recovery driver.
|
|
|
198
210
|
| `gateway.commerceV2`, the v1 `commerce.*` bridge methods | `gateway.commerce` and the `commerce.v2.*` methods |
|
|
199
211
|
| `features: ['nativeIap']` on the registry entry | none: the entry declares no `features` and its readiness is `getCapabilities().commerce` |
|
|
200
212
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
## Native engine
|
|
214
|
+
|
|
215
|
+
The Swift package keeps its 0.3 Capacitor contract (methods,
|
|
216
|
+
`transactionUpdated`, payload fields, `STOREKIT_*` codes, the finish gate)
|
|
217
|
+
over OpenIAP's `OpenIapModule`; the
|
|
218
|
+
[migration record](../../docs/specs/commerce/openiap-migration.md) lists the
|
|
219
|
+
contract, the call mapping, and every behavior difference. Two points to know
|
|
220
|
+
when integrating:
|
|
221
|
+
|
|
222
|
+
- `getTransactions` and the `finishTransaction` lookup read StoreKit's
|
|
223
|
+
verified lists directly (a read-only shim), because OpenIAP's queries fail
|
|
224
|
+
as a whole on one unverifiable transaction; an unverified entry is skipped
|
|
225
|
+
on its own, as in 0.3. The lists carry every app account token; the
|
|
226
|
+
TypeScript SDK admits the session account.
|
|
227
|
+
- OpenIAP does not deliver revoked transactions, so a small shim forwards
|
|
228
|
+
verified revocations from `Transaction.updates`; it never finishes anything.
|
|
229
|
+
|
|
230
|
+
The package declares the products `MpgdCapacitorStoreKit` and
|
|
231
|
+
`MpgdCapacitorStorekit` (the spelling `cap sync ios` links). SwiftPM clones
|
|
232
|
+
the OpenIAP monorepo on first resolution.
|
|
233
|
+
|
|
234
|
+
The Kit tests use mocked StoreKit responses, Swift XCTests of the native
|
|
235
|
+
policy (`MPGD_STOREKIT_BRIDGE_TESTS=1 swift test` on a Mac), and simulator
|
|
236
|
+
builds of the workspace and the packed package
|
|
237
|
+
(`pnpm smoke:native-packed-store-plugins ios`). None of these checks proves a
|
|
238
|
+
signed App Store purchase, sandbox checkout, TestFlight installation, or
|
|
239
|
+
device grant. Validate those with a
|
|
205
240
|
game-owned account, keys, and device before a production rollout.
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -39,7 +39,8 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
|
|
|
39
39
|
readonly products: readonly CapacitorStoreKitStoreSdkProduct[];
|
|
40
40
|
/**
|
|
41
41
|
* The active player's app account token (a UUID). Transactions bound to any
|
|
42
|
-
* other token are never
|
|
42
|
+
* other token are never emitted and never listed, except a durable on the
|
|
43
|
+
* player's Restore Purchases read; the backend binds the same value.
|
|
43
44
|
*/
|
|
44
45
|
readonly getAppAccountToken: () => string | Promise<string>;
|
|
45
46
|
readonly sdk?: CapacitorStoreKitStoreSdkPlugin;
|
|
@@ -60,7 +61,11 @@ export interface CreateCapacitorStoreKitStoreSdkInput {
|
|
|
60
61
|
* StoreKit type disagrees with the catalog kind is reported through
|
|
61
62
|
* `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
|
|
62
63
|
* the reconciler never journals a purchase it cannot resolve or that belongs
|
|
63
|
-
* to another player.
|
|
64
|
+
* to another player. One exception: the player's Restore Purchases read
|
|
65
|
+
* (`getAvailablePurchases({ restore: true })`) also lists durables bound to
|
|
66
|
+
* another or no token, the state of every earlier purchase after a reinstall
|
|
67
|
+
* or on a new device; the reconciler sends them with `intent: 'restore'` and
|
|
68
|
+
* the backend's restore sharing decides (RFC "Restore sharing").
|
|
64
69
|
*/
|
|
65
70
|
export declare function createCapacitorStoreKitStoreSdk(input: CreateCapacitorStoreKitStoreSdkInput): StoreSdk;
|
|
66
71
|
export { storeKitStoreSdkCapabilities };
|
package/dist/store-sdk.js
CHANGED
|
@@ -15,7 +15,11 @@ import { CapacitorStoreKit } from './plugin.js';
|
|
|
15
15
|
* StoreKit type disagrees with the catalog kind is reported through
|
|
16
16
|
* `onDiagnostic` and never reaches `getAvailablePurchases` or an event, so
|
|
17
17
|
* the reconciler never journals a purchase it cannot resolve or that belongs
|
|
18
|
-
* to another player.
|
|
18
|
+
* to another player. One exception: the player's Restore Purchases read
|
|
19
|
+
* (`getAvailablePurchases({ restore: true })`) also lists durables bound to
|
|
20
|
+
* another or no token, the state of every earlier purchase after a reinstall
|
|
21
|
+
* or on a new device; the reconciler sends them with `intent: 'restore'` and
|
|
22
|
+
* the backend's restore sharing decides (RFC "Restore sharing").
|
|
19
23
|
*/
|
|
20
24
|
export function createCapacitorStoreKitStoreSdk(input) {
|
|
21
25
|
const sdk = input.sdk ?? CapacitorStoreKit;
|
|
@@ -69,8 +73,13 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
69
73
|
/**
|
|
70
74
|
* Keeps a verified transaction only when it is sellable to the active
|
|
71
75
|
* player: configured SKU, declared kind, and the active account token.
|
|
76
|
+
* `restore` (the player's Restore Purchases read) also keeps a durable
|
|
77
|
+
* bound to another or no token: after a reinstall or on a new device every
|
|
78
|
+
* earlier purchase carries the earlier guest's token, and the backend's
|
|
79
|
+
* restore sharing decides whether this player may hold it. A consumable
|
|
80
|
+
* bound to another token is never kept.
|
|
72
81
|
*/
|
|
73
|
-
const admit = (transaction, appAccountToken) => {
|
|
82
|
+
const admit = (transaction, appAccountToken, restore = false) => {
|
|
74
83
|
const expected = kinds.get(transaction.productId);
|
|
75
84
|
if (expected === undefined) {
|
|
76
85
|
onDiagnostic({
|
|
@@ -80,7 +89,10 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
80
89
|
});
|
|
81
90
|
return false;
|
|
82
91
|
}
|
|
83
|
-
|
|
92
|
+
const restorable = restore
|
|
93
|
+
&& expected === 'durable'
|
|
94
|
+
&& kindOf(transaction.type) === 'durable';
|
|
95
|
+
if (transaction.appAccountToken?.toLowerCase() !== appAccountToken && !restorable) {
|
|
84
96
|
onDiagnostic({
|
|
85
97
|
type: 'account-mismatch',
|
|
86
98
|
sku: transaction.productId,
|
|
@@ -247,7 +259,11 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
247
259
|
/**
|
|
248
260
|
* Mirrors the native finish gate: the transaction must be listed, not
|
|
249
261
|
* revoked, and bound to the session app account token. A missing list is
|
|
250
|
-
* an invalid response, not a quiet "not finished".
|
|
262
|
+
* an invalid response, not a quiet "not finished". A listed, non-revoked
|
|
263
|
+
* durable StoreKit already finished (`unfinished: false`) has nothing left
|
|
264
|
+
* to finish whatever its token: a restore shared to this player from an
|
|
265
|
+
* earlier guest's purchase ends there instead of retrying a finish the
|
|
266
|
+
* native token match refuses.
|
|
251
267
|
*/
|
|
252
268
|
const finishPermitted = async (transactionId, appAccountToken) => {
|
|
253
269
|
const response = await sdk.getTransactions();
|
|
@@ -255,9 +271,13 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
255
271
|
throw evidenceInvalid('StoreKit returned an invalid transaction list.');
|
|
256
272
|
}
|
|
257
273
|
const transaction = response.transactions.find((candidate) => isValidStoreKitTransaction(candidate) && candidate.transactionId === transactionId);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
274
|
+
if (transaction === undefined || transaction.revokedAt !== undefined) {
|
|
275
|
+
return 'refused';
|
|
276
|
+
}
|
|
277
|
+
if (transaction.type === 'non_consumable' && transaction.unfinished === false) {
|
|
278
|
+
return 'finished';
|
|
279
|
+
}
|
|
280
|
+
return transaction.appAccountToken?.toLowerCase() === appAccountToken ? 'finish' : 'refused';
|
|
261
281
|
};
|
|
262
282
|
return {
|
|
263
283
|
store: appStoreStoreId,
|
|
@@ -310,10 +330,11 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
310
330
|
throw evidenceInvalid('StoreKit returned an invalid purchase result.');
|
|
311
331
|
}
|
|
312
332
|
},
|
|
313
|
-
async getAvailablePurchases() {
|
|
333
|
+
async getAvailablePurchases(request) {
|
|
314
334
|
// Pending (Ask to Buy) transactions never appear in `Transaction.unfinished`
|
|
315
335
|
// before approval, so `includePending` has nothing to filter here.
|
|
316
336
|
requireIos();
|
|
337
|
+
const restore = request?.restore === true;
|
|
317
338
|
const appAccountToken = await resolveAppAccountToken();
|
|
318
339
|
const response = await sdk.getTransactions();
|
|
319
340
|
if (!isRecord(response) || !Array.isArray(response.transactions)) {
|
|
@@ -341,7 +362,7 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
341
362
|
continue;
|
|
342
363
|
}
|
|
343
364
|
seen.add(transaction.transactionId);
|
|
344
|
-
if (admit(transaction, appAccountToken)) {
|
|
365
|
+
if (admit(transaction, appAccountToken, restore)) {
|
|
345
366
|
purchases.push(toSdkPurchase(transaction));
|
|
346
367
|
}
|
|
347
368
|
}
|
|
@@ -364,8 +385,9 @@ export function createCapacitorStoreKitStoreSdk(input) {
|
|
|
364
385
|
const promise = (async () => {
|
|
365
386
|
// Same match the native plugin applies: missing, revoked, unbound, or
|
|
366
387
|
// another account never reaches `transaction.finish()`.
|
|
367
|
-
|
|
368
|
-
|
|
388
|
+
const permitted = await finishPermitted(transactionId, appAccountToken);
|
|
389
|
+
if (permitted !== 'finish') {
|
|
390
|
+
return { finished: permitted === 'finished' };
|
|
369
391
|
}
|
|
370
392
|
const reported = await sdk.finishTransaction({
|
|
371
393
|
transactionId,
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import Capacitor
|
|
2
2
|
import Foundation
|
|
3
|
-
import
|
|
3
|
+
import MpgdStoreKitBridge
|
|
4
|
+
import OpenIAP
|
|
4
5
|
|
|
6
|
+
/// The `CapacitorStoreKit` plugin: the 0.3 JavaScript contract over the
|
|
7
|
+
/// OpenIAP Apple core. OpenIAP talks to StoreKit 2; this class only parses
|
|
8
|
+
/// Capacitor calls, applies `StoreKitTransactionPolicy`, and answers in the
|
|
9
|
+
/// payload shapes the 0.3 plugin defined. Nothing here grants, and nothing
|
|
10
|
+
/// here finishes a transaction except `finishTransaction`, which the game
|
|
11
|
+
/// calls only after its backend confirmed the ledger grant.
|
|
5
12
|
@objc(CapacitorStoreKitPlugin)
|
|
6
13
|
public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
7
14
|
public let identifier = "CapacitorStoreKitPlugin"
|
|
@@ -14,45 +21,57 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
14
21
|
CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
|
|
15
22
|
]
|
|
16
23
|
|
|
17
|
-
private
|
|
24
|
+
private let connection = OpenIapAppleConnection()
|
|
25
|
+
private let revocations = RevocationUpdatesShim()
|
|
26
|
+
|
|
27
|
+
private var module: OpenIapModule { connection.module }
|
|
18
28
|
|
|
19
29
|
@objc override public func load() {
|
|
20
30
|
super.load()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// OpenIAP delivers verified, non-revoked updates (and the result of a
|
|
32
|
+
// purchase call, which StoreSdk and the reconciler deduplicate); the
|
|
33
|
+
// shim delivers verified revocations OpenIAP skips. Neither is
|
|
34
|
+
// retained for a listener that attaches later: `getTransactions` is
|
|
35
|
+
// the authoritative recovery query.
|
|
36
|
+
connection.start { [weak self] purchase in
|
|
37
|
+
guard let transaction = StoreKitTransactionPolicy.transaction(from: purchase) else { return }
|
|
38
|
+
self?.notifyListeners("transactionUpdated", data: StoreKitTransactionPolicy.payload(transaction))
|
|
39
|
+
}
|
|
40
|
+
if connection.providerError == nil {
|
|
41
|
+
revocations.start { [weak self] transaction in
|
|
42
|
+
// Delivered on the main actor, as OpenIAP's listener and the
|
|
43
|
+
// 0.3 update loop were, never from the shim's detached task.
|
|
44
|
+
Task { @MainActor in
|
|
45
|
+
self?.notifyListeners("transactionUpdated", data: StoreKitTransactionPolicy.payload(transaction))
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
}
|
|
29
49
|
}
|
|
30
50
|
|
|
31
51
|
deinit {
|
|
32
|
-
|
|
52
|
+
revocations.stop()
|
|
53
|
+
connection.stop()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Rejects the call when OpenIAP did not select the Apple provider.
|
|
57
|
+
private func providerReady(_ call: CAPPluginCall) -> Bool {
|
|
58
|
+
guard let message = connection.providerError else { return true }
|
|
59
|
+
call.reject(message, "STOREKIT_CONFIGURATION_ERROR")
|
|
60
|
+
return false
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
@objc func getProducts(_ call: CAPPluginCall) {
|
|
36
64
|
guard let identifiers = call.options["productIds"] as? [String],
|
|
37
65
|
!identifiers.isEmpty, identifiers.count <= 100,
|
|
38
|
-
identifiers.allSatisfy({
|
|
66
|
+
identifiers.allSatisfy({ StoreKitTransactionPolicy.isValidProductId($0) }) else {
|
|
39
67
|
call.reject("Provide 1-100 valid product IDs.", "STOREKIT_INVALID_PRODUCTS")
|
|
40
68
|
return
|
|
41
69
|
}
|
|
42
|
-
|
|
70
|
+
guard providerReady(call) else { return }
|
|
71
|
+
Task { @MainActor [module] in
|
|
43
72
|
do {
|
|
44
|
-
let
|
|
45
|
-
let values
|
|
46
|
-
guard let type = Self.oneTimeType(product.type) else { return nil }
|
|
47
|
-
return [
|
|
48
|
-
"productId": product.id,
|
|
49
|
-
"type": type,
|
|
50
|
-
"title": product.displayName,
|
|
51
|
-
"description": product.description,
|
|
52
|
-
"formattedPrice": product.displayPrice,
|
|
53
|
-
"currencyCode": product.priceFormatStyle.currencyCode
|
|
54
|
-
]
|
|
55
|
-
}
|
|
73
|
+
let result = try await module.fetchProducts(ProductRequest(skus: identifiers, type: .inApp))
|
|
74
|
+
let values = Self.iosProducts(result).compactMap(StoreKitTransactionPolicy.productPayload)
|
|
56
75
|
call.resolve(["products": values])
|
|
57
76
|
} catch {
|
|
58
77
|
call.reject("StoreKit product lookup failed.", "STOREKIT_UNAVAILABLE", error)
|
|
@@ -61,46 +80,60 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
@objc func purchase(_ call: CAPPluginCall) {
|
|
64
|
-
guard let productId = call.getString("productId"),
|
|
65
|
-
|
|
66
|
-
let accountToken =
|
|
83
|
+
guard let productId = call.getString("productId"),
|
|
84
|
+
StoreKitTransactionPolicy.isValidProductId(productId),
|
|
85
|
+
let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
|
|
86
|
+
call.getString("appAccountToken"), allowNil: true) else {
|
|
67
87
|
call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
|
|
68
88
|
return
|
|
69
89
|
}
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
guard providerReady(call) else { return }
|
|
91
|
+
Task { @MainActor [module] in
|
|
72
92
|
do {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
let result = try await module.fetchProducts(ProductRequest(skus: [productId], type: .inApp))
|
|
94
|
+
guard let product = Self.iosProducts(result).first(where: { $0.id == productId }),
|
|
95
|
+
MpgdOneTimeType(productType: product.typeIOS) != nil else {
|
|
76
96
|
call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
|
|
77
97
|
return
|
|
78
98
|
}
|
|
79
|
-
product = found
|
|
80
99
|
} catch {
|
|
81
100
|
call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
|
|
82
101
|
return
|
|
83
102
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
switch result {
|
|
87
|
-
case .success(let verification):
|
|
88
|
-
guard let payload = Self.verifiedPayload(verification),
|
|
89
|
-
payload["productId"] as? String == productId,
|
|
90
|
-
(payload["appAccountToken"] as? String)?.lowercased()
|
|
91
|
-
== accountToken.uuidString.lowercased() else {
|
|
92
|
-
call.reject("StoreKit transaction failed verification or account binding.",
|
|
93
|
-
"STOREKIT_TRANSACTION_UNVERIFIED")
|
|
94
|
-
return
|
|
95
|
-
}
|
|
103
|
+
let request = RequestPurchaseProps(
|
|
104
|
+
request: .purchase(RequestPurchasePropsByPlatforms(apple: RequestPurchaseIosProps(
|
|
96
105
|
// Never finish here: the game backend must commit its ledger grant first.
|
|
97
|
-
|
|
106
|
+
andDangerouslyFinishTransactionAutomatically: false,
|
|
107
|
+
appAccountToken: accountToken,
|
|
108
|
+
sku: productId
|
|
109
|
+
))),
|
|
110
|
+
type: .inApp
|
|
111
|
+
)
|
|
112
|
+
do {
|
|
113
|
+
let outcome = try await module.requestPurchase(request)
|
|
114
|
+
guard case .purchase(let purchased?) = outcome,
|
|
115
|
+
case .purchaseIos(let ios) = purchased,
|
|
116
|
+
let transaction = StoreKitTransactionPolicy.transaction(from: ios),
|
|
117
|
+
StoreKitTransactionPolicy.matchesPurchaseRequest(
|
|
118
|
+
transaction, productId: productId, appAccountToken: accountToken) else {
|
|
119
|
+
call.reject("StoreKit transaction failed verification or account binding.",
|
|
120
|
+
"STOREKIT_TRANSACTION_UNVERIFIED")
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
call.resolve([
|
|
124
|
+
"status": "purchased",
|
|
125
|
+
"transaction": StoreKitTransactionPolicy.payload(transaction)
|
|
126
|
+
])
|
|
127
|
+
} catch let error as PurchaseError {
|
|
128
|
+
switch StoreKitTransactionPolicy.purchaseFailure(error.code) {
|
|
129
|
+
case .cancelled:
|
|
130
|
+
call.resolve(["status": "cancelled"])
|
|
98
131
|
case .pending:
|
|
132
|
+
// Ask to Buy: the approved transaction arrives through
|
|
133
|
+
// `transactionUpdated` or `getTransactions` later.
|
|
99
134
|
call.resolve(["status": "pending"])
|
|
100
|
-
case .
|
|
101
|
-
call.
|
|
102
|
-
@unknown default:
|
|
103
|
-
call.reject("StoreKit returned an unknown purchase result.", "STOREKIT_UNKNOWN_RESULT")
|
|
135
|
+
case .rejected(let code, let message):
|
|
136
|
+
call.reject(message, code, error)
|
|
104
137
|
}
|
|
105
138
|
} catch {
|
|
106
139
|
// The purchase sheet may have completed before an error reached us.
|
|
@@ -111,36 +144,28 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
@objc func getTransactions(_ call: CAPPluginCall) {
|
|
147
|
+
guard providerReady(call) else { return }
|
|
114
148
|
Task {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// grant can be verified. The server ledger dedupes.
|
|
127
|
-
for await result in Transaction.currentEntitlements {
|
|
128
|
-
if var payload = Self.verifiedPayload(result),
|
|
129
|
-
payload["type"] as? String == "non_consumable",
|
|
130
|
-
let id = payload["transactionId"] as? String,
|
|
131
|
-
byId[id] == nil {
|
|
132
|
-
payload["unfinished"] = false
|
|
133
|
-
byId[id] = payload
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
call.resolve(["transactions": Array(byId.values)])
|
|
149
|
+
// Verified `Transaction.unfinished`, then current entitlements, read
|
|
150
|
+
// by `RecoveryReadShim`: an unverified entry is skipped on its own
|
|
151
|
+
// (0.3 behavior) instead of failing the whole OpenIAP query. Never
|
|
152
|
+
// `Transaction.all`: finished consumables must not return to the
|
|
153
|
+
// recovery list. No account filter: the TypeScript SDK admits.
|
|
154
|
+
let unfinished = await RecoveryReadShim.unfinished()
|
|
155
|
+
let entitlements = await RecoveryReadShim.currentEntitlements()
|
|
156
|
+
call.resolve(["transactions": StoreKitTransactionPolicy.listedTransactions(
|
|
157
|
+
unfinished: unfinished,
|
|
158
|
+
entitlements: entitlements
|
|
159
|
+
)])
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
@objc func sync(_ call: CAPPluginCall) {
|
|
141
|
-
|
|
164
|
+
guard providerReady(call) else { return }
|
|
165
|
+
// User-initiated restore only (`AppStore.sync` may show sign-in UI).
|
|
166
|
+
Task { @MainActor [module] in
|
|
142
167
|
do {
|
|
143
|
-
try await
|
|
168
|
+
_ = try await module.syncIOS()
|
|
144
169
|
call.resolve(["synced": true])
|
|
145
170
|
} catch {
|
|
146
171
|
call.reject("StoreKit account synchronization failed.", "STOREKIT_SYNC_FAILED", error)
|
|
@@ -155,67 +180,56 @@ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
155
180
|
// SDK supplies the session token and applies the same match before
|
|
156
181
|
// calling. A signed server finish capability is still required to stop
|
|
157
182
|
// a webview caller who read the token from `getTransactions`.
|
|
158
|
-
guard let
|
|
183
|
+
guard let transactionId = call.getString("transactionId"), UInt64(transactionId) != nil,
|
|
159
184
|
let ledgerEntryId = call.getString("ledgerEntryId"),
|
|
160
185
|
!ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
161
|
-
let
|
|
162
|
-
|
|
163
|
-
accountToken.uuidString.lowercased()
|
|
164
|
-
!= "00000000-0000-0000-0000-000000000000" else {
|
|
186
|
+
let accountToken = StoreKitTransactionPolicy.normalizedAccountToken(
|
|
187
|
+
call.getString("appAccountToken")) else {
|
|
165
188
|
call.reject(
|
|
166
189
|
"A transaction ID, confirmed ledger entry, and app account token are required.",
|
|
167
190
|
"STOREKIT_FINISH_REQUIRES_GRANT"
|
|
168
191
|
)
|
|
169
192
|
return
|
|
170
193
|
}
|
|
171
|
-
|
|
172
|
-
Task {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
194
|
+
guard providerReady(call) else { return }
|
|
195
|
+
Task { [module] in
|
|
196
|
+
do {
|
|
197
|
+
// The verified transaction behind the id, through the same read
|
|
198
|
+
// as `getTransactions` (an unverified sibling never blocks it):
|
|
199
|
+
// unfinished first, then the StoreKit history
|
|
200
|
+
// (`Transaction.all`) the 0.3 plugin searched, so finishing an
|
|
201
|
+
// already-finished durable stays an idempotent `finished: true`.
|
|
202
|
+
var candidate = StoreKitTransactionPolicy.finishable(
|
|
203
|
+
transactionId: transactionId,
|
|
204
|
+
appAccountToken: accountToken,
|
|
205
|
+
in: await RecoveryReadShim.unfinished())
|
|
206
|
+
if candidate == nil {
|
|
207
|
+
candidate = StoreKitTransactionPolicy.finishable(
|
|
208
|
+
transactionId: transactionId,
|
|
209
|
+
appAccountToken: accountToken,
|
|
210
|
+
in: await RecoveryReadShim.history())
|
|
211
|
+
}
|
|
212
|
+
guard let match = candidate else {
|
|
213
|
+
call.resolve(["finished": false])
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
// OpenIAP finishes it (and checks the Apple storeId).
|
|
217
|
+
try await module.finishTransaction(
|
|
218
|
+
purchase: .purchaseIos(StoreKitTransactionPolicy.finishInput(match)),
|
|
219
|
+
isConsumable: match.type == .consumable)
|
|
181
220
|
call.resolve(["finished": true])
|
|
182
|
-
|
|
221
|
+
} catch {
|
|
222
|
+
call.reject("StoreKit could not finish the transaction.",
|
|
223
|
+
StoreKitTransactionPolicy.queryFailureCode(error), error)
|
|
183
224
|
}
|
|
184
|
-
call.resolve(["finished": false])
|
|
185
225
|
}
|
|
186
226
|
}
|
|
187
227
|
|
|
188
|
-
private static func
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
var payload: [String: Any] = [
|
|
194
|
-
"transactionId": String(transaction.id),
|
|
195
|
-
"originalTransactionId": String(transaction.originalID),
|
|
196
|
-
"productId": transaction.productID,
|
|
197
|
-
"type": type,
|
|
198
|
-
"purchasedAt": ISO8601DateFormatter().string(from: transaction.purchaseDate),
|
|
199
|
-
"signedTransaction": result.jwsRepresentation
|
|
200
|
-
]
|
|
201
|
-
if let accountToken = transaction.appAccountToken {
|
|
202
|
-
payload["appAccountToken"] = accountToken.uuidString.lowercased()
|
|
203
|
-
}
|
|
204
|
-
if let revocationDate = transaction.revocationDate {
|
|
205
|
-
payload["revokedAt"] = ISO8601DateFormatter().string(from: revocationDate)
|
|
228
|
+
private static func iosProducts(_ result: FetchProductsResult) -> [ProductIOS] {
|
|
229
|
+
guard case .products(let products?) = result else { return [] }
|
|
230
|
+
return products.compactMap { product in
|
|
231
|
+
if case .productIos(let ios) = product { return ios }
|
|
232
|
+
return nil
|
|
206
233
|
}
|
|
207
|
-
return payload
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private static func oneTimeType(_ type: Product.ProductType) -> String? {
|
|
211
|
-
switch type {
|
|
212
|
-
case .consumable: return "consumable"
|
|
213
|
-
case .nonConsumable: return "non_consumable"
|
|
214
|
-
default: return nil
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private static func validProductId(_ value: String) -> Bool {
|
|
219
|
-
value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
|
|
220
234
|
}
|
|
221
235
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import OpenIAP
|
|
3
|
+
|
|
4
|
+
/// The OpenIAP Apple connection one Capacitor plugin instance owns.
|
|
5
|
+
///
|
|
6
|
+
/// This is the store-neutral half of the bridge: it selects the Apple
|
|
7
|
+
/// provider explicitly, checks that the selected provider really is `apple`,
|
|
8
|
+
/// keeps one OpenIAP purchase listener for the plugin's lifetime, and closes
|
|
9
|
+
/// the connection when the plugin goes away. mpgd policy (which transactions
|
|
10
|
+
/// are reported and finished) lives in `StoreKitTransactionPolicy`.
|
|
11
|
+
public final class OpenIapAppleConnection {
|
|
12
|
+
public let module: OpenIapModule
|
|
13
|
+
private var purchaseSubscription: Subscription?
|
|
14
|
+
private var started = false
|
|
15
|
+
|
|
16
|
+
/// The Apple provider is selected explicitly rather than through the
|
|
17
|
+
/// `dev.hyo.openiap.PROVIDER` Info.plist key, so a host app cannot route
|
|
18
|
+
/// mpgd's App Store evidence through another store's provider.
|
|
19
|
+
public init(module: OpenIapModule = OpenIapModule(factory: OpenIapAppleProviderFactory())) {
|
|
20
|
+
self.module = module
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// The provider error, or nil when OpenIAP selected the Apple provider.
|
|
24
|
+
/// Every plugin method rejects while this is non-nil.
|
|
25
|
+
public var providerError: String? {
|
|
26
|
+
guard let storeId = module.storeId else {
|
|
27
|
+
return "The OpenIAP Apple provider could not be selected."
|
|
28
|
+
}
|
|
29
|
+
guard storeId == StoreKitTransactionPolicy.appleStoreId else {
|
|
30
|
+
return "OpenIAP selected the \(storeId) provider; this plugin requires apple."
|
|
31
|
+
}
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Opens the connection and attaches the single purchase listener. Safe to
|
|
36
|
+
/// call more than once; later calls are no-ops. `onPurchase` receives every
|
|
37
|
+
/// verified purchase OpenIAP reports, replays included
|
|
38
|
+
/// (`dedupeTransactionIOS: false`), so the plugin keeps forwarding each
|
|
39
|
+
/// StoreKit update the way the 0.3 `Transaction.updates` loop did.
|
|
40
|
+
public func start(onPurchase: @escaping @Sendable (PurchaseIOS) -> Void) {
|
|
41
|
+
guard !started, providerError == nil else { return }
|
|
42
|
+
started = true
|
|
43
|
+
purchaseSubscription = module.purchaseUpdatedListener({ purchase in
|
|
44
|
+
if case .purchaseIos(let ios) = purchase {
|
|
45
|
+
onPurchase(ios)
|
|
46
|
+
}
|
|
47
|
+
}, options: PurchaseUpdatedListenerOptions(dedupeTransactionIOS: false))
|
|
48
|
+
Task { [module] in
|
|
49
|
+
// Every OpenIAP call also ensures the connection; this only starts
|
|
50
|
+
// the transaction listener at launch, as the 0.3 plugin did.
|
|
51
|
+
_ = try? await module.initConnection()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Removes the listener and ends the connection. The plugin calls this
|
|
56
|
+
/// from `deinit`; nothing is finished or cancelled in StoreKit.
|
|
57
|
+
public func stop() {
|
|
58
|
+
if let subscription = purchaseSubscription {
|
|
59
|
+
module.removeListener(subscription)
|
|
60
|
+
purchaseSubscription = nil
|
|
61
|
+
}
|
|
62
|
+
guard started else { return }
|
|
63
|
+
started = false
|
|
64
|
+
Task { [module] in
|
|
65
|
+
_ = try? await module.endConnection()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import StoreKit
|
|
3
|
+
|
|
4
|
+
/// Compatibility shim: the verified, read-only transaction lists behind
|
|
5
|
+
/// `getTransactions` and the `finishTransaction` lookup.
|
|
6
|
+
///
|
|
7
|
+
/// Why: OpenIAP Apple 4.0.5's `getPendingTransactionsIOS` and
|
|
8
|
+
/// `getAvailablePurchases` throw on the first transaction StoreKit cannot
|
|
9
|
+
/// verify (`OpenIapStoreKitModule.checkVerified`), so one unverifiable
|
|
10
|
+
/// transaction on a device would hide every verified one from recovery and
|
|
11
|
+
/// block finishing them. The 0.3 plugin skipped an unverified transaction on
|
|
12
|
+
/// its own. This shim reads `Transaction.unfinished`,
|
|
13
|
+
/// `Transaction.currentEntitlements`, and `Transaction.all` the same way and
|
|
14
|
+
/// classifies each entry (`StoreKitTransactionPolicy.ReadEntry`).
|
|
15
|
+
///
|
|
16
|
+
/// Scope: reads only. It never purchases, finishes, or filters by account
|
|
17
|
+
/// (the TypeScript SDK admits transactions for the session account). Purchase,
|
|
18
|
+
/// finish, and the update listener stay on OpenIAP; the finish itself is
|
|
19
|
+
/// `OpenIapModule.finishTransaction`, whose own `Transaction.unfinished` scan
|
|
20
|
+
/// skips unverified entries.
|
|
21
|
+
///
|
|
22
|
+
/// Errors: these StoreKit sequences do not throw. Nothing here turns a
|
|
23
|
+
/// failure into an empty list; an empty list means StoreKit returned none.
|
|
24
|
+
///
|
|
25
|
+
/// Removal: delete this shim once the pinned OpenIAP release offers a
|
|
26
|
+
/// verified-only read that skips (and reports) unverified entries (upstream
|
|
27
|
+
/// request 3 in `docs/specs/commerce/openiap-upstream-proposal.md`).
|
|
28
|
+
public enum RecoveryReadShim {
|
|
29
|
+
/// `Transaction.unfinished`.
|
|
30
|
+
public static func unfinished() async -> [StoreKitTransactionPolicy.ReadEntry] {
|
|
31
|
+
await read(StoreKit.Transaction.unfinished)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// `Transaction.currentEntitlements` (active non-consumables and
|
|
35
|
+
/// subscriptions; revoked ones are not entitlements).
|
|
36
|
+
public static func currentEntitlements() async -> [StoreKitTransactionPolicy.ReadEntry] {
|
|
37
|
+
await read(StoreKit.Transaction.currentEntitlements)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// `Transaction.all`, the history the 0.3 finish searched.
|
|
41
|
+
public static func history() async -> [StoreKitTransactionPolicy.ReadEntry] {
|
|
42
|
+
await read(StoreKit.Transaction.all)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static func read(_ sequence: StoreKit.Transaction.Transactions) async -> [StoreKitTransactionPolicy.ReadEntry] {
|
|
46
|
+
var entries: [StoreKitTransactionPolicy.ReadEntry] = []
|
|
47
|
+
for await result in sequence {
|
|
48
|
+
entries.append(entry(result))
|
|
49
|
+
}
|
|
50
|
+
return entries
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static func entry(
|
|
54
|
+
_ result: VerificationResult<StoreKit.Transaction>
|
|
55
|
+
) -> StoreKitTransactionPolicy.ReadEntry {
|
|
56
|
+
guard case .verified(let transaction) = result else { return .unverified }
|
|
57
|
+
guard let mapped = MpgdStoreKitTransaction(verified: transaction, jws: result.jwsRepresentation) else {
|
|
58
|
+
return .notOneTime
|
|
59
|
+
}
|
|
60
|
+
return .oneTime(mapped)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
extension MpgdStoreKitTransaction {
|
|
65
|
+
/// The 0.3 fields of a StoreKit-verified one-time transaction; nil for a
|
|
66
|
+
/// subscription. Shared by the read and revocation shims.
|
|
67
|
+
init?(verified transaction: StoreKit.Transaction, jws: String) {
|
|
68
|
+
let type: MpgdOneTimeType
|
|
69
|
+
switch transaction.productType {
|
|
70
|
+
case .consumable: type = .consumable
|
|
71
|
+
case .nonConsumable: type = .nonConsumable
|
|
72
|
+
default: return nil
|
|
73
|
+
}
|
|
74
|
+
self.init(
|
|
75
|
+
transactionId: String(transaction.id),
|
|
76
|
+
originalTransactionId: String(transaction.originalID),
|
|
77
|
+
productId: transaction.productID,
|
|
78
|
+
type: type,
|
|
79
|
+
appAccountToken: transaction.appAccountToken?.uuidString,
|
|
80
|
+
purchasedAtMillis: transaction.purchaseDate.timeIntervalSince1970 * 1000,
|
|
81
|
+
revokedAtMillis: transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 },
|
|
82
|
+
signedTransaction: jws
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import StoreKit
|
|
3
|
+
|
|
4
|
+
/// Compatibility shim: forwards verified **revoked** one-time transactions.
|
|
5
|
+
///
|
|
6
|
+
/// Why: OpenIAP Apple 4.0.5's `Transaction.updates` listener skips any
|
|
7
|
+
/// transaction with a `revocationDate` (`OpenIapStoreKitModule`
|
|
8
|
+
/// `startTransactionListener`, "Skipping revoked transaction"). The 0.3 mpgd
|
|
9
|
+
/// plugin forwarded those updates as `transactionUpdated` with `revokedAt`,
|
|
10
|
+
/// and `StoreSdk` turns them into `state: 'revoked'` so the game asks its
|
|
11
|
+
/// server to re-check the grant. Dropping them would silently remove the
|
|
12
|
+
/// refund signal.
|
|
13
|
+
///
|
|
14
|
+
/// Scope: this observes `Transaction.updates` only to read revocations. It
|
|
15
|
+
/// never purchases, lists, or finishes anything, and it ignores every
|
|
16
|
+
/// non-revoked update (OpenIAP's listener delivers those). The signal is a
|
|
17
|
+
/// prompt for the server to re-verify, never a ledger clawback by itself.
|
|
18
|
+
///
|
|
19
|
+
/// Removal: delete this shim once the pinned OpenIAP release can deliver
|
|
20
|
+
/// revoked transactions to its purchase listener (the upstream proposal in
|
|
21
|
+
/// `docs/specs/commerce/openiap-upstream-proposal.md` asks for that).
|
|
22
|
+
public final class RevocationUpdatesShim {
|
|
23
|
+
private var task: Task<Void, Never>?
|
|
24
|
+
|
|
25
|
+
public init() {}
|
|
26
|
+
|
|
27
|
+
public func start(onRevoked: @escaping @Sendable (MpgdStoreKitTransaction) -> Void) {
|
|
28
|
+
guard task == nil else { return }
|
|
29
|
+
task = Task.detached(priority: .utility) {
|
|
30
|
+
for await result in StoreKit.Transaction.updates {
|
|
31
|
+
if Task.isCancelled { return }
|
|
32
|
+
guard case .verified(let transaction) = result,
|
|
33
|
+
let revoked = Self.revokedTransaction(transaction, jws: result.jwsRepresentation) else {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
onRevoked(revoked)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public func stop() {
|
|
42
|
+
task?.cancel()
|
|
43
|
+
task = nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
deinit {
|
|
47
|
+
task?.cancel()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static func revokedTransaction(
|
|
51
|
+
_ transaction: StoreKit.Transaction,
|
|
52
|
+
jws: String
|
|
53
|
+
) -> MpgdStoreKitTransaction? {
|
|
54
|
+
guard transaction.revocationDate != nil else { return nil }
|
|
55
|
+
return MpgdStoreKitTransaction(verified: transaction, jws: jws)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import OpenIAP
|
|
3
|
+
|
|
4
|
+
/// The one-time product kinds the mpgd StoreKit plugin sells and reports.
|
|
5
|
+
/// Raw values are the plugin's wire values (`StoreKitTransaction.type`).
|
|
6
|
+
public enum MpgdOneTimeType: String, Equatable, Sendable {
|
|
7
|
+
case consumable = "consumable"
|
|
8
|
+
case nonConsumable = "non_consumable"
|
|
9
|
+
|
|
10
|
+
/// The App Store product kind as StoreKit and OpenIAP describe a product.
|
|
11
|
+
public init?(productType: ProductTypeIOS) {
|
|
12
|
+
switch productType {
|
|
13
|
+
case .consumable: self = .consumable
|
|
14
|
+
case .nonConsumable: self = .nonConsumable
|
|
15
|
+
default: return nil
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// The `type` claim of a StoreKit-verified JWS transaction
|
|
20
|
+
/// (`JWSTransactionDecodedPayload.type`): `Consumable` or `Non-Consumable`.
|
|
21
|
+
/// Subscriptions and unknown values are not one-time purchases.
|
|
22
|
+
public init?(transactionTypeClaim: String) {
|
|
23
|
+
switch transactionTypeClaim {
|
|
24
|
+
case "Consumable": self = .consumable
|
|
25
|
+
case "Non-Consumable": self = .nonConsumable
|
|
26
|
+
default: return nil
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The fields of one verified StoreKit transaction the mpgd plugin reports.
|
|
32
|
+
/// Both the OpenIAP purchase path and the revocation shim build this value,
|
|
33
|
+
/// so every `transactionUpdated` and `getTransactions` entry has one shape.
|
|
34
|
+
public struct MpgdStoreKitTransaction: Equatable, Sendable {
|
|
35
|
+
public let transactionId: String
|
|
36
|
+
public let originalTransactionId: String
|
|
37
|
+
public let productId: String
|
|
38
|
+
public let type: MpgdOneTimeType
|
|
39
|
+
/// Lowercased UUID, as the 0.3 native plugin reported it.
|
|
40
|
+
public let appAccountToken: String?
|
|
41
|
+
public let purchasedAtMillis: Double
|
|
42
|
+
public let revokedAtMillis: Double?
|
|
43
|
+
/// The StoreKit-verified JWS; the backend verifies it again.
|
|
44
|
+
public let signedTransaction: String
|
|
45
|
+
|
|
46
|
+
public init(
|
|
47
|
+
transactionId: String,
|
|
48
|
+
originalTransactionId: String,
|
|
49
|
+
productId: String,
|
|
50
|
+
type: MpgdOneTimeType,
|
|
51
|
+
appAccountToken: String?,
|
|
52
|
+
purchasedAtMillis: Double,
|
|
53
|
+
revokedAtMillis: Double?,
|
|
54
|
+
signedTransaction: String
|
|
55
|
+
) {
|
|
56
|
+
self.transactionId = transactionId
|
|
57
|
+
self.originalTransactionId = originalTransactionId
|
|
58
|
+
self.productId = productId
|
|
59
|
+
self.type = type
|
|
60
|
+
self.appAccountToken = appAccountToken?.lowercased()
|
|
61
|
+
self.purchasedAtMillis = purchasedAtMillis
|
|
62
|
+
self.revokedAtMillis = revokedAtMillis
|
|
63
|
+
self.signedTransaction = signedTransaction
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// mpgd policy over OpenIAP's canonical Apple purchase: which transactions the
|
|
68
|
+
/// plugin reports, the payload shape it reports them in, which ones it may
|
|
69
|
+
/// finish, and how OpenIAP errors become the plugin's existing codes. Nothing
|
|
70
|
+
/// here calls StoreKit; the Capacitor plugin and tests drive it directly.
|
|
71
|
+
public enum StoreKitTransactionPolicy {
|
|
72
|
+
/// The provider the plugin requires. OpenIAP's Apple provider reports
|
|
73
|
+
/// `apple`; mpgd names the same store `app-store` on the JavaScript side.
|
|
74
|
+
public static let appleStoreId = StoreIds.Apple
|
|
75
|
+
|
|
76
|
+
// MARK: - Mapping
|
|
77
|
+
|
|
78
|
+
/// What the plugin makes of one OpenIAP Apple purchase (the `purchase`
|
|
79
|
+
/// result and listener events).
|
|
80
|
+
public enum Classification: Equatable {
|
|
81
|
+
/// A verified consumable or non-consumable the plugin reports.
|
|
82
|
+
case oneTime(MpgdStoreKitTransaction)
|
|
83
|
+
/// A subscription (its JWS `type` claim says so); never reported.
|
|
84
|
+
case notOneTime
|
|
85
|
+
/// No readable StoreKit JWS, no `type` claim, another provider, or no
|
|
86
|
+
/// id. Never reported from the OpenIAP purchase and listener paths.
|
|
87
|
+
case unreadable
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public static func classify(_ purchase: PurchaseIOS) -> Classification {
|
|
91
|
+
guard purchase.storeId == appleStoreId, purchase.store == .apple,
|
|
92
|
+
!purchase.id.isEmpty, !purchase.productId.isEmpty,
|
|
93
|
+
let jws = purchase.purchaseToken, isCompactJws(jws),
|
|
94
|
+
let claims = jwsPayload(jws),
|
|
95
|
+
let typeClaim = claims["type"] as? String else {
|
|
96
|
+
return .unreadable
|
|
97
|
+
}
|
|
98
|
+
guard let type = MpgdOneTimeType(transactionTypeClaim: typeClaim) else {
|
|
99
|
+
return .notOneTime
|
|
100
|
+
}
|
|
101
|
+
return .oneTime(MpgdStoreKitTransaction(
|
|
102
|
+
transactionId: purchase.id,
|
|
103
|
+
// OpenIAP omits an original id of 0; the 0.3 plugin always sent one.
|
|
104
|
+
originalTransactionId: purchase.originalTransactionIdentifierIOS ?? purchase.id,
|
|
105
|
+
productId: purchase.productId,
|
|
106
|
+
type: type,
|
|
107
|
+
appAccountToken: purchase.appAccountToken,
|
|
108
|
+
purchasedAtMillis: purchase.transactionDate,
|
|
109
|
+
revokedAtMillis: purchase.revocationDateIOS,
|
|
110
|
+
signedTransaction: jws
|
|
111
|
+
))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The verified one-time transaction an OpenIAP Apple purchase describes,
|
|
115
|
+
/// or nil for anything the plugin does not report.
|
|
116
|
+
public static func transaction(from purchase: PurchaseIOS) -> MpgdStoreKitTransaction? {
|
|
117
|
+
if case .oneTime(let transaction) = classify(purchase) { return transaction }
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// The `StoreKitTransaction` payload of the 0.3 native plugin, key for key.
|
|
122
|
+
/// `unfinished` is set only on `getTransactions` entries.
|
|
123
|
+
public static func payload(
|
|
124
|
+
_ transaction: MpgdStoreKitTransaction,
|
|
125
|
+
unfinished: Bool? = nil
|
|
126
|
+
) -> [String: Any] {
|
|
127
|
+
var payload: [String: Any] = [
|
|
128
|
+
"transactionId": transaction.transactionId,
|
|
129
|
+
"originalTransactionId": transaction.originalTransactionId,
|
|
130
|
+
"productId": transaction.productId,
|
|
131
|
+
"type": transaction.type.rawValue,
|
|
132
|
+
"purchasedAt": isoTimestamp(milliseconds: transaction.purchasedAtMillis),
|
|
133
|
+
"signedTransaction": transaction.signedTransaction
|
|
134
|
+
]
|
|
135
|
+
if let token = transaction.appAccountToken {
|
|
136
|
+
payload["appAccountToken"] = token
|
|
137
|
+
}
|
|
138
|
+
if let revoked = transaction.revokedAtMillis {
|
|
139
|
+
payload["revokedAt"] = isoTimestamp(milliseconds: revoked)
|
|
140
|
+
}
|
|
141
|
+
if let unfinished {
|
|
142
|
+
payload["unfinished"] = unfinished
|
|
143
|
+
}
|
|
144
|
+
return payload
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// The `StoreKitProduct` payload of a one-time product, or nil for a
|
|
148
|
+
/// subscription the plugin does not sell.
|
|
149
|
+
public static func productPayload(_ product: ProductIOS) -> [String: Any]? {
|
|
150
|
+
guard let type = MpgdOneTimeType(productType: product.typeIOS) else {
|
|
151
|
+
return nil
|
|
152
|
+
}
|
|
153
|
+
return [
|
|
154
|
+
"productId": product.id,
|
|
155
|
+
"type": type.rawValue,
|
|
156
|
+
"title": product.displayNameIOS,
|
|
157
|
+
"description": product.description,
|
|
158
|
+
"formattedPrice": product.displayPrice,
|
|
159
|
+
"currencyCode": product.currency
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// One entry of a StoreKit read (`RecoveryReadShim`): a verified one-time
|
|
164
|
+
/// transaction, a verified subscription the plugin never reports, or a
|
|
165
|
+
/// transaction StoreKit could not verify, which is skipped on its own as
|
|
166
|
+
/// the 0.3 plugin did, so it never hides or blocks a verified sibling.
|
|
167
|
+
public enum ReadEntry: Equatable, Sendable {
|
|
168
|
+
case oneTime(MpgdStoreKitTransaction)
|
|
169
|
+
case notOneTime
|
|
170
|
+
case unverified
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// `getTransactions`: every verified unfinished one-time transaction
|
|
174
|
+
/// (`unfinished: true`), then every current non-consumable entitlement not
|
|
175
|
+
/// already listed (`unfinished: false`), merged by transaction id.
|
|
176
|
+
/// Finished consumables are never added: they are not current
|
|
177
|
+
/// entitlements, and the game reads a consumable leaving this list as
|
|
178
|
+
/// evidence that its finish landed. No account filter here: the
|
|
179
|
+
/// TypeScript SDK admits transactions for the session account (and
|
|
180
|
+
/// restore handling needs to see the others).
|
|
181
|
+
public static func listedTransactions(
|
|
182
|
+
unfinished: [ReadEntry],
|
|
183
|
+
entitlements: [ReadEntry]
|
|
184
|
+
) -> [[String: Any]] {
|
|
185
|
+
var seen = Set<String>()
|
|
186
|
+
var listed: [[String: Any]] = []
|
|
187
|
+
for entry in unfinished {
|
|
188
|
+
guard case .oneTime(let transaction) = entry,
|
|
189
|
+
seen.insert(transaction.transactionId).inserted else { continue }
|
|
190
|
+
listed.append(payload(transaction, unfinished: true))
|
|
191
|
+
}
|
|
192
|
+
for entry in entitlements {
|
|
193
|
+
guard case .oneTime(let transaction) = entry,
|
|
194
|
+
transaction.type == .nonConsumable,
|
|
195
|
+
seen.insert(transaction.transactionId).inserted else { continue }
|
|
196
|
+
listed.append(payload(transaction, unfinished: false))
|
|
197
|
+
}
|
|
198
|
+
return listed
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// The transaction `finishTransaction` may finish: the verified one-time
|
|
202
|
+
/// transaction with this id that is not revoked and whose app account
|
|
203
|
+
/// token equals the session token. OpenIAP finishes whatever it is given,
|
|
204
|
+
/// so this is the plugin's local gate; it is still not a signed server
|
|
205
|
+
/// capability (see `finishTransaction` in the plugin definitions).
|
|
206
|
+
public static func finishable(
|
|
207
|
+
transactionId: String,
|
|
208
|
+
appAccountToken: String,
|
|
209
|
+
in entries: [ReadEntry]
|
|
210
|
+
) -> MpgdStoreKitTransaction? {
|
|
211
|
+
let expected = appAccountToken.lowercased()
|
|
212
|
+
for entry in entries {
|
|
213
|
+
guard case .oneTime(let transaction) = entry,
|
|
214
|
+
transaction.transactionId == transactionId,
|
|
215
|
+
transaction.revokedAtMillis == nil,
|
|
216
|
+
transaction.appAccountToken == expected else { continue }
|
|
217
|
+
return transaction
|
|
218
|
+
}
|
|
219
|
+
return nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// The OpenIAP purchase handed to `OpenIapModule.finishTransaction` for a
|
|
223
|
+
/// transaction the read resolved. OpenIAP finishes by transaction id
|
|
224
|
+
/// (looking it up in `Transaction.unfinished`, then
|
|
225
|
+
/// `Transaction.latest(for:)`) after checking `storeId`; these are the
|
|
226
|
+
/// fields it reads.
|
|
227
|
+
public static func finishInput(_ transaction: MpgdStoreKitTransaction) -> PurchaseIOS {
|
|
228
|
+
PurchaseIOS(
|
|
229
|
+
appAccountToken: transaction.appAccountToken,
|
|
230
|
+
id: transaction.transactionId,
|
|
231
|
+
isAutoRenewing: false,
|
|
232
|
+
originalTransactionIdentifierIOS: transaction.originalTransactionId,
|
|
233
|
+
productId: transaction.productId,
|
|
234
|
+
purchaseState: .purchased,
|
|
235
|
+
purchaseToken: transaction.signedTransaction,
|
|
236
|
+
quantity: 1,
|
|
237
|
+
revocationDateIOS: transaction.revokedAtMillis,
|
|
238
|
+
store: .apple,
|
|
239
|
+
storeId: appleStoreId,
|
|
240
|
+
transactionDate: transaction.purchasedAtMillis,
|
|
241
|
+
transactionId: transaction.transactionId
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/// Whether a just-purchased transaction is the one the call asked for:
|
|
246
|
+
/// same product and bound to the token the purchase was started with.
|
|
247
|
+
public static func matchesPurchaseRequest(
|
|
248
|
+
_ transaction: MpgdStoreKitTransaction,
|
|
249
|
+
productId: String,
|
|
250
|
+
appAccountToken: String
|
|
251
|
+
) -> Bool {
|
|
252
|
+
transaction.productId == productId
|
|
253
|
+
&& transaction.appAccountToken == appAccountToken.lowercased()
|
|
254
|
+
&& transaction.revokedAtMillis == nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// MARK: - Errors
|
|
258
|
+
|
|
259
|
+
/// How a `requestPurchase` failure answers the plugin's `purchase` call.
|
|
260
|
+
public enum PurchaseFailure: Equatable {
|
|
261
|
+
case cancelled
|
|
262
|
+
/// Ask to Buy or another deferred payment: never a cancellation.
|
|
263
|
+
case pending
|
|
264
|
+
case rejected(code: String, message: String)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Maps an OpenIAP purchase error to the 0.3 plugin outcome. Anything that
|
|
268
|
+
/// may have happened after the purchase sheet opened stays
|
|
269
|
+
/// `STOREKIT_PURCHASE_UNCERTAIN`, so the game requeries instead of
|
|
270
|
+
/// assuming nothing was bought.
|
|
271
|
+
public static func purchaseFailure(_ code: ErrorCode) -> PurchaseFailure {
|
|
272
|
+
switch code {
|
|
273
|
+
case .userCancelled:
|
|
274
|
+
return .cancelled
|
|
275
|
+
case .deferredPayment, .pending:
|
|
276
|
+
return .pending
|
|
277
|
+
case .transactionValidationFailed:
|
|
278
|
+
return .rejected(code: "STOREKIT_TRANSACTION_UNVERIFIED",
|
|
279
|
+
message: "StoreKit transaction failed verification or account binding.")
|
|
280
|
+
case .skuNotFound, .itemUnavailable, .emptySkuList:
|
|
281
|
+
return .rejected(code: "STOREKIT_PRODUCT_UNAVAILABLE",
|
|
282
|
+
message: "The one-time product is unavailable.")
|
|
283
|
+
case .queryProduct:
|
|
284
|
+
return .rejected(code: "STOREKIT_PRODUCT_LOOKUP_FAILED",
|
|
285
|
+
message: "StoreKit product lookup failed.")
|
|
286
|
+
case .developerError:
|
|
287
|
+
return .rejected(code: "STOREKIT_INVALID_PURCHASE",
|
|
288
|
+
message: "A product ID and app account token are required.")
|
|
289
|
+
case .iapNotAvailable, .notPrepared, .initConnection:
|
|
290
|
+
// Raised before any purchase sheet: nothing was bought.
|
|
291
|
+
return .rejected(code: "STOREKIT_UNAVAILABLE",
|
|
292
|
+
message: "StoreKit purchases are unavailable on this device.")
|
|
293
|
+
case .unknown:
|
|
294
|
+
return .rejected(code: "STOREKIT_UNKNOWN_RESULT",
|
|
295
|
+
message: "StoreKit returned an unknown purchase result.")
|
|
296
|
+
default:
|
|
297
|
+
return .rejected(code: "STOREKIT_PURCHASE_UNCERTAIN",
|
|
298
|
+
message: "StoreKit purchase result is uncertain; requery transactions.")
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// The code an OpenIAP failure rejects with (`finishTransaction`). The
|
|
303
|
+
/// transaction lists come from `RecoveryReadShim`, which skips an
|
|
304
|
+
/// unverified entry instead of failing.
|
|
305
|
+
public static func queryFailureCode(_ error: Error) -> String {
|
|
306
|
+
if let purchaseError = error as? PurchaseError,
|
|
307
|
+
purchaseError.code == .transactionValidationFailed {
|
|
308
|
+
return "STOREKIT_TRANSACTION_UNVERIFIED"
|
|
309
|
+
}
|
|
310
|
+
return "STOREKIT_UNAVAILABLE"
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - Validation and formatting
|
|
314
|
+
|
|
315
|
+
public static func isValidProductId(_ value: String) -> Bool {
|
|
316
|
+
value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// A UUID app account token, lowercased; nil when invalid. `finish`
|
|
320
|
+
/// also refuses the nil UUID, as the 0.3 plugin did; `purchase` accepted
|
|
321
|
+
/// any UUID in 0.3 and still does (the StoreKit SDK never sends the nil one).
|
|
322
|
+
public static func normalizedAccountToken(_ value: String?, allowNil: Bool = false) -> String? {
|
|
323
|
+
guard let value, let uuid = UUID(uuidString: value) else { return nil }
|
|
324
|
+
let normalized = uuid.uuidString.lowercased()
|
|
325
|
+
return !allowNil && normalized == "00000000-0000-0000-0000-000000000000" ? nil : normalized
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// The 0.3 plugin's timestamp format: `ISO8601DateFormatter` defaults.
|
|
329
|
+
public static func isoTimestamp(milliseconds: Double) -> String {
|
|
330
|
+
ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: milliseconds / 1000))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
static func isCompactJws(_ value: String) -> Bool {
|
|
334
|
+
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
|
335
|
+
return parts.count == 3 && parts.allSatisfy { !$0.isEmpty }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// The claims of a compact JWS StoreKit already verified. Only read for
|
|
339
|
+
/// the `type` claim OpenIAP's `PurchaseIOS` does not carry.
|
|
340
|
+
static func jwsPayload(_ jws: String) -> [String: Any]? {
|
|
341
|
+
let parts = jws.split(separator: ".", omittingEmptySubsequences: false)
|
|
342
|
+
guard parts.count == 3 else { return nil }
|
|
343
|
+
var base64 = String(parts[1])
|
|
344
|
+
.replacingOccurrences(of: "-", with: "+")
|
|
345
|
+
.replacingOccurrences(of: "_", with: "/")
|
|
346
|
+
while base64.count % 4 != 0 {
|
|
347
|
+
base64.append("=")
|
|
348
|
+
}
|
|
349
|
+
guard let data = Data(base64Encoded: base64),
|
|
350
|
+
let object = try? JSONSerialization.jsonObject(with: data),
|
|
351
|
+
let claims = object as? [String: Any] else {
|
|
352
|
+
return nil
|
|
353
|
+
}
|
|
354
|
+
return claims
|
|
355
|
+
}
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/capacitor-storekit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Opt-in StoreKit 2 one-time purchase collector and commerce v2 provider for mpgd Capacitor games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,23 +33,23 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
|
-
"ios",
|
|
36
|
+
"ios/Sources",
|
|
37
37
|
"Package.swift"
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
|
-
"@mpgd/adapter-capacitor": "^0.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.14.0",
|
|
42
42
|
"@mpgd/bridge": "^0.16.0",
|
|
43
|
-
"@mpgd/platform": "^0.
|
|
43
|
+
"@mpgd/platform": "^0.30.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@capacitor/core": "8.5.2",
|
|
47
47
|
"ttsc": "0.32.1",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
|
-
"@mpgd/adapter-capacitor": "0.
|
|
50
|
-
"@mpgd/
|
|
51
|
-
"@mpgd/
|
|
52
|
-
"@mpgd/
|
|
49
|
+
"@mpgd/adapter-capacitor": "0.14.0",
|
|
50
|
+
"@mpgd/game-services": "0.30.0",
|
|
51
|
+
"@mpgd/platform": "0.30.0",
|
|
52
|
+
"@mpgd/bridge": "0.16.2"
|
|
53
53
|
},
|
|
54
54
|
"main": "./dist/index.js",
|
|
55
55
|
"types": "./dist/index.d.ts",
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
"scripts": {
|
|
60
60
|
"check": "ttsc --noEmit",
|
|
61
61
|
"lint": "ttsc --noEmit",
|
|
62
|
-
"test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts"
|
|
62
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/store-sdk.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-profile.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-provider.conformance.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-reconciler-storekit-smoke.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/openiap-engine-swap.test.ts && node tools/run-ttsx.mjs --project native-plugins/capacitor-storekit/tsconfig.json native-plugins/capacitor-storekit/src/commerce-restore-reinstall.test.ts"
|
|
63
63
|
}
|
|
64
64
|
}
|