@mpgd/capacitor-storekit 0.1.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 +23 -0
- package/README.md +86 -0
- package/dist/definitions.d.ts +53 -0
- package/dist/definitions.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +2 -0
- package/dist/provider.d.ts +94 -0
- package/dist/provider.js +297 -0
- package/ios/Sources/CapacitorStoreKit/CapacitorStoreKitPlugin.swift +199 -0
- package/package.json +49 -0
package/Package.swift
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// swift-tools-version: 5.9
|
|
2
|
+
import PackageDescription
|
|
3
|
+
|
|
4
|
+
let package = Package(
|
|
5
|
+
name: "MpgdCapacitorStoreKit",
|
|
6
|
+
platforms: [.iOS(.v15)],
|
|
7
|
+
products: [
|
|
8
|
+
.library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"])
|
|
9
|
+
],
|
|
10
|
+
dependencies: [
|
|
11
|
+
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2")
|
|
12
|
+
],
|
|
13
|
+
targets: [
|
|
14
|
+
.target(
|
|
15
|
+
name: "MpgdCapacitorStoreKit",
|
|
16
|
+
dependencies: [
|
|
17
|
+
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
|
18
|
+
.product(name: "Cordova", package: "capacitor-swift-pm")
|
|
19
|
+
],
|
|
20
|
+
path: "ios/Sources/CapacitorStoreKit"
|
|
21
|
+
)
|
|
22
|
+
]
|
|
23
|
+
)
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @mpgd/capacitor-storekit
|
|
2
|
+
|
|
3
|
+
Private, opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
|
|
4
|
+
It does not enable native IAP in the base Capacitor shell. Install it only in a
|
|
5
|
+
game-owned iOS project and register `createCapacitorStoreKitProvider(...)` in
|
|
6
|
+
that game's provider list.
|
|
7
|
+
|
|
8
|
+
Map each logical game product to its App Store Connect product ID and provide
|
|
9
|
+
an authenticated, stable UUID `getAppAccountToken()` value. The game backend
|
|
10
|
+
must resolve the same UUID for the player. Product lookup, purchase results,
|
|
11
|
+
`Transaction.updates`, unfinished transactions, and current non-consumable
|
|
12
|
+
entitlements provide **provisional evidence**, never a wallet or entitlement
|
|
13
|
+
grant. A completed native purchase returns no `authoritativeGrant`.
|
|
14
|
+
|
|
15
|
+
On startup, account restoration, and after a pending or uncertain checkout,
|
|
16
|
+
call `recoverStoreKitPurchases({ provider, backend, playerId })`. It sends only
|
|
17
|
+
transactions whose `appAccountToken` matches the current authenticated game
|
|
18
|
+
account to the game-owned `backend.recoverPurchase` contract. That backend
|
|
19
|
+
must reuse the original idempotency key from the durable purchase journal, or
|
|
20
|
+
return the existing ledger grant for the same Apple evidence, product and
|
|
21
|
+
player. It must not invent a new key for an already-granted transaction: the
|
|
22
|
+
generic game-services verifier rejects such evidence replay. For new evidence,
|
|
23
|
+
the backend uses the existing App Store Server API/JWS verifier to validate
|
|
24
|
+
bundle, product, environment and account binding before the ledger grant.
|
|
25
|
+
The recovery helper calls native `finishTransaction` only after a
|
|
26
|
+
verified response with a ledger entry ID. If finish fails, it reports
|
|
27
|
+
`finishPending: true`; repeat recovery with the original purchase identity.
|
|
28
|
+
An unknown backend result stays pending and never triggers a
|
|
29
|
+
new purchase sheet automatically.
|
|
30
|
+
|
|
31
|
+
On the game server, `@mpgd/game-services/app-store-recovery` provides
|
|
32
|
+
`createAppStoreRecoveryBackend(...)` for this contract. Construct it only after
|
|
33
|
+
authenticating the game player, and bind `playerId` to that authenticated
|
|
34
|
+
identity. Pass the same durable `GameServicesStore` instance and
|
|
35
|
+
`createGameServicesBackend(...).purchases` used for checkout. Its optional
|
|
36
|
+
`resolveOriginalIdempotencyKey` callback must query the game's durable
|
|
37
|
+
checkout journal for the **original** key associated with this player and
|
|
38
|
+
transaction. The adapter first checks existing App Store ledger evidence and
|
|
39
|
+
rejects cross-player, cross-product, cross-deployment and key collisions. If
|
|
40
|
+
neither a matching grant nor an original journal key exists, it returns
|
|
41
|
+
`pending` without attempting a new grant. Do not use the in-memory store for
|
|
42
|
+
production recovery. The game's HTTP/RPC endpoint must authenticate the
|
|
43
|
+
request; this helper does not expose one or authenticate clients.
|
|
44
|
+
For restored non-consumables whose new transaction ID differs from the
|
|
45
|
+
original, configure `restoredNonConsumables` with the same catalog and official
|
|
46
|
+
App Store evidence verifier, bundle ID and environment used by the game backend.
|
|
47
|
+
When no prior grant exists, also bind `deploymentTarget` on the server-side
|
|
48
|
+
recovery backend. The client-supplied target is not trusted to select a product;
|
|
49
|
+
without a bound target this restore stays pending until the game backend is
|
|
50
|
+
configured for its deployment. An existing grant can supply its recorded target.
|
|
51
|
+
The bundle/environment identify an existing grant for its deployment target
|
|
52
|
+
before product lookup; they must match the verifier configuration. The helper re-verifies
|
|
53
|
+
the **current** Apple transaction and looks up the prior grant by its canonical
|
|
54
|
+
original-transaction verification ID before reusing the original key. Without
|
|
55
|
+
that verifier, this restore remains pending rather than trusting a client-
|
|
56
|
+
reported original ID. A verifier that exceeds its timeout also remains pending.
|
|
57
|
+
The native provider forwards both IDs and product type.
|
|
58
|
+
For production-scale ledgers, implement the store's indexed
|
|
59
|
+
`findEntitlementTransactionByPlatformEvidence` and
|
|
60
|
+
`findEntitlementTransactionByIdempotency` methods, plus
|
|
61
|
+
`findEntitlementTransactionByEvidenceVerificationId` for restored
|
|
62
|
+
non-consumables. Without them, the helper
|
|
63
|
+
uses the same full-ledger scan fallback as the generic game-services server.
|
|
64
|
+
|
|
65
|
+
An existing grant is returned by the normal backend idempotent-retry path;
|
|
66
|
+
that path relies on the prior verified ledger record rather than fetching
|
|
67
|
+
fresh Apple status. Continue to process App Store revocations separately.
|
|
68
|
+
Permanently rejected and account-mismatched transactions are reported as
|
|
69
|
+
`rejected` and are **not** finished: the helper cannot prove content delivery
|
|
70
|
+
for them. Persist that terminal result in the game-owned operation journal and
|
|
71
|
+
do not run an automatic retry loop for it. A support flow may recheck a
|
|
72
|
+
corrected account or backend decision later. One malformed transaction is
|
|
73
|
+
reported separately and cannot block valid sibling transactions.
|
|
74
|
+
|
|
75
|
+
The iOS native plugin also emits `transactionUpdated` for delayed StoreKit
|
|
76
|
+
transactions. Call recovery after this event and on app launch because events
|
|
77
|
+
can be missed while the game is closed. `commerce.restore` intentionally
|
|
78
|
+
invokes user-initiated `AppStore.sync()` before returning no local entitlements;
|
|
79
|
+
call recovery afterward. Subscriptions are not supported by this package;
|
|
80
|
+
they require a separate subscription status and renewal flow.
|
|
81
|
+
|
|
82
|
+
The Kit tests use mocked StoreKit responses and a syntax-only local Swift
|
|
83
|
+
parse. The macOS CI job compiles the optional Swift package against an iOS
|
|
84
|
+
Simulator SDK. Neither check proves a signed App Store purchase, sandbox
|
|
85
|
+
checkout, TestFlight installation, or device grant. Validate those with a
|
|
86
|
+
game-owned account, keys, and device before a production rollout.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
export interface StoreKitProduct {
|
|
3
|
+
readonly productId: string;
|
|
4
|
+
readonly type: 'consumable' | 'non_consumable';
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly description: string;
|
|
7
|
+
readonly formattedPrice: string;
|
|
8
|
+
readonly currencyCode: string;
|
|
9
|
+
}
|
|
10
|
+
export interface StoreKitTransaction {
|
|
11
|
+
readonly transactionId: string;
|
|
12
|
+
readonly originalTransactionId: string;
|
|
13
|
+
readonly productId: string;
|
|
14
|
+
readonly type: 'consumable' | 'non_consumable';
|
|
15
|
+
readonly appAccountToken?: string;
|
|
16
|
+
readonly purchasedAt: string;
|
|
17
|
+
readonly revokedAt?: string;
|
|
18
|
+
/** StoreKit-verified JWS; the game backend independently verifies Apple data. */
|
|
19
|
+
readonly signedTransaction: string;
|
|
20
|
+
}
|
|
21
|
+
export type StoreKitPurchaseOutcome = {
|
|
22
|
+
readonly status: 'purchased';
|
|
23
|
+
readonly transaction: StoreKitTransaction;
|
|
24
|
+
} | {
|
|
25
|
+
readonly status: 'pending' | 'cancelled';
|
|
26
|
+
};
|
|
27
|
+
export interface CapacitorStoreKitPlugin {
|
|
28
|
+
getProducts(input: {
|
|
29
|
+
readonly productIds: readonly string[];
|
|
30
|
+
}): Promise<{
|
|
31
|
+
readonly products: readonly StoreKitProduct[];
|
|
32
|
+
}>;
|
|
33
|
+
purchase(input: {
|
|
34
|
+
readonly productId: string;
|
|
35
|
+
readonly appAccountToken: string;
|
|
36
|
+
}): Promise<StoreKitPurchaseOutcome>;
|
|
37
|
+
/** Includes unfinished consumables and current non-consumable entitlements. */
|
|
38
|
+
getTransactions(): Promise<{
|
|
39
|
+
readonly transactions: readonly StoreKitTransaction[];
|
|
40
|
+
}>;
|
|
41
|
+
/** User-initiated App Store account synchronization before restore. */
|
|
42
|
+
sync(): Promise<{
|
|
43
|
+
readonly synced: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
/** Call only after the authenticated backend confirms the ledger grant. */
|
|
46
|
+
finishTransaction(input: {
|
|
47
|
+
readonly transactionId: string;
|
|
48
|
+
readonly ledgerEntryId: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
readonly finished: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
|
|
2
|
+
import type { PurchaseResult } from '@mpgd/platform';
|
|
3
|
+
import type { CapacitorStoreKitPlugin } from './definitions.js';
|
|
4
|
+
export interface StoreKitProductMapping {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly storeId: string;
|
|
7
|
+
readonly type: 'consumable' | 'non_consumable';
|
|
8
|
+
}
|
|
9
|
+
export interface StoreKitProviderOptions {
|
|
10
|
+
readonly products: readonly StoreKitProductMapping[];
|
|
11
|
+
/** Must agree with the authenticated backend's resolveAppAccountToken. */
|
|
12
|
+
readonly getAppAccountToken: () => string | Promise<string>;
|
|
13
|
+
readonly sdk?: Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction'>;
|
|
14
|
+
readonly isIos?: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface RecoverableStoreKitTransaction {
|
|
17
|
+
readonly productId: string;
|
|
18
|
+
readonly originalTransactionId: string;
|
|
19
|
+
readonly type: 'consumable' | 'non_consumable';
|
|
20
|
+
readonly appAccountToken?: string;
|
|
21
|
+
readonly purchasedAt: string;
|
|
22
|
+
readonly result: PurchaseResult;
|
|
23
|
+
}
|
|
24
|
+
export interface InvalidStoreKitTransaction {
|
|
25
|
+
readonly productId: string;
|
|
26
|
+
readonly transactionId?: string;
|
|
27
|
+
readonly reason: 'invalid-evidence' | 'revoked';
|
|
28
|
+
}
|
|
29
|
+
export interface StoreKitRecoverySnapshot {
|
|
30
|
+
readonly transactions: readonly RecoverableStoreKitTransaction[];
|
|
31
|
+
readonly invalid: readonly InvalidStoreKitTransaction[];
|
|
32
|
+
}
|
|
33
|
+
export interface CapacitorStoreKitProvider {
|
|
34
|
+
readonly id: 'apple-storekit';
|
|
35
|
+
readonly features: readonly ['nativeIap'];
|
|
36
|
+
readonly methods: readonly [
|
|
37
|
+
'commerce.getProducts',
|
|
38
|
+
'commerce.purchase',
|
|
39
|
+
'commerce.restore',
|
|
40
|
+
'commerce.getEntitlements'
|
|
41
|
+
];
|
|
42
|
+
readonly bridge: {
|
|
43
|
+
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
44
|
+
};
|
|
45
|
+
getAvailability(): Promise<Readonly<{
|
|
46
|
+
nativeIap: 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
|
|
47
|
+
}>>;
|
|
48
|
+
getAppAccountToken(): Promise<string>;
|
|
49
|
+
getRecoverableTransactions(): Promise<StoreKitRecoverySnapshot>;
|
|
50
|
+
finishGrantedTransaction(input: {
|
|
51
|
+
readonly transactionId: string;
|
|
52
|
+
readonly ledgerEntryId: string;
|
|
53
|
+
}): Promise<boolean>;
|
|
54
|
+
}
|
|
55
|
+
export interface StoreKitPurchaseVerification {
|
|
56
|
+
readonly verified: boolean;
|
|
57
|
+
readonly disposition?: 'pending' | 'rejected';
|
|
58
|
+
readonly alreadyProcessed: boolean;
|
|
59
|
+
readonly ledgerEntryId?: string;
|
|
60
|
+
readonly reason?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface StoreKitRecoveryBackend {
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a journaled checkout with its ORIGINAL idempotency key, or return
|
|
65
|
+
* the already-recorded grant for matching App Store evidence and owner.
|
|
66
|
+
* The backend must verify Apple data before returning verified=true.
|
|
67
|
+
*/
|
|
68
|
+
recoverPurchase(input: {
|
|
69
|
+
readonly target: 'ios';
|
|
70
|
+
readonly deploymentTarget?: string;
|
|
71
|
+
readonly playerId: string;
|
|
72
|
+
readonly productId: string;
|
|
73
|
+
readonly platformTransactionId: string;
|
|
74
|
+
readonly originalTransactionId: string;
|
|
75
|
+
readonly productType: 'consumable' | 'non_consumable';
|
|
76
|
+
readonly purchasedAt: string;
|
|
77
|
+
}): Promise<StoreKitPurchaseVerification>;
|
|
78
|
+
}
|
|
79
|
+
export interface RecoveredStoreKitPurchase {
|
|
80
|
+
readonly productId: string;
|
|
81
|
+
readonly transactionId?: string;
|
|
82
|
+
readonly status: 'granted' | 'pending' | 'rejected';
|
|
83
|
+
readonly reason?: 'invalid-evidence' | 'revoked' | 'account-mismatch';
|
|
84
|
+
readonly finishPending?: boolean;
|
|
85
|
+
readonly verification?: StoreKitPurchaseVerification;
|
|
86
|
+
}
|
|
87
|
+
/** Submit unfinished purchases to the backend before asking StoreKit to finish. */
|
|
88
|
+
export declare function recoverStoreKitPurchases(input: {
|
|
89
|
+
readonly provider: CapacitorStoreKitProvider;
|
|
90
|
+
readonly backend: StoreKitRecoveryBackend;
|
|
91
|
+
readonly playerId: string;
|
|
92
|
+
readonly deploymentTarget?: string;
|
|
93
|
+
}): Promise<readonly RecoveredStoreKitPurchase[]>;
|
|
94
|
+
export declare function createCapacitorStoreKitProvider(options: StoreKitProviderOptions): CapacitorStoreKitProvider;
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { Capacitor } from '@capacitor/core';
|
|
2
|
+
import { CapacitorStoreKit } from './plugin.js';
|
|
3
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
4
|
+
const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
5
|
+
const transactionPattern = /^[1-9][0-9]{0,19}$/u;
|
|
6
|
+
/** Submit unfinished purchases to the backend before asking StoreKit to finish. */
|
|
7
|
+
export async function recoverStoreKitPurchases(input) {
|
|
8
|
+
if (input.playerId.trim() === '') {
|
|
9
|
+
throw new TypeError('StoreKit recovery requires an authenticated player ID.');
|
|
10
|
+
}
|
|
11
|
+
const token = await input.provider.getAppAccountToken();
|
|
12
|
+
const snapshot = await input.provider.getRecoverableTransactions();
|
|
13
|
+
const recovered = await Promise.all(snapshot.transactions.map(async (item) => {
|
|
14
|
+
const transactionId = item.result.transactionId;
|
|
15
|
+
if (transactionId === undefined) {
|
|
16
|
+
return { productId: item.productId, status: 'rejected', reason: 'invalid-evidence' };
|
|
17
|
+
}
|
|
18
|
+
if (item.appAccountToken?.toLowerCase() !== token) {
|
|
19
|
+
// Shared Apple IDs can expose a transaction owned by another game user.
|
|
20
|
+
return {
|
|
21
|
+
productId: item.productId, transactionId, status: 'rejected', reason: 'account-mismatch',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const verification = await input.backend.recoverPurchase({
|
|
26
|
+
target: 'ios',
|
|
27
|
+
...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
|
|
28
|
+
playerId: input.playerId,
|
|
29
|
+
productId: item.productId,
|
|
30
|
+
platformTransactionId: transactionId,
|
|
31
|
+
originalTransactionId: item.originalTransactionId,
|
|
32
|
+
productType: item.type,
|
|
33
|
+
purchasedAt: item.purchasedAt,
|
|
34
|
+
});
|
|
35
|
+
if (!verification.verified) {
|
|
36
|
+
return {
|
|
37
|
+
productId: item.productId,
|
|
38
|
+
transactionId,
|
|
39
|
+
status: verification.disposition ?? 'pending',
|
|
40
|
+
verification,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (verification.ledgerEntryId === undefined || verification.ledgerEntryId.trim() === '') {
|
|
44
|
+
return { productId: item.productId, transactionId, status: 'pending', verification };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const finished = await input.provider.finishGrantedTransaction({
|
|
48
|
+
transactionId,
|
|
49
|
+
ledgerEntryId: verification.ledgerEntryId,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
productId: item.productId,
|
|
53
|
+
transactionId,
|
|
54
|
+
status: 'granted',
|
|
55
|
+
finishPending: !finished,
|
|
56
|
+
verification,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {
|
|
61
|
+
productId: item.productId,
|
|
62
|
+
transactionId,
|
|
63
|
+
status: 'granted',
|
|
64
|
+
finishPending: true,
|
|
65
|
+
verification,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { productId: item.productId, transactionId, status: 'pending' };
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
return [
|
|
74
|
+
...recovered,
|
|
75
|
+
...snapshot.invalid.map((item) => ({ ...item, status: 'rejected' })),
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
export function createCapacitorStoreKitProvider(options) {
|
|
79
|
+
const sdk = options.sdk ?? CapacitorStoreKit;
|
|
80
|
+
const isIos = options.isIos ?? (() => Capacitor.getPlatform() === 'ios');
|
|
81
|
+
const byLogicalId = new Map();
|
|
82
|
+
const byStoreId = new Map();
|
|
83
|
+
for (const product of options.products) {
|
|
84
|
+
if (!productPattern.test(product.id)
|
|
85
|
+
|| !productPattern.test(product.storeId)
|
|
86
|
+
|| byLogicalId.has(product.id)
|
|
87
|
+
|| byStoreId.has(product.storeId)) {
|
|
88
|
+
throw new TypeError('StoreKit product mappings must be unique and valid.');
|
|
89
|
+
}
|
|
90
|
+
byLogicalId.set(product.id, product);
|
|
91
|
+
byStoreId.set(product.storeId, product);
|
|
92
|
+
}
|
|
93
|
+
if (byLogicalId.size === 0 || byLogicalId.size > 100) {
|
|
94
|
+
throw new TypeError('StoreKit requires 1-100 one-time product mappings.');
|
|
95
|
+
}
|
|
96
|
+
async function getAppAccountToken() {
|
|
97
|
+
const token = (await options.getAppAccountToken()).toLowerCase();
|
|
98
|
+
if (!uuidPattern.test(token)) {
|
|
99
|
+
throw new TypeError('StoreKit requires a UUID app account token.');
|
|
100
|
+
}
|
|
101
|
+
return token;
|
|
102
|
+
}
|
|
103
|
+
async function getRecoverableTransactions() {
|
|
104
|
+
if (!isIos()) {
|
|
105
|
+
throw new Error('StoreKit is only available on iOS.');
|
|
106
|
+
}
|
|
107
|
+
const response = await sdk.getTransactions();
|
|
108
|
+
const owned = [];
|
|
109
|
+
const invalid = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const transaction of response.transactions) {
|
|
112
|
+
const mapping = byStoreId.get(transaction.productId);
|
|
113
|
+
if (mapping === undefined) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (mapping.type !== transaction.type || !validTransaction(transaction)) {
|
|
117
|
+
invalid.push({
|
|
118
|
+
productId: mapping.id,
|
|
119
|
+
transactionId: transaction.transactionId,
|
|
120
|
+
reason: 'invalid-evidence',
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (transaction.revokedAt !== undefined) {
|
|
125
|
+
invalid.push({
|
|
126
|
+
productId: mapping.id,
|
|
127
|
+
transactionId: transaction.transactionId,
|
|
128
|
+
reason: 'revoked',
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (seen.has(transaction.transactionId)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
seen.add(transaction.transactionId);
|
|
136
|
+
owned.push({
|
|
137
|
+
productId: mapping.id,
|
|
138
|
+
originalTransactionId: transaction.originalTransactionId,
|
|
139
|
+
type: transaction.type,
|
|
140
|
+
...(transaction.appAccountToken === undefined
|
|
141
|
+
? {} : { appAccountToken: transaction.appAccountToken }),
|
|
142
|
+
purchasedAt: transaction.purchasedAt,
|
|
143
|
+
result: toPurchaseResult(transaction),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { transactions: owned, invalid };
|
|
147
|
+
}
|
|
148
|
+
async function request(input) {
|
|
149
|
+
if (!isIos()) {
|
|
150
|
+
return failure(input.id, 'STOREKIT_IOS_REQUIRED');
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
switch (input.method) {
|
|
154
|
+
case 'commerce.getProducts': {
|
|
155
|
+
const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
|
|
156
|
+
const products = [];
|
|
157
|
+
for (const item of response.products) {
|
|
158
|
+
const mapping = byStoreId.get(item.productId);
|
|
159
|
+
if (mapping === undefined || mapping.type !== item.type) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
products.push({
|
|
163
|
+
id: mapping.id,
|
|
164
|
+
type: mapping.type,
|
|
165
|
+
title: item.title,
|
|
166
|
+
description: item.description,
|
|
167
|
+
price: { formatted: item.formattedPrice, currencyCode: item.currencyCode },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return success(input.id, products);
|
|
171
|
+
}
|
|
172
|
+
case 'commerce.purchase': {
|
|
173
|
+
const payload = input.payload;
|
|
174
|
+
const productId = isRecord(payload) ? payload.productId : undefined;
|
|
175
|
+
const mapping = typeof productId === 'string' ? byLogicalId.get(productId) : undefined;
|
|
176
|
+
if (mapping === undefined) {
|
|
177
|
+
return failure(input.id, 'STOREKIT_PRODUCT_UNKNOWN');
|
|
178
|
+
}
|
|
179
|
+
const appAccountToken = await getAppAccountToken();
|
|
180
|
+
const outcome = await sdk.purchase({
|
|
181
|
+
productId: mapping.storeId,
|
|
182
|
+
appAccountToken,
|
|
183
|
+
});
|
|
184
|
+
return success(input.id, convertOutcome(outcome, mapping, appAccountToken));
|
|
185
|
+
}
|
|
186
|
+
case 'commerce.restore':
|
|
187
|
+
await sdk.sync();
|
|
188
|
+
return success(input.id, { restoredEntitlements: [] });
|
|
189
|
+
case 'commerce.getEntitlements':
|
|
190
|
+
return success(input.id, []);
|
|
191
|
+
default:
|
|
192
|
+
return failure(input.id, 'STOREKIT_METHOD_UNSUPPORTED');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
197
|
+
if (input.method === 'commerce.purchase' && code === 'STOREKIT_PURCHASE_UNCERTAIN') {
|
|
198
|
+
return success(input.id, { status: 'pending', entitlementIds: [] });
|
|
199
|
+
}
|
|
200
|
+
if (typeof code === 'string' && /^STOREKIT_[A-Z_]+$/u.test(code)) {
|
|
201
|
+
const retryable = code === 'STOREKIT_UNAVAILABLE'
|
|
202
|
+
|| code === 'STOREKIT_PRODUCT_LOOKUP_FAILED'
|
|
203
|
+
|| code === 'STOREKIT_SYNC_FAILED';
|
|
204
|
+
return failure(input.id, code, retryable);
|
|
205
|
+
}
|
|
206
|
+
if (error instanceof TypeError) {
|
|
207
|
+
return failure(input.id, 'STOREKIT_CONFIGURATION_ERROR');
|
|
208
|
+
}
|
|
209
|
+
return failure(input.id, 'STOREKIT_UNAVAILABLE', true);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
id: 'apple-storekit',
|
|
214
|
+
features: ['nativeIap'],
|
|
215
|
+
methods: [
|
|
216
|
+
'commerce.getProducts',
|
|
217
|
+
'commerce.purchase',
|
|
218
|
+
'commerce.restore',
|
|
219
|
+
'commerce.getEntitlements',
|
|
220
|
+
],
|
|
221
|
+
bridge: { request },
|
|
222
|
+
async getAvailability() {
|
|
223
|
+
if (!isIos()) {
|
|
224
|
+
return { nativeIap: 'unsupported' };
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
await getAppAccountToken();
|
|
228
|
+
const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
|
|
229
|
+
const available = response.products.some((item) => {
|
|
230
|
+
const mapping = byStoreId.get(item.productId);
|
|
231
|
+
return mapping !== undefined && mapping.type === item.type;
|
|
232
|
+
});
|
|
233
|
+
return { nativeIap: available ? 'available' : 'configuration-required' };
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
237
|
+
return { nativeIap: code === 'STOREKIT_INVALID_PRODUCTS'
|
|
238
|
+
|| code === 'STOREKIT_PRODUCT_UNAVAILABLE'
|
|
239
|
+
|| error instanceof TypeError
|
|
240
|
+
? 'configuration-required' : 'temporarily-unavailable' };
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
getAppAccountToken,
|
|
244
|
+
getRecoverableTransactions,
|
|
245
|
+
async finishGrantedTransaction(input) {
|
|
246
|
+
if (!isIos() || !transactionPattern.test(input.transactionId)
|
|
247
|
+
|| input.ledgerEntryId.trim() === '') {
|
|
248
|
+
throw new TypeError('StoreKit finish requires a verified grant and transaction ID.');
|
|
249
|
+
}
|
|
250
|
+
const response = await sdk.finishTransaction(input);
|
|
251
|
+
return response.finished;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function convertOutcome(outcome, mapping, expectedAccountToken) {
|
|
256
|
+
if (outcome.status !== 'purchased') {
|
|
257
|
+
return { status: outcome.status, entitlementIds: [] };
|
|
258
|
+
}
|
|
259
|
+
if (outcome.transaction.productId !== mapping.storeId
|
|
260
|
+
|| outcome.transaction.type !== mapping.type
|
|
261
|
+
|| outcome.transaction.appAccountToken?.toLowerCase() !== expectedAccountToken
|
|
262
|
+
|| !validTransaction(outcome.transaction)) {
|
|
263
|
+
throw Object.assign(new Error('StoreKit purchase evidence is invalid.'), {
|
|
264
|
+
code: 'STOREKIT_EVIDENCE_INVALID',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return toPurchaseResult(outcome.transaction);
|
|
268
|
+
}
|
|
269
|
+
function toPurchaseResult(transaction) {
|
|
270
|
+
return {
|
|
271
|
+
status: 'completed',
|
|
272
|
+
transactionId: transaction.transactionId,
|
|
273
|
+
entitlementIds: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function validTransaction(value) {
|
|
277
|
+
return transactionPattern.test(value.transactionId)
|
|
278
|
+
&& /^[0-9]{1,20}$/u.test(value.originalTransactionId)
|
|
279
|
+
&& productPattern.test(value.productId)
|
|
280
|
+
&& (value.type === 'consumable' || value.type === 'non_consumable')
|
|
281
|
+
&& Number.isFinite(Date.parse(value.purchasedAt))
|
|
282
|
+
&& typeof value.signedTransaction === 'string'
|
|
283
|
+
&& value.signedTransaction.length > 0
|
|
284
|
+
&& value.signedTransaction.length <= 128 * 1024
|
|
285
|
+
&& value.signedTransaction.split('.').length === 3
|
|
286
|
+
&& (value.revokedAt === undefined || Number.isFinite(Date.parse(value.revokedAt)))
|
|
287
|
+
&& (value.appAccountToken === undefined || uuidPattern.test(value.appAccountToken));
|
|
288
|
+
}
|
|
289
|
+
function isRecord(value) {
|
|
290
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
291
|
+
}
|
|
292
|
+
function success(id, data) {
|
|
293
|
+
return { id, ok: true, data };
|
|
294
|
+
}
|
|
295
|
+
function failure(id, code, retryable = false) {
|
|
296
|
+
return { id, ok: false, error: { code, message: code, retryable } };
|
|
297
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
import Foundation
|
|
3
|
+
import StoreKit
|
|
4
|
+
|
|
5
|
+
@objc(CapacitorStoreKitPlugin)
|
|
6
|
+
public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
7
|
+
public let identifier = "CapacitorStoreKitPlugin"
|
|
8
|
+
public let jsName = "CapacitorStoreKit"
|
|
9
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
10
|
+
CAPPluginMethod(name: "getProducts", returnType: CAPPluginReturnPromise),
|
|
11
|
+
CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
|
|
12
|
+
CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
|
|
14
|
+
CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
private var updatesTask: Task<Void, Never>?
|
|
18
|
+
|
|
19
|
+
@objc override public func load() {
|
|
20
|
+
super.load()
|
|
21
|
+
updatesTask = Task { @MainActor [weak self] in
|
|
22
|
+
for await result in Transaction.updates {
|
|
23
|
+
guard !Task.isCancelled else { return }
|
|
24
|
+
if let payload = Self.verifiedPayload(result) {
|
|
25
|
+
self?.notifyListeners("transactionUpdated", data: payload)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
deinit {
|
|
32
|
+
updatesTask?.cancel()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@objc func getProducts(_ call: CAPPluginCall) {
|
|
36
|
+
guard let identifiers = call.options["productIds"] as? [String],
|
|
37
|
+
!identifiers.isEmpty, identifiers.count <= 100,
|
|
38
|
+
identifiers.allSatisfy({ Self.validProductId($0) }) else {
|
|
39
|
+
call.reject("Provide 1-100 valid product IDs.", "STOREKIT_INVALID_PRODUCTS")
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
Task { @MainActor in
|
|
43
|
+
do {
|
|
44
|
+
let products = try await Product.products(for: identifiers)
|
|
45
|
+
let values: [[String: Any]] = products.compactMap { product in
|
|
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
|
+
}
|
|
56
|
+
call.resolve(["products": values])
|
|
57
|
+
} catch {
|
|
58
|
+
call.reject("StoreKit product lookup failed.", "STOREKIT_UNAVAILABLE", error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@objc func purchase(_ call: CAPPluginCall) {
|
|
64
|
+
guard let productId = call.getString("productId"), Self.validProductId(productId),
|
|
65
|
+
let rawAccountToken = call.getString("appAccountToken"),
|
|
66
|
+
let accountToken = UUID(uuidString: rawAccountToken) else {
|
|
67
|
+
call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
Task { @MainActor in
|
|
71
|
+
let product: Product
|
|
72
|
+
do {
|
|
73
|
+
guard let found = try await Product.products(for: [productId]).first,
|
|
74
|
+
found.id == productId,
|
|
75
|
+
Self.oneTimeType(found.type) != nil else {
|
|
76
|
+
call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
product = found
|
|
80
|
+
} catch {
|
|
81
|
+
call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
do {
|
|
85
|
+
let result = try await product.purchase(options: [.appAccountToken(accountToken)])
|
|
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
|
+
}
|
|
96
|
+
// Never finish here: the game backend must commit its ledger grant first.
|
|
97
|
+
call.resolve(["status": "purchased", "transaction": payload])
|
|
98
|
+
case .pending:
|
|
99
|
+
call.resolve(["status": "pending"])
|
|
100
|
+
case .userCancelled:
|
|
101
|
+
call.resolve(["status": "cancelled"])
|
|
102
|
+
@unknown default:
|
|
103
|
+
call.reject("StoreKit returned an unknown purchase result.", "STOREKIT_UNKNOWN_RESULT")
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// The purchase sheet may have completed before an error reached us.
|
|
107
|
+
call.reject("StoreKit purchase result is uncertain; requery transactions.",
|
|
108
|
+
"STOREKIT_PURCHASE_UNCERTAIN", error)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@objc func getTransactions(_ call: CAPPluginCall) {
|
|
114
|
+
Task {
|
|
115
|
+
var byId: [String: [String: Any]] = [:]
|
|
116
|
+
for await result in Transaction.unfinished {
|
|
117
|
+
if let payload = Self.verifiedPayload(result),
|
|
118
|
+
let id = payload["transactionId"] as? String {
|
|
119
|
+
byId[id] = payload
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for await result in Transaction.currentEntitlements {
|
|
123
|
+
if let payload = Self.verifiedPayload(result),
|
|
124
|
+
payload["type"] as? String == "non_consumable",
|
|
125
|
+
let id = payload["transactionId"] as? String {
|
|
126
|
+
byId[id] = payload
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
call.resolve(["transactions": Array(byId.values)])
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@objc func sync(_ call: CAPPluginCall) {
|
|
134
|
+
Task { @MainActor in
|
|
135
|
+
do {
|
|
136
|
+
try await AppStore.sync()
|
|
137
|
+
call.resolve(["synced": true])
|
|
138
|
+
} catch {
|
|
139
|
+
call.reject("StoreKit account synchronization failed.", "STOREKIT_SYNC_FAILED", error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@objc func finishTransaction(_ call: CAPPluginCall) {
|
|
145
|
+
guard let rawId = call.getString("transactionId"), let id = UInt64(rawId),
|
|
146
|
+
let ledgerEntryId = call.getString("ledgerEntryId"),
|
|
147
|
+
!ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
148
|
+
call.reject("A transaction ID and confirmed ledger entry are required.",
|
|
149
|
+
"STOREKIT_FINISH_REQUIRES_GRANT")
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
Task {
|
|
153
|
+
for await result in Transaction.all {
|
|
154
|
+
guard case .verified(let transaction) = result,
|
|
155
|
+
transaction.id == id,
|
|
156
|
+
transaction.revocationDate == nil,
|
|
157
|
+
Self.oneTimeType(transaction.productType) != nil else { continue }
|
|
158
|
+
await transaction.finish()
|
|
159
|
+
call.resolve(["finished": true])
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
call.resolve(["finished": false])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private static func verifiedPayload(
|
|
167
|
+
_ result: VerificationResult<Transaction>
|
|
168
|
+
) -> [String: Any]? {
|
|
169
|
+
guard case .verified(let transaction) = result,
|
|
170
|
+
let type = oneTimeType(transaction.productType) else { return nil }
|
|
171
|
+
var payload: [String: Any] = [
|
|
172
|
+
"transactionId": String(transaction.id),
|
|
173
|
+
"originalTransactionId": String(transaction.originalID),
|
|
174
|
+
"productId": transaction.productID,
|
|
175
|
+
"type": type,
|
|
176
|
+
"purchasedAt": ISO8601DateFormatter().string(from: transaction.purchaseDate),
|
|
177
|
+
"signedTransaction": result.jwsRepresentation
|
|
178
|
+
]
|
|
179
|
+
if let accountToken = transaction.appAccountToken {
|
|
180
|
+
payload["appAccountToken"] = accountToken.uuidString.lowercased()
|
|
181
|
+
}
|
|
182
|
+
if let revocationDate = transaction.revocationDate {
|
|
183
|
+
payload["revokedAt"] = ISO8601DateFormatter().string(from: revocationDate)
|
|
184
|
+
}
|
|
185
|
+
return payload
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static func oneTimeType(_ type: Product.ProductType) -> String? {
|
|
189
|
+
switch type {
|
|
190
|
+
case .consumable: return "consumable"
|
|
191
|
+
case .nonConsumable: return "non_consumable"
|
|
192
|
+
default: return nil
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private static func validProductId(_ value: String) -> Bool {
|
|
197
|
+
value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
|
|
198
|
+
}
|
|
199
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mpgd/capacitor-storekit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Opt-in StoreKit 2 one-time purchase collector for mpgd Capacitor games.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/imjlk/mpgd-kit.git",
|
|
9
|
+
"directory": "native-plugins/capacitor-storekit"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"capacitor": {
|
|
20
|
+
"ios": {
|
|
21
|
+
"src": "ios"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": ["dist", "ios", "Package.swift"],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"check": "ttsc --noEmit",
|
|
27
|
+
"lint": "ttsc --noEmit",
|
|
28
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@capacitor/core": "^8.5.1",
|
|
32
|
+
"@mpgd/bridge": "^0.10.0",
|
|
33
|
+
"@mpgd/platform": "^0.13.0 || ^0.14.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@capacitor/core": "8.5.2",
|
|
37
|
+
"@mpgd/adapter-capacitor": "workspace:*",
|
|
38
|
+
"@mpgd/bridge": "workspace:*",
|
|
39
|
+
"@mpgd/game-services": "workspace:*",
|
|
40
|
+
"@mpgd/platform": "workspace:*",
|
|
41
|
+
"ttsc": "0.30.4",
|
|
42
|
+
"typescript": "7.0.2"
|
|
43
|
+
},
|
|
44
|
+
"main": "./dist/index.js",
|
|
45
|
+
"types": "./dist/index.d.ts",
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|