Skip to content

Commit fa38a27

Browse files
committed
fix(capacitor-ios): stop losing unfinished transactions when JS init is slow (gh #1714)
The Transaction.updates observer started at plugin load, before any JS listener existed. Unfinished transactions delivered early were marked processed yet their events were dropped by the Capacitor bridge, leaving them in limbo for the whole session. - Defer startTransactionObserver() to the end of init(), guarded against duplicate observers. - Add a Transaction.unfinished pass to init() so consumables (absent from currentEntitlements) are re-emitted once JS is listening. - Consolidate processedTransactionIds dedupe in markEmittedToJs(), an atomic MainActor check-and-insert used by the observer, both init passes, purchase(), and restore() — closing the data race between the detached observer task and the plugin-method tasks.
1 parent ea1ed1b commit fa38a27

1 file changed

Lines changed: 54 additions & 25 deletions

File tree

capacitor/ios/Sources/PurchasePlugin/PurchasePlugin.swift

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,21 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
4646

4747
// MARK: - Lifecycle
4848

49-
override public func load() {
50-
if #available(iOS 15.0, *) {
51-
startTransactionObserver()
52-
}
53-
}
54-
5549
deinit {
5650
if #available(iOS 15.0, *) {
5751
sk2.transactionObserverTask?.cancel()
5852
}
5953
}
6054

55+
/// Start listening to Transaction.updates. Called at the end of init(),
56+
/// NOT at plugin load: Transaction.updates delivers unfinished
57+
/// transactions as soon as iteration begins, and before JS called init()
58+
/// no listener is attached — the events would be lost while their ids
59+
/// still land in processedTransactionIds, hiding unfinished purchases
60+
/// for the rest of the session.
6161
@available(iOS 15.0, *)
6262
private func startTransactionObserver() {
63+
guard sk2.transactionObserverTask == nil else { return }
6364
sk2.transactionObserverTask = Task.detached { [weak self] in
6465
for await result in Transaction.updates {
6566
guard let self = self else { return }
@@ -73,18 +74,10 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
7374
let jwsRepresentation = result.jwsRepresentation
7475
switch result {
7576
case .verified(let transaction):
76-
// Serialize access to processedTransactionIds on the main thread
77-
// since this method runs from a detached Task while init()/purchase()
78-
// also mutate the set.
79-
let shouldEmit: Bool = await MainActor.run {
80-
if sk2.processedTransactionIds.contains(transaction.id) {
81-
debugLog("Transaction.updates: skipping duplicate id=\(transaction.id)")
82-
return false
83-
}
84-
sk2.processedTransactionIds.insert(transaction.id)
85-
return true
77+
guard await markEmittedToJs(transaction.id) else {
78+
debugLog("Transaction.updates: skipping duplicate id=\(transaction.id)")
79+
return
8680
}
87-
guard shouldEmit else { return }
8881
await emitTransactionUpdate(transaction, state: "PaymentTransactionStatePurchased",
8982
jwsRepresentation: jwsRepresentation)
9083
case .unverified(let transaction, _):
@@ -104,8 +97,9 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
10497
debugEnabled = call.getBool("debug", false)
10598
debugLog("init")
10699

107-
// Load current entitlements and emit to JS so existing subscriptions
108-
// are visible immediately on app launch (without requiring a manual restore).
100+
// Load current entitlements and unfinished transactions, and emit them
101+
// to JS so existing purchases are visible immediately on app launch
102+
// (without requiring a manual restore).
109103
Task {
110104
for await result in Transaction.currentEntitlements {
111105
switch result {
@@ -114,19 +108,37 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
114108
debugLog("init: skipping upgraded entitlement id=\(transaction.id) product=\(transaction.productID)")
115109
continue
116110
}
117-
if self.sk2.processedTransactionIds.contains(transaction.id) {
118-
debugLog("init: skipping already-processed entitlement id=\(transaction.id)")
111+
guard await self.markEmittedToJs(transaction.id) else {
112+
debugLog("init: skipping already-emitted entitlement id=\(transaction.id)")
119113
continue
120114
}
121-
self.sk2.processedTransactionIds.insert(transaction.id)
122-
self.sk2.unfinishedTransactions[String(transaction.id)] = transaction
123115
await self.emitTransactionUpdate(transaction,
124116
state: "PaymentTransactionStateRestored",
125117
jwsRepresentation: result.jwsRepresentation)
126118
case .unverified(let transaction, let error):
127119
debugLog("init: unverified entitlement id=\(transaction.id) product=\(transaction.productID) error=\(error)")
128120
}
129121
}
122+
// Emit every transaction the app hasn't finished yet. This is the
123+
// only reliable source for unfinished consumables: they never
124+
// appear in currentEntitlements, so without this pass an
125+
// unfinished consumable would hang in limbo for the session.
126+
for await result in Transaction.unfinished {
127+
switch result {
128+
case .verified(let transaction):
129+
guard await self.markEmittedToJs(transaction.id) else { continue }
130+
debugLog("init: emitting unfinished transaction id=\(transaction.id) product=\(transaction.productID)")
131+
await self.emitTransactionUpdate(transaction,
132+
state: "PaymentTransactionStateRestored",
133+
jwsRepresentation: result.jwsRepresentation)
134+
case .unverified(let transaction, let error):
135+
debugLog("init: unverified unfinished transaction id=\(transaction.id) product=\(transaction.productID) error=\(error)")
136+
}
137+
}
138+
// Only now start observing Transaction.updates — see the comment
139+
// on startTransactionObserver() for why starting earlier loses
140+
// unfinished transactions.
141+
self.startTransactionObserver()
130142
call.resolve()
131143
}
132144
}
@@ -206,7 +218,7 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
206218
let jwsRepresentation = verification.jwsRepresentation
207219
switch verification {
208220
case .verified(let transaction):
209-
sk2.processedTransactionIds.insert(transaction.id)
221+
_ = await markEmittedToJs(transaction.id)
210222
await emitTransactionUpdate(transaction,
211223
state: "PaymentTransactionStatePurchased",
212224
jwsRepresentation: jwsRepresentation)
@@ -296,7 +308,9 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
296308
debugLog("restore: skipping upgraded entitlement id=\(transaction.id) product=\(transaction.productID)")
297309
continue
298310
}
299-
sk2.processedTransactionIds.insert(transaction.id)
311+
// Restore deliberately re-emits transactions already
312+
// delivered this session — mark, but ignore the result.
313+
_ = await markEmittedToJs(transaction.id)
300314
await emitTransactionUpdate(transaction,
301315
state: "PaymentTransactionStateRestored",
302316
jwsRepresentation: result.jwsRepresentation)
@@ -414,6 +428,21 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
414428
}
415429
}
416430

431+
/// Atomically check-and-mark a transaction id as emitted to the JS layer.
432+
/// Returns false when it was already emitted this session. "Emitted" is
433+
/// unrelated to Apple's finished state — it only prevents duplicate
434+
/// delivery of the same transaction within one app session. Serialized on
435+
/// the main thread because the Transaction.updates observer runs in a
436+
/// detached Task while init()/restore()/purchase() run in their own Tasks.
437+
@available(iOS 15.0, *)
438+
private func markEmittedToJs(_ id: UInt64) async -> Bool {
439+
return await MainActor.run {
440+
if sk2.processedTransactionIds.contains(id) { return false }
441+
sk2.processedTransactionIds.insert(id)
442+
return true
443+
}
444+
}
445+
417446
@available(iOS 15.0, *)
418447
private func emitTransactionUpdate(_ transaction: Transaction, state: String,
419448
errorCode: Int? = nil, errorText: String? = nil,

0 commit comments

Comments
 (0)