Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Docs/Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,33 @@ By default, the package injects its native wrapper through `buildTransitive` on

The managed API surface is intentionally small and designed for app-level orchestration.

## Transaction acknowledgement and offer codes

`PurchaseAsync`, `RestoreAsync`, `GetCurrentEntitlementsAsync`, and
`GetUnfinishedTransactionsAsync` expose the verified transaction id and signed
transaction JWS. `Transaction.updates` exposes the same proof through
`StoreKitTransactionUpdate`.

The wrapper does not finish successful purchases or transaction updates
automatically. After the consuming app has verified the JWS and durably
acknowledged the entitlement, call:

```csharp
await client.FinishTransactionAsync(transactionId, cancellationToken);
```

This makes temporary backend or network failures recoverable through
`GetUnfinishedTransactionsAsync`.

On iOS 16 or later, present Apple’s native code-entry sheet with:

```csharp
var result = await client.PresentOfferCodeRedeemSheetAsync(cancellationToken);
```

`Succeeded` means the sheet was presented. Dismissal is not proof of a redeemed
code; observe and verify the resulting transaction instead. Apps supporting
iOS 15 must keep any StoreKit 1 presentation adapter in the app itself; this
StoreKit 2 package does not reference StoreKit 1.

For source-mode usage, see `Docs/SourceMode.md`.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Public OSS repository that packages a StoreKit 2 iOS interop wrapper into a cons

A NuGet package that:
- provides a minimal managed API for StoreKit 2 purchase and restore flows;
- presents Apple’s offer-code redemption sheet on iOS 16+;
- exposes current entitlements, unfinished verified transactions, transaction ids, and signed JWS payloads;
- lets the consuming app finish a transaction explicitly after its own backend acknowledgement;
- exposes store-formatted subscription prices plus an optional annual-to-monthly equivalent formatted with StoreKit's native price style;
- redistributes the native wrapper `xcframework` inside the `.nupkg`;
- injects the wrapper into consuming apps through `buildTransitive` `NativeReference`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import StoreKit
import UIKit

public typealias KapuschStoreKit2PurchaseCallback = @convention(c) (
Int32,
Expand All @@ -8,6 +9,7 @@ public typealias KapuschStoreKit2PurchaseCallback = @convention(c) (
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafeMutableRawPointer
) -> Void

Expand All @@ -27,11 +29,19 @@ public typealias KapuschStoreKit2OfferMetadataCallback = @convention(c) (
) -> Void

public typealias KapuschStoreKit2TransactionUpdateCallback = @convention(c) (
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?
) -> Void

public typealias KapuschStoreKit2OperationCallback = @convention(c) (
Int32,
UnsafePointer<CChar>?,
UnsafePointer<CChar>?,
UnsafeMutableRawPointer
) -> Void

private enum NativeStatus: Int32 {
case success = 0
case cancelled = 1
Expand All @@ -42,6 +52,7 @@ private enum NativeStatus: Int32 {
private struct RestoreTransactionPayload: Codable {
let productId: String
let originalTransactionId: String
let transactionId: String
let signedTransactionInfo: String
}

Expand Down Expand Up @@ -84,6 +95,16 @@ private final class OfferMetadataCallbackContext: @unchecked Sendable {
}
}

private final class OperationCallbackContext: @unchecked Sendable {
let callback: KapuschStoreKit2OperationCallback
let context: UnsafeMutableRawPointer

init(callback: @escaping KapuschStoreKit2OperationCallback, context: UnsafeMutableRawPointer) {
self.callback = callback
self.context = context
}
}

@MainActor
private var transactionUpdatesTask: Task<Void, Never>?

Expand All @@ -106,31 +127,53 @@ private func callPurchaseCallback(
status: NativeStatus,
productId: String? = nil,
originalTransactionId: String? = nil,
transactionId: String? = nil,
signedTransactionInfo: String? = nil,
errorCode: String? = nil,
errorMessage: String? = nil
) {
withCString(productId) { productIdC in
withCString(originalTransactionId) { originalTransactionIdC in
withCString(signedTransactionInfo) { signedTransactionInfoC in
withCString(errorCode) { errorCodeC in
withCString(errorMessage) { errorMessageC in
callbackContext.callback(
status.rawValue,
productIdC,
originalTransactionIdC,
signedTransactionInfoC,
errorCodeC,
errorMessageC,
callbackContext.context
)
withCString(transactionId) { transactionIdC in
withCString(signedTransactionInfo) { signedTransactionInfoC in
withCString(errorCode) { errorCodeC in
withCString(errorMessage) { errorMessageC in
callbackContext.callback(
status.rawValue,
productIdC,
originalTransactionIdC,
transactionIdC,
signedTransactionInfoC,
errorCodeC,
errorMessageC,
callbackContext.context
)
}
}
}
}
}
}
}

private func callOperationCallback(
_ callbackContext: OperationCallbackContext,
succeeded: Bool,
errorCode: String? = nil,
errorMessage: String? = nil
) {
withCString(errorCode) { errorCodeC in
withCString(errorMessage) { errorMessageC in
callbackContext.callback(
succeeded ? NativeStatus.success.rawValue : NativeStatus.failed.rawValue,
errorCodeC,
errorMessageC,
callbackContext.context
)
}
}
}

private func callRestoreCallback(
_ callbackContext: RestoreCallbackContext,
status: NativeStatus,
Expand Down Expand Up @@ -177,7 +220,8 @@ private func callTransactionUpdateCallback(
_ callback: KapuschStoreKit2TransactionUpdateCallback?,
productId: String?,
originalTransactionId: String?,
transactionId: String?
transactionId: String?,
signedTransactionInfo: String?
) {
guard let callback else {
return
Expand All @@ -186,7 +230,9 @@ private func callTransactionUpdateCallback(
withCString(productId) { productIdC in
withCString(originalTransactionId) { originalTransactionIdC in
withCString(transactionId) { transactionIdC in
callback(productIdC, originalTransactionIdC, transactionIdC)
withCString(signedTransactionInfo) { signedTransactionInfoC in
callback(productIdC, originalTransactionIdC, transactionIdC, signedTransactionInfoC)
}
}
}
}
Expand Down Expand Up @@ -275,6 +321,7 @@ private func collectCurrentEntitlements(
RestoreTransactionPayload(
productId: transaction.productID,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id),
signedTransactionInfo: verificationResult.jwsRepresentation
)
)
Expand Down Expand Up @@ -347,13 +394,12 @@ private func executePurchase(
let transactionJws = verificationResult.jwsRepresentation
let originalId = String(transaction.originalID)

await transaction.finish()

callPurchaseCallback(
callbackContext,
status: .success,
productId: transaction.productID,
originalTransactionId: originalId,
transactionId: String(transaction.id),
signedTransactionInfo: transactionJws
)

Expand All @@ -364,6 +410,7 @@ private func executePurchase(
status: .failed,
productId: transaction.productID,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id),
signedTransactionInfo: transactionJws,
errorCode: "unverified_transaction",
errorMessage: error.localizedDescription
Expand Down Expand Up @@ -409,12 +456,11 @@ public func kstorekit2_transaction_updates_start(
transactionUpdatesCallback,
productId: transaction.productID,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id)
transactionId: String(transaction.id),
signedTransactionInfo: verificationResult.jwsRepresentation
)
}

await transaction.finish()

case .unverified:
continue
}
Expand All @@ -423,6 +469,87 @@ public func kstorekit2_transaction_updates_start(
}
}

@_cdecl("kstorekit2_offer_code_redeem_start")
public func kstorekit2_offer_code_redeem_start(
_ callback: @escaping KapuschStoreKit2OperationCallback,
_ context: UnsafeMutableRawPointer
) {
let callbackContext = OperationCallbackContext(callback: callback, context: context)

Task { @MainActor in
guard #available(iOS 16.0, *) else {
callOperationCallback(
callbackContext,
succeeded: false,
errorCode: "offer_code_sheet_requires_ios_16",
errorMessage: "The StoreKit 2 offer code sheet requires iOS 16 or later."
)
return
}

guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else {
callOperationCallback(
callbackContext,
succeeded: false,
errorCode: "active_window_scene_unavailable",
errorMessage: "No active window scene is available to present the offer code sheet."
)
return
}

do {
try await AppStore.presentOfferCodeRedeemSheet(in: windowScene)
callOperationCallback(callbackContext, succeeded: true)
} catch {
let nsError = error as NSError
callOperationCallback(
callbackContext,
succeeded: false,
errorCode: "\(nsError.domain):\(nsError.code)",
errorMessage: nsError.localizedDescription
)
}
}
}

@_cdecl("kstorekit2_finish_transaction_start")
public func kstorekit2_finish_transaction_start(
_ transactionIdPtr: UnsafePointer<CChar>?,
_ callback: @escaping KapuschStoreKit2OperationCallback,
_ context: UnsafeMutableRawPointer
) {
let callbackContext = OperationCallbackContext(callback: callback, context: context)
guard let transactionId = sanitizeRequiredCstring(transactionIdPtr) else {
callOperationCallback(
callbackContext,
succeeded: false,
errorCode: "invalid_transaction_id",
errorMessage: "The transaction id is empty."
)
return
}

Task {
for await verificationResult in Transaction.unfinished {
guard case .verified(let transaction) = verificationResult else {
continue
}
guard String(transaction.id) == transactionId else {
continue
}

await transaction.finish()
callOperationCallback(callbackContext, succeeded: true)
return
}

// Finishing is idempotent. A missing unfinished transaction is already acknowledged.
callOperationCallback(callbackContext, succeeded: true)
}
}

@_cdecl("kstorekit2_purchase_start")
public func kstorekit2_purchase_start(
_ productIdPtr: UnsafePointer<CChar>?,
Expand Down Expand Up @@ -625,6 +752,55 @@ public func kstorekit2_current_entitlements_start(
}
}

@_cdecl("kstorekit2_unfinished_transactions_start")
public func kstorekit2_unfinished_transactions_start(
_ productIdsJsonPtr: UnsafePointer<CChar>?,
_ callback: @escaping KapuschStoreKit2RestoreCallback,
_ context: UnsafeMutableRawPointer
) {
let callbackContext = RestoreCallbackContext(callback: callback, context: context)
let filterProductIds = parseProductIdsJson(productIdsJsonPtr)

Task {
var transactions: [RestoreTransactionPayload] = []
for await verificationResult in Transaction.unfinished {
guard case .verified(let transaction) = verificationResult else {
continue
}
if !filterProductIds.isEmpty && !filterProductIds.contains(transaction.productID) {
continue
}

transactions.append(
RestoreTransactionPayload(
productId: transaction.productID,
originalTransactionId: String(transaction.originalID),
transactionId: String(transaction.id),
signedTransactionInfo: verificationResult.jwsRepresentation
)
)
}

do {
let payloadData = try JSONEncoder().encode(transactions)
let payloadJson = String(data: payloadData, encoding: .utf8)
callRestoreCallback(
callbackContext,
status: .success,
payloadJson: payloadJson
)
} catch {
let nsError = error as NSError
callRestoreCallback(
callbackContext,
status: .failed,
errorCode: "\(nsError.domain):\(nsError.code)",
errorMessage: nsError.localizedDescription
)
}
}
}

@_cdecl("kstorekit2_offer_metadata_start")
public func kstorekit2_offer_metadata_start(
_ productIdsJsonPtr: UnsafePointer<CChar>?,
Expand Down
Loading
Loading