From f3e9983742b06d2716ce6e6703ad7f6dcdaf2974 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 24 Jul 2025 20:54:17 +0800 Subject: [PATCH] feat: add IAP in native bridge plugin for iOS (#1673) --- .../tauri-plugin-native-bridge/build.rs | 4 + .../ios/Sources/NativeBridgePlugin.swift | 111 ++++++++++++++++ .../ios/Sources/StoreKitManager.swift | 121 ++++++++++++++++++ .../commands/iap_fetch_products.toml | 13 ++ .../commands/iap_initialize.toml | 13 ++ .../commands/iap_purchase_product.toml | 13 ++ .../commands/iap_restore_purchases.toml | 13 ++ .../permissions/autogenerated/reference.md | 108 ++++++++++++++++ .../permissions/default.toml | 4 + .../permissions/schemas/schema.json | 52 +++++++- .../src/commands.rs | 31 +++++ .../tauri-plugin-native-bridge/src/desktop.rs | 25 ++++ .../tauri-plugin-native-bridge/src/lib.rs | 4 + .../tauri-plugin-native-bridge/src/mobile.rs | 43 +++++++ .../tauri-plugin-native-bridge/src/models.rs | 66 ++++++++++ apps/readest-app/src/utils/iap.ts | 99 ++++++++++++++ 16 files changed, 718 insertions(+), 2 deletions(-) create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/StoreKitManager.swift create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_fetch_products.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_initialize.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_purchase_product.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_restore_purchases.toml create mode 100644 apps/readest-app/src/utils/iap.ts diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs index 590f0be2..a841f369 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs @@ -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() { diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 6a4f0463..e19b817c 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -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") diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/StoreKitManager.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/StoreKitManager.swift new file mode 100644 index 00000000..c2681763 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/StoreKitManager.swift @@ -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) -> 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) -> 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 + } + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_fetch_products.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_fetch_products.toml new file mode 100644 index 00000000..a28ae0d8 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_fetch_products.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_initialize.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_initialize.toml new file mode 100644 index 00000000..80e531c7 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_initialize.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_purchase_product.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_purchase_product.toml new file mode 100644 index 00000000..c2583d2b --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_purchase_product.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_restore_purchases.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_restore_purchases.toml new file mode 100644 index 00000000..debee009 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/iap_restore_purchases.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index 1ce8c04b..0684dfb5 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -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. +`native-bridge:allow-iap-fetch-products` + + + + +Enables the iap_fetch_products command without any pre-configured scope. + + + + + + + +`native-bridge:deny-iap-fetch-products` + + + + +Denies the iap_fetch_products command without any pre-configured scope. + + + + + + + +`native-bridge:allow-iap-initialize` + + + + +Enables the iap_initialize command without any pre-configured scope. + + + + + + + +`native-bridge:deny-iap-initialize` + + + + +Denies the iap_initialize command without any pre-configured scope. + + + + + + + +`native-bridge:allow-iap-purchase-product` + + + + +Enables the iap_purchase_product command without any pre-configured scope. + + + + + + + +`native-bridge:deny-iap-purchase-product` + + + + +Denies the iap_purchase_product command without any pre-configured scope. + + + + + + + +`native-bridge:allow-iap-restore-purchases` + + + + +Enables the iap_restore_purchases command without any pre-configured scope. + + + + + + + +`native-bridge:deny-iap-restore-purchases` + + + + +Denies the iap_restore_purchases command without any pre-configured scope. + + + + + + + `native-bridge:allow-install-package` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index 225774e2..93231aa1 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -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", ] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index 02eac9d9..80e63a45 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -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`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index 49409031..10f91867 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -81,3 +81,34 @@ pub(crate) async fn lock_screen_orientation( ) -> Result<()> { app.native_bridge().lock_screen_orientation(payload) } + +#[command] +pub(crate) async fn iap_initialize( + app: AppHandle, + payload: IAPInitializeRequest, +) -> Result { + app.native_bridge().iap_initialize(payload) +} + +#[command] +pub(crate) async fn iap_fetch_products( + app: AppHandle, + payload: IAPFetchProductsRequest, +) -> Result { + app.native_bridge().iap_fetch_products(payload) +} + +#[command] +pub(crate) async fn iap_purchase_product( + app: AppHandle, + payload: IAPPurchaseProductRequest, +) -> Result { + app.native_bridge().iap_purchase_product(payload) +} + +#[command] +pub(crate) async fn iap_restore_purchases( + app: AppHandle, +) -> Result { + app.native_bridge().iap_restore_purchases() +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index 71e9bd9d..f2bd6006 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -73,4 +73,29 @@ impl NativeBridge { ) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatformError) } + + pub fn iap_initialize( + &self, + _payload: IAPInitializeRequest, + ) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } + + pub fn iap_fetch_products( + &self, + _payload: IAPFetchProductsRequest, + ) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } + + pub fn iap_purchase_product( + &self, + _payload: IAPPurchaseProductRequest, + ) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } + + pub fn iap_restore_purchases(&self) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index 97264f7d..ace003b3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -47,6 +47,10 @@ pub fn init() -> TauriPlugin { 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)] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index eaa6ecfa..88c3f4c7 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -112,3 +112,46 @@ impl NativeBridge { .map_err(Into::into) } } + +impl NativeBridge { + pub fn iap_initialize( + &self, + payload: IAPInitializeRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("iap_initialize", payload) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn iap_fetch_products( + &self, + payload: IAPFetchProductsRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("iap_fetch_products", payload) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn iap_purchase_product( + &self, + payload: IAPPurchaseProductRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("iap_purchase_product", payload) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn iap_restore_purchases( + &self, + ) -> crate::Result { + self.0 + .run_mobile_plugin("iap_restore_purchases", ()) + .map_err(Into::into) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index 030aa65b..c25808c1 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -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, + 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, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IAPFetchProductsResponse { + pub products: Vec, +} + +#[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, + pub cancelled_purchase: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IAPRestorePurchasesResponse { + pub purchases: Vec, +} diff --git a/apps/readest-app/src/utils/iap.ts b/apps/readest-app/src/utils/iap.ts new file mode 100644 index 00000000..c5f8eb64 --- /dev/null +++ b/apps/readest-app/src/utils/iap.ts @@ -0,0 +1,99 @@ +import { invoke } from '@tauri-apps/api/core'; + +export interface IAPProduct { + id: string; + title: string; + description: string; + price: string; + priceCurrencyCode?: string; + priceAmountMicros: number; +} + +export interface IAPPurchase { + productId: string; + transactionId: string; + originalTransactionId: string; + purchaseDate: string; + platform: 'ios' | 'android'; +} + +interface InitializeRequest { + publicKey?: string; +} + +interface InitializeResponse { + success: boolean; +} + +interface FetchProductsRequest { + productIds: string[]; +} + +interface FetchProductsResponse { + products: IAPProduct[]; +} + +interface PurchaseProductRequest { + productId: string; +} + +interface PurchaseProductResponse { + purchase: IAPPurchase; +} + +interface RestorePurchasesResponse { + purchases: IAPPurchase[]; +} + +class IAPService { + async initialize(): Promise { + const result = await invoke('plugin:native-bridge|iap_initialize', { + payload: {} as InitializeRequest, + }); + return result.success; + } + + async fetchProducts(productIds: string[]): Promise { + try { + const response = await invoke( + 'plugin:native-bridge|iap_fetch_products', + { + payload: { productIds } as FetchProductsRequest, + }, + ); + return response.products; + } catch (error) { + console.error('Failed to fetch products:', error); + throw error; + } + } + + async purchaseProduct(productId: string): Promise { + try { + const response = await invoke( + 'plugin:native-bridge|iap_purchase_product', + { + payload: { productId } as PurchaseProductRequest, + }, + ); + return response.purchase; + } catch (error) { + console.error('Failed to purchase product:', error); + throw error; + } + } + + async restorePurchases(): Promise { + try { + const response = await invoke( + 'plugin:native-bridge|iap_restore_purchases', + ); + return response.purchases; + } catch (error) { + console.error('Failed to restore purchases:', error); + throw error; + } + } +} + +export default IAPService;