feat: add IAP in native bridge plugin for iOS (#1673)

This commit is contained in:
Huang Xin 2025-07-24 20:54:17 +08:00 committed by GitHub
parent beaf034035
commit f3e9983742
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 718 additions and 2 deletions

View file

@ -9,6 +9,10 @@ const COMMANDS: &[&str] = &[
"get_sys_fonts_list",
"intercept_keys",
"lock_screen_orientation",
"iap_initialize",
"iap_fetch_products",
"iap_purchase_product",
"iap_restore_purchases",
];
fn main() {

View file

@ -3,6 +3,7 @@ import AuthenticationServices
import CoreText
import MediaPlayer
import ObjectiveC
import StoreKit
import SwiftRs
import Tauri
import UIKit
@ -48,6 +49,37 @@ class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
struct FetchProductsRequest: Decodable {
let productIds: [String]
}
struct PurchaseProductRequest: Decodable {
let productId: String
}
struct ProductData: Codable {
let id: String
let title: String
let description: String
let price: String
let priceCurrencyCode: String?
let priceAmountMicros: Int64
let productType: String
}
struct PurchaseData: Codable {
let productId: String
let transactionId: String
let originalTransactionId: String
let purchaseDate: String
let purchaseState: String
let platform: String
}
class VolumeKeyHandler: NSObject {
private var audioSession: AVAudioSession?
private var originalVolume: Float = 0.0
@ -422,6 +454,85 @@ class NativeBridgePlugin: Plugin {
}
}
}
@objc public func iap_initialize(_ invoke: Invoke) {
StoreKitManager.shared.initialize()
invoke.resolve(["success": true])
}
@objc public func iap_fetch_products(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(FetchProductsRequest.self)
StoreKitManager.shared.fetchProducts(productIds: args.productIds) { products in
let productsData: [ProductData] = products.map { product in
return ProductData(
id: product.productIdentifier,
title: product.localizedTitle,
description: product.localizedDescription,
price: product.price.stringValue,
priceCurrencyCode: product.priceLocale.currencyCode,
priceAmountMicros: Int64(product.price.doubleValue * 1_000_000),
productType: product.productIdentifier.contains("monthly")
|| product.productIdentifier.contains("yearly") ? "subscription" : "consumable"
)
}
invoke.resolve(["products": productsData])
}
} catch {
invoke.reject("Failed to parse fetch products arguments: \(error.localizedDescription)")
}
}
@objc public func iap_purchase_product(_ invoke: Invoke) {
do {
let args = try invoke.parseArgs(PurchaseProductRequest.self)
StoreKitManager.shared.fetchProducts(productIds: [args.productId]) { products in
guard let product = products.first else {
invoke.reject("Product not found")
return
}
StoreKitManager.shared.purchase(product: product) { result in
switch result {
case .success(let txn):
let purchase = PurchaseData(
productId: txn.payment.productIdentifier,
transactionId: txn.transactionIdentifier ?? "",
originalTransactionId: txn.original?.transactionIdentifier ?? txn
.transactionIdentifier ?? "",
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
purchaseState: "purchased",
platform: "ios"
)
invoke.resolve(["purchase": purchase])
case .failure(let error):
invoke.reject("Purchase failed: \(error.localizedDescription)")
}
}
}
} catch {
invoke.reject("Failed to parse purchase arguments: \(error.localizedDescription)")
}
}
@objc public func iap_restore_purchases(_ invoke: Invoke) {
StoreKitManager.shared.restorePurchases { transactions in
let restored = transactions.map { txn -> PurchaseData in
return PurchaseData(
productId: txn.payment.productIdentifier,
transactionId: txn.transactionIdentifier ?? "",
originalTransactionId: txn.original?.transactionIdentifier ?? txn.transactionIdentifier
?? "",
purchaseDate: ISO8601DateFormatter().string(from: txn.transactionDate ?? Date()),
purchaseState: "restored",
platform: "ios"
)
}
invoke.resolve(["purchases": restored])
}
}
}
@_cdecl("init_plugin_native_bridge")

View file

@ -0,0 +1,121 @@
import StoreKit
import os
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StoreKitManager")
class StoreKitManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {
static let shared = StoreKitManager()
private var productsRequest: SKProductsRequest?
private var productResponseHandler: (([SKProduct]) -> Void)?
private var purchaseHandler: ((Result<SKPaymentTransaction, Error>) -> Void)?
private var restoreHandler: (([SKPaymentTransaction]) -> Void)?
private override init() {
super.init()
}
func initialize() {
SKPaymentQueue.default().add(self)
}
deinit {
SKPaymentQueue.default().remove(self)
}
func fetchProducts(productIds: [String], completion: @escaping ([SKProduct]) -> Void) {
let ids = Set(productIds)
productsRequest = SKProductsRequest(productIdentifiers: ids)
productsRequest?.delegate = self
productResponseHandler = completion
productsRequest?.start()
}
func purchase(
product: SKProduct, completion: @escaping (Result<SKPaymentTransaction, Error>) -> Void
) {
guard SKPaymentQueue.canMakePayments() else {
completion(
.failure(
NSError(
domain: "iap", code: 0, userInfo: [NSLocalizedDescriptionKey: "Purchases disabled."])))
return
}
purchaseHandler = completion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
func restorePurchases(completion: @escaping ([SKPaymentTransaction]) -> Void) {
restoreHandler = completion
SKPaymentQueue.default().restoreCompletedTransactions()
}
// MARK: - SKProductsRequestDelegate
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
DispatchQueue.main.async { [weak self] in
self?.productResponseHandler?(response.products)
self?.productResponseHandler = nil
}
}
func request(_ request: SKRequest, didFailWithError error: Error) {
DispatchQueue.main.async { [weak self] in
logger.error("Products request failed: \(error.localizedDescription)")
self?.productResponseHandler?([])
self?.productResponseHandler = nil
}
}
// MARK: - SKPaymentTransactionObserver
func paymentQueue(
_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]
) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
SKPaymentQueue.default().finishTransaction(transaction)
DispatchQueue.main.async { [weak self] in
self?.purchaseHandler?(.success(transaction))
self?.purchaseHandler = nil
}
case .failed:
SKPaymentQueue.default().finishTransaction(transaction)
let error =
transaction.error
?? NSError(
domain: "iap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown purchase error"]
)
DispatchQueue.main.async { [weak self] in
self?.purchaseHandler?(.failure(error))
self?.purchaseHandler = nil
}
case .restored:
SKPaymentQueue.default().finishTransaction(transaction)
case .deferred, .purchasing:
// Handle these states if needed
break
@unknown default:
break
}
}
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
let restored = queue.transactions.filter { $0.transactionState == .restored }
DispatchQueue.main.async { [weak self] in
self?.restoreHandler?(restored)
self?.restoreHandler = nil
}
}
func paymentQueue(
_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error
) {
DispatchQueue.main.async { [weak self] in
logger.error("Restore purchases failed: \(error.localizedDescription)")
self?.restoreHandler?([])
self?.restoreHandler = nil
}
}
}

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-fetch-products"
description = "Enables the iap_fetch_products command without any pre-configured scope."
commands.allow = ["iap_fetch_products"]
[[permission]]
identifier = "deny-iap-fetch-products"
description = "Denies the iap_fetch_products command without any pre-configured scope."
commands.deny = ["iap_fetch_products"]

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-initialize"
description = "Enables the iap_initialize command without any pre-configured scope."
commands.allow = ["iap_initialize"]
[[permission]]
identifier = "deny-iap-initialize"
description = "Denies the iap_initialize command without any pre-configured scope."
commands.deny = ["iap_initialize"]

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-purchase-product"
description = "Enables the iap_purchase_product command without any pre-configured scope."
commands.allow = ["iap_purchase_product"]
[[permission]]
identifier = "deny-iap-purchase-product"
description = "Denies the iap_purchase_product command without any pre-configured scope."
commands.deny = ["iap_purchase_product"]

View file

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-iap-restore-purchases"
description = "Enables the iap_restore_purchases command without any pre-configured scope."
commands.allow = ["iap_restore_purchases"]
[[permission]]
identifier = "deny-iap-restore-purchases"
description = "Denies the iap_restore_purchases command without any pre-configured scope."
commands.deny = ["iap_restore_purchases"]

View file

@ -14,6 +14,10 @@ Default permissions for the plugin
- `allow-get-sys-fonts-list`
- `allow-intercept-keys`
- `allow-lock-screen-orientation`
- `allow-iap-initialize`
- `allow-iap-fetch-products`
- `allow-iap-purchase-product`
- `allow-iap-restore-purchases`
## Permission Table
@ -157,6 +161,110 @@ Denies the get_sys_fonts_list command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-iap-fetch-products`
</td>
<td>
Enables the iap_fetch_products command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-fetch-products`
</td>
<td>
Denies the iap_fetch_products command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-initialize`
</td>
<td>
Enables the iap_initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-initialize`
</td>
<td>
Denies the iap_initialize command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-purchase-product`
</td>
<td>
Enables the iap_purchase_product command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-purchase-product`
</td>
<td>
Denies the iap_purchase_product command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-iap-restore-purchases`
</td>
<td>
Enables the iap_restore_purchases command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-iap-restore-purchases`
</td>
<td>
Denies the iap_restore_purchases command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-install-package`
</td>

View file

@ -11,4 +11,8 @@ permissions = [
"allow-get-sys-fonts-list",
"allow-intercept-keys",
"allow-lock-screen-orientation",
"allow-iap-initialize",
"allow-iap-fetch-products",
"allow-iap-purchase-product",
"allow-iap-restore-purchases",
]

View file

@ -354,6 +354,54 @@
"const": "deny-get-sys-fonts-list",
"markdownDescription": "Denies the get_sys_fonts_list command without any pre-configured scope."
},
{
"description": "Enables the iap_fetch_products command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-fetch-products",
"markdownDescription": "Enables the iap_fetch_products command without any pre-configured scope."
},
{
"description": "Denies the iap_fetch_products command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-fetch-products",
"markdownDescription": "Denies the iap_fetch_products command without any pre-configured scope."
},
{
"description": "Enables the iap_initialize command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-initialize",
"markdownDescription": "Enables the iap_initialize command without any pre-configured scope."
},
{
"description": "Denies the iap_initialize command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-initialize",
"markdownDescription": "Denies the iap_initialize command without any pre-configured scope."
},
{
"description": "Enables the iap_purchase_product command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-purchase-product",
"markdownDescription": "Enables the iap_purchase_product command without any pre-configured scope."
},
{
"description": "Denies the iap_purchase_product command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-purchase-product",
"markdownDescription": "Denies the iap_purchase_product command without any pre-configured scope."
},
{
"description": "Enables the iap_restore_purchases command without any pre-configured scope.",
"type": "string",
"const": "allow-iap-restore-purchases",
"markdownDescription": "Enables the iap_restore_purchases command without any pre-configured scope."
},
{
"description": "Denies the iap_restore_purchases command without any pre-configured scope.",
"type": "string",
"const": "deny-iap-restore-purchases",
"markdownDescription": "Denies the iap_restore_purchases command without any pre-configured scope."
},
{
"description": "Enables the install_package command without any pre-configured scope.",
"type": "string",
@ -415,10 +463,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`"
}
]
}

View file

@ -81,3 +81,34 @@ pub(crate) async fn lock_screen_orientation<R: Runtime>(
) -> Result<()> {
app.native_bridge().lock_screen_orientation(payload)
}
#[command]
pub(crate) async fn iap_initialize<R: Runtime>(
app: AppHandle<R>,
payload: IAPInitializeRequest,
) -> Result<IAPInitializeResponse> {
app.native_bridge().iap_initialize(payload)
}
#[command]
pub(crate) async fn iap_fetch_products<R: Runtime>(
app: AppHandle<R>,
payload: IAPFetchProductsRequest,
) -> Result<IAPFetchProductsResponse> {
app.native_bridge().iap_fetch_products(payload)
}
#[command]
pub(crate) async fn iap_purchase_product<R: Runtime>(
app: AppHandle<R>,
payload: IAPPurchaseProductRequest,
) -> Result<IAPPurchaseProductResponse> {
app.native_bridge().iap_purchase_product(payload)
}
#[command]
pub(crate) async fn iap_restore_purchases<R: Runtime>(
app: AppHandle<R>,
) -> Result<IAPRestorePurchasesResponse> {
app.native_bridge().iap_restore_purchases()
}

View file

@ -73,4 +73,29 @@ impl<R: Runtime> NativeBridge<R> {
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_initialize(
&self,
_payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_fetch_products(
&self,
_payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_purchase_product(
&self,
_payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn iap_restore_purchases(&self) -> crate::Result<IAPRestorePurchasesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}

View file

@ -47,6 +47,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::get_sys_fonts_list,
commands::intercept_keys,
commands::lock_screen_orientation,
commands::iap_initialize,
commands::iap_fetch_products,
commands::iap_purchase_product,
commands::iap_restore_purchases,
])
.setup(|app, api| {
#[cfg(mobile)]

View file

@ -112,3 +112,46 @@ impl<R: Runtime> NativeBridge<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_initialize(
&self,
payload: IAPInitializeRequest,
) -> crate::Result<IAPInitializeResponse> {
self.0
.run_mobile_plugin("iap_initialize", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_fetch_products(
&self,
payload: IAPFetchProductsRequest,
) -> crate::Result<IAPFetchProductsResponse> {
self.0
.run_mobile_plugin("iap_fetch_products", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_purchase_product(
&self,
payload: IAPPurchaseProductRequest,
) -> crate::Result<IAPPurchaseProductResponse> {
self.0
.run_mobile_plugin("iap_purchase_product", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn iap_restore_purchases(
&self,
) -> crate::Result<IAPRestorePurchasesResponse> {
self.0
.run_mobile_plugin("iap_restore_purchases", ())
.map_err(Into::into)
}
}

View file

@ -86,3 +86,69 @@ pub struct InterceptKeysRequest {
pub struct LockScreenOrientationRequest {
pub orientation: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Product {
pub id: String,
pub title: String,
pub description: String,
pub price: String,
pub price_currency_code: Option<String>,
pub price_amount_micros: i64,
pub product_type: String, // "consumable", "non_consumable", or "subscription"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Purchase {
pub product_id: String,
pub transaction_id: String,
pub purchase_date: String,
pub original_transaction_id: String,
pub purchase_state: String, // "purchased", "pending", "cancelled"
pub platform: String, // "ios" or "android"
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeRequest {
pub public_key: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPInitializeResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPFetchProductsRequest {
pub product_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPFetchProductsResponse {
pub products: Vec<Product>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPPurchaseProductRequest {
pub product_id: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPPurchaseProductResponse {
pub purchase: Option<Purchase>,
pub cancelled_purchase: Option<Purchase>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IAPRestorePurchasesResponse {
pub purchases: Vec<Purchase>,
}