From 8a01c1a6ad2f782804ded8102ec81a1158f80e9f Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Mon, 5 May 2025 11:56:34 -0500 Subject: [PATCH 1/4] Only consider US for apple external billing --- .gitignore | 9 + flake.nix | 2 +- frontend/src-tauri/Cargo.lock | 11 + frontend/src-tauri/Cargo.toml | 5 + frontend/src-tauri/capabilities/default.json | 3 +- .../src-tauri/capabilities/mobile-ios.json | 5 +- frontend/src-tauri/src/lib.rs | 9 +- frontend/src/components/Marketing.tsx | 81 +- .../src/routes/auth.$provider.callback.tsx | 2 +- frontend/src/routes/pricing.tsx | 79 +- frontend/src/utils/region-gate.ts | 55 + plugins/store/.tauri/tauri-api/.gitignore | 10 + plugins/store/.tauri/tauri-api/Package.swift | 40 + plugins/store/.tauri/tauri-api/README.md | 3 + .../tauri-api/Sources/Tauri/Channel.swift | 65 + .../tauri-api/Sources/Tauri/Invoke.swift | 118 + .../tauri-api/Sources/Tauri/JSTypes.swift | 123 + .../tauri-api/Sources/Tauri/JsonValue.swift | 58 + .../tauri-api/Sources/Tauri/Logger.swift | 58 + .../Sources/Tauri/Plugin/Plugin.swift | 81 + .../tauri-api/Sources/Tauri/Tauri.swift | 134 + .../tauri-api/Sources/Tauri/UiUtils.swift | 15 + plugins/store/Cargo.lock | 4636 +++++++++++++++++ plugins/store/Cargo.toml | 18 + plugins/store/build.rs | 7 + plugins/store/ios/Package.resolved | 16 + plugins/store/ios/Package.swift | 25 + plugins/store/ios/Sources/StorePlugin.swift | 65 + .../autogenerated/commands/get_region.toml | 13 + .../permissions/autogenerated/reference.md | 36 + plugins/store/permissions/schemas/schema.json | 312 ++ plugins/store/src/commands.rs | 11 + plugins/store/src/desktop.rs | 19 + plugins/store/src/error.rs | 24 + plugins/store/src/lib.rs | 45 + plugins/store/src/mobile.rs | 38 + 36 files changed, 6202 insertions(+), 29 deletions(-) create mode 100644 frontend/src/utils/region-gate.ts create mode 100644 plugins/store/.tauri/tauri-api/.gitignore create mode 100644 plugins/store/.tauri/tauri-api/Package.swift create mode 100644 plugins/store/.tauri/tauri-api/README.md create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/Channel.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/Invoke.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/JSTypes.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/JsonValue.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/Logger.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/Plugin/Plugin.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/Tauri.swift create mode 100644 plugins/store/.tauri/tauri-api/Sources/Tauri/UiUtils.swift create mode 100644 plugins/store/Cargo.lock create mode 100644 plugins/store/Cargo.toml create mode 100644 plugins/store/build.rs create mode 100644 plugins/store/ios/Package.resolved create mode 100644 plugins/store/ios/Package.swift create mode 100644 plugins/store/ios/Sources/StorePlugin.swift create mode 100644 plugins/store/permissions/autogenerated/commands/get_region.toml create mode 100644 plugins/store/permissions/autogenerated/reference.md create mode 100644 plugins/store/permissions/schemas/schema.json create mode 100644 plugins/store/src/commands.rs create mode 100644 plugins/store/src/desktop.rs create mode 100644 plugins/store/src/error.rs create mode 100644 plugins/store/src/lib.rs create mode 100644 plugins/store/src/mobile.rs diff --git a/.gitignore b/.gitignore index 115cc540..66ef3a57 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,12 @@ frontend/*.local *.tsbuildinfo **/.claude/settings.local.json + +# Rust build directories +target/ +**/target/ +plugins/*/target/ + +# iOS build artifacts +frontend/src-tauri/gen/apple/build/ +frontend/src-tauri/gen/apple/DerivedData/ \ No newline at end of file diff --git a/flake.nix b/flake.nix index 1e5555c7..b6399fb4 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ }; # Use specific rust version required by Tauri - rustToolchain = pkgs.rust-bin.stable."1.78.0".default.override { + rustToolchain = pkgs.rust-bin.stable."1.81.0".default.override { extensions = [ "rust-src" ]; }; in diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 12445b00..c2c337b2 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2236,6 +2236,7 @@ dependencies = [ "once_cell", "serde", "serde_json", + "store", "tauri", "tauri-build", "tauri-plugin", @@ -3971,6 +3972,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "store" +version = "0.1.0" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", +] + [[package]] name = "string_cache" version = "0.8.8" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 41a13e8e..49457c40 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -32,3 +32,8 @@ tauri-plugin-os = "2" tauri-plugin-sign-in-with-apple = "1.0.2" tokio = { version = "1.0", features = ["time"] } once_cell = "1.18.0" +store = { path = "../../plugins/store", features = [] } + +[target.'cfg(target_os = "ios")'.dependencies.store] +path = "../../plugins/store" +features = ["mobile"] diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index d8a7b18c..522b4270 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -28,6 +28,7 @@ } ] }, - "sign-in-with-apple:default" + "sign-in-with-apple:default", + "store:allow-get-region" ] } diff --git a/frontend/src-tauri/capabilities/mobile-ios.json b/frontend/src-tauri/capabilities/mobile-ios.json index 6adf773b..3878dcb8 100644 --- a/frontend/src-tauri/capabilities/mobile-ios.json +++ b/frontend/src-tauri/capabilities/mobile-ios.json @@ -29,6 +29,7 @@ } ] }, - "sign-in-with-apple:default" + "sign-in-with-apple:default", + "store:allow-get-region" ] -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 52777924..60e74ea6 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,6 +1,11 @@ use tauri::Emitter; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_opener; + +#[cfg(all(not(desktop), target_os = "ios"))] +use store; + +#[cfg(all(not(desktop), target_os = "ios"))] use tauri_plugin_sign_in_with_apple; // This handles incoming deep links @@ -198,7 +203,9 @@ pub fn run() { // Only add the Apple Sign In plugin on iOS #[cfg(all(not(desktop), target_os = "ios"))] { - builder = builder.plugin(tauri_plugin_sign_in_with_apple::init()); + builder = builder + .plugin(tauri_plugin_sign_in_with_apple::init()) + .plugin(store::init()); } #[cfg(not(desktop))] diff --git a/frontend/src/components/Marketing.tsx b/frontend/src/components/Marketing.tsx index ad3c8ab2..820c7fa4 100644 --- a/frontend/src/components/Marketing.tsx +++ b/frontend/src/components/Marketing.tsx @@ -88,7 +88,8 @@ function PricingTier({ ctaText, popular = false, productId = "", // Add productId parameter - isIOS = false // Add iOS detection parameter + isIOS = false, // Add iOS detection parameter + externalBillingAllowed = true // Add external billing parameter with default to true }: { name: string; price: string; @@ -98,6 +99,7 @@ function PricingTier({ popular?: boolean; productId?: string; // Add type for productId isIOS?: boolean; // Add type for iOS detection + externalBillingAllowed?: boolean; // Add type for external billing }) { const isTeamPlan = name.toLowerCase().includes("team"); const isFreeplan = name.toLowerCase().includes("free"); @@ -133,19 +135,49 @@ function PricingTier({ ))} - {/* For iOS devices, disable paid plans with "Coming Soon" text */} + {/* For iOS devices, check if external billing is allowed */} {isIOS && !isFreeplan ? ( - + externalBillingAllowed ? ( + // If external billing is allowed, handle product selection normally + productId ? ( + + ) : ( + + {ctaText} + + ) + ) : ( + // If external billing is not allowed, show "Coming Soon" text + + ) ) : isTeamPlan ? ( // For team plans, add "Contact Us" button that opens email + ); +} \ No newline at end of file diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index 2e2f76b3..87067a12 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -1,10 +1,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, Suspense } from "react"; import { useOpenSecret } from "@opensecret/react"; import { TopNav } from "@/components/TopNav"; import { FullPageMain } from "@/components/FullPageMain"; import { getBillingService } from "@/billing/billingService"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { MarketingHeader } from "@/components/MarketingHeader"; import { Loader2, Check, AlertTriangle, Bitcoin } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -12,6 +12,37 @@ import { useLocalState } from "@/state/useLocalState"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { type } from "@tauri-apps/plugin-os"; +import { ApplePayButton } from "@/components/ApplePayButton"; + +// Wrapper for Apple Pay button to handle success/error/cancel +function ApplePayButtonWrapper({ productId, className }: { productId: string, className?: string }) { + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const handleSuccess = async (transactionId: number) => { + try { + // Refresh billing status after successful purchase + await queryClient.invalidateQueries({ queryKey: ["billingStatus"] }); + } catch (error) { + console.error("Error handling successful Apple Pay transaction:", error); + } + }; + + return ( +
+ setError(error.message)} + text="Subscribe with" + /> + {error && ( +
{error}
+ )} +
+ ); +} type PricingSearchParams = { selected_plan?: string; @@ -834,33 +865,50 @@ function PricingPage() { )} - + {/* Show Apple Pay button for paid plans on iOS in non-US regions */} + {isIOS && + !externalBillingAllowed && + !product.name.toLowerCase().includes("free") && + !product.name.toLowerCase().includes("team") ? ( +
+ {/* ApplePayButton will be dynamically imported below */} + +
+ In-app purchase required for your region +
+
+ ) : ( + + )} ); diff --git a/frontend/src/utils/region-gate.ts b/frontend/src/utils/region-gate.ts index 14e5e296..a9b14f90 100644 --- a/frontend/src/utils/region-gate.ts +++ b/frontend/src/utils/region-gate.ts @@ -41,10 +41,27 @@ export async function getStoreRegion(): Promise { * Returns true for US regions, false for all others or on errors. */ export async function allowExternalBilling(): Promise { - const regionCode = await getStoreRegion(); - const isAllowed = US_CODES.includes(regionCode); - console.log("[Region Gate] Is US region:", isAllowed, "Valid US codes:", US_CODES); - return isAllowed; + try { + // Check if we're on iOS + const { platform } = await import("@tauri-apps/plugin-os"); + const currentPlatform = await platform(); + + // Only apply the region check on iOS + if (currentPlatform !== "ios") { + // For non-iOS platforms, always allow external billing + return true; + } + + // On iOS, check the App Store region + const regionCode = await getStoreRegion(); + const isAllowed = US_CODES.includes(regionCode); + console.log("[Region Gate] Is US region:", isAllowed, "Valid US codes:", US_CODES); + return isAllowed; + } catch (error) { + console.error("[Region Gate] Error checking external billing:", error); + // On error, assume non-US to be safe + return false; + } } /** diff --git a/plugins/store/build.rs b/plugins/store/build.rs index a839d311..ee8e65ac 100644 --- a/plugins/store/build.rs +++ b/plugins/store/build.rs @@ -1,4 +1,12 @@ -const COMMANDS: &[&str] = &["get_region"]; +const COMMANDS: &[&str] = &[ + "get_region", + "get_products", + "purchase", + "verify_purchase", + "get_transactions", + "restore_purchases", + "get_subscription_status" +]; fn main() { tauri_plugin::Builder::new(COMMANDS) diff --git a/plugins/store/ios/Sources/StorePlugin.swift b/plugins/store/ios/Sources/StorePlugin.swift index b29bc946..b4be548f 100644 --- a/plugins/store/ios/Sources/StorePlugin.swift +++ b/plugins/store/ios/Sources/StorePlugin.swift @@ -1,13 +1,82 @@ // -// StoreRegionPlugin.swift +// StorePlugin.swift // store // import StoreKit import Tauri +// Main StoreKit plugin class @available(iOS 15.0, *) class StorePlugin: Plugin { + // MARK: - Properties + + // Dictionary to store pending transactions by identifier + private var pendingTransactions: [UInt32: Transaction] = [:] + + // Store product identifiers + private var availableProducts: [Product] = [] + + // Transaction listener task + private var transactionListenerTask: Task? = nil + + // MARK: - Initialization + + override init() { + super.init() + // Start listening for transactions + startTransactionListener() + } + + deinit { + // Cancel the transaction listener task when the plugin is destroyed + transactionListenerTask?.cancel() + } + + // MARK: - Transaction Listener + + private func startTransactionListener() { + transactionListenerTask = Task { + // Listen for transactions for the lifetime of the app + for await verificationResult in Transaction.updates { + // Handle transaction updates + do { + let transaction = try checkVerificationResult(verificationResult) + + // Process the transaction accordingly + await handleTransactionUpdate(transaction) + } catch { + print("[StorePlugin] Error handling transaction update: \(error.localizedDescription)") + } + } + } + } + + private func checkVerificationResult(_ result: VerificationResult) throws -> T { + switch result { + case .unverified(let unverifiedItem, let error): + print("[StorePlugin] Unverified transaction: \(error.localizedDescription)") + // Still return the unverified item, but log the error + return unverifiedItem + case .verified(let verifiedItem): + return verifiedItem + } + } + + private func handleTransactionUpdate(_ transaction: Transaction) async { + // Notify your server about the transaction + + // For consumables, manage them appropriately + + // Finish the transaction to inform Apple the transaction is complete + await transaction.finish() + + // Send a notification to the frontend if needed + emit("transactionUpdated", transaction.jsonRepresentation) + } + + // MARK: - Command: getRegion + @objc public func getRegion(_ invoke: Invoke) { // Using StoreKit 2 API for iOS 15+ Task { @@ -45,8 +114,478 @@ class StorePlugin: Plugin { print("[StorePlugin] All methods failed, returning UNKNOWN") invoke.resolve("UNKNOWN") } + + // MARK: - Command: getProducts + + @objc public func getProducts(_ invoke: Invoke) { + let args = invoke.arguments + guard let productIds = args["productIds"] as? [String] else { + invoke.reject("Invalid arguments: expected an array of product IDs") + return + } + + Task { + do { + let storeProducts = try await Product.products(for: Set(productIds)) + availableProducts = storeProducts + + // Convert products to dictionaries for JSON serialization + let productDicts = storeProducts.map { product -> [String: Any] in + return product.jsonRepresentation + } + + invoke.resolve(productDicts) + } catch { + print("[StorePlugin] Error fetching products: \(error.localizedDescription)") + invoke.reject("Failed to fetch products: \(error.localizedDescription)") + } + } + } + + // MARK: - Command: purchase + + @objc public func purchase(_ invoke: Invoke) { + let args = invoke.arguments + guard let productId = args["productId"] as? String else { + invoke.reject("Invalid arguments: expected a product ID") + return + } + + // Find the product in the available products + guard let product = availableProducts.first(where: { $0.id == productId }) else { + invoke.reject("Product not found. Make sure to call getProducts first.") + return + } + + Task { + do { + // Create a purchase option + let options: Set = [] + + // Purchase the product + let result = try await product.purchase(options: options) + + // Handle the purchase result + switch result { + case .success(let verificationResult): + do { + let transaction = try checkVerificationResult(verificationResult) + + // Save the transaction with the invoke ID for later resolution + pendingTransactions[invoke.callbackId] = transaction + + // Resolve the purchase with the transaction details + invoke.resolve([ + "status": "success", + "transactionId": transaction.id, + "originalTransactionId": transaction.originalID, + "productId": transaction.productID, + "purchaseDate": Int(transaction.purchaseDate.timeIntervalSince1970 * 1000), + "expirationDate": transaction.expirationDate.map { Int($0.timeIntervalSince1970 * 1000) }, + "webOrderLineItemId": transaction.webOrderLineItemID ?? "", + "quantity": transaction.purchasedQuantity, + "type": transactionTypeToString(transaction), + "ownershipType": transaction.ownershipType == .purchased ? "purchased" : "familyShared", + "signedDate": Int(transaction.signedDate.timeIntervalSince1970 * 1000), + "environment": environmentToString() + ]) + + // Process and finish the transaction + await handleTransactionUpdate(transaction) + } catch { + invoke.reject("Transaction verification failed: \(error.localizedDescription)") + } + case .userCancelled: + invoke.reject("Purchase was cancelled by the user") + case .pending: + // For ask to buy or other pending states + invoke.resolve(["status": "pending", "message": "Purchase is pending approval"]) + @unknown default: + invoke.reject("Unknown purchase result") + } + } catch { + print("[StorePlugin] Purchase error: \(error.localizedDescription)") + invoke.reject("Purchase failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Command: verifyPurchase + + @objc public func verifyPurchase(_ invoke: Invoke) { + let args = invoke.arguments + guard let productId = args["productId"] as? String, + let transactionId = args["transactionId"] as? UInt64 else { + invoke.reject("Invalid arguments: expected productId and transactionId") + return + } + + Task { + do { + // Get all transaction history for the specified product + var verifiedTransaction: Transaction? + + for await result in Transaction.currentEntitlements { + do { + let transaction = try checkVerificationResult(result) + + if transaction.productID == productId && transaction.id == transactionId { + verifiedTransaction = transaction + break + } + } catch { + continue // Skip unverified transactions + } + } + + if let transaction = verifiedTransaction { + // Transaction is valid + invoke.resolve([ + "isValid": true, + "expirationDate": transaction.expirationDate.map { Int($0.timeIntervalSince1970 * 1000) }, + "purchaseDate": Int(transaction.purchaseDate.timeIntervalSince1970 * 1000) + ]) + } else { + // Transaction not found or not valid + invoke.resolve(["isValid": false]) + } + } catch { + print("[StorePlugin] Verification error: \(error.localizedDescription)") + invoke.reject("Verification failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Command: getTransactions + + @objc public func getTransactions(_ invoke: Invoke) { + let args = invoke.arguments + let productId = args["productId"] as? String // Optional filter by product ID + + Task { + do { + var transactions: [[String: Any]] = [] + + // Get all current entitlements (active subscriptions and non-consumables) + for await verificationResult in Transaction.currentEntitlements { + do { + let transaction = try checkVerificationResult(verificationResult) + + // Filter by product ID if specified + if let productId = productId, transaction.productID != productId { + continue + } + + transactions.append(transaction.jsonRepresentation) + } catch { + continue // Skip unverified transactions + } + } + + invoke.resolve(transactions) + } catch { + print("[StorePlugin] Error fetching transactions: \(error.localizedDescription)") + invoke.reject("Failed to fetch transactions: \(error.localizedDescription)") + } + } + } + + // MARK: - Command: restorePurchases + + @objc public func restorePurchases(_ invoke: Invoke) { + Task { + do { + // Request a refresh of App Store purchase history + try await AppStore.sync() + + var restoredTransactions: [[String: Any]] = [] + + // Get all current entitlements after sync + for await verificationResult in Transaction.currentEntitlements { + do { + let transaction = try checkVerificationResult(verificationResult) + restoredTransactions.append(transaction.jsonRepresentation) + } catch { + continue // Skip unverified transactions + } + } + + invoke.resolve(["status": "success", "transactions": restoredTransactions]) + } catch { + print("[StorePlugin] Restore error: \(error.localizedDescription)") + invoke.reject("Restore failed: \(error.localizedDescription)") + } + } + } + + // MARK: - Command: getSubscriptionStatus + + @objc public func getSubscriptionStatus(_ invoke: Invoke) { + let args = invoke.arguments + guard let productId = args["productId"] as? String else { + invoke.reject("Invalid arguments: expected a product ID") + return + } + + Task { + do { + var subscriptionGroupStatus: [String: Any] = [ + "productId": productId, + "status": "not_subscribed", + "willAutoRenew": false, + "expirationDate": nil, + "gracePeriodExpirationDate": nil + ] + + // Look for subscription transactions for this product + for await verificationResult in Transaction.currentEntitlements { + do { + let transaction = try checkVerificationResult(verificationResult) + + // Only check for subscriptions that match this product ID + if transaction.productID == productId && transaction.productType == .autoRenewable { + + // Check the subscription info + let status = try? await transaction.subscriptionStatus + + if let status = status { + // Update subscription status + subscriptionGroupStatus["status"] = subscriptionStateToString(status.state) + subscriptionGroupStatus["willAutoRenew"] = status.willAutoRenew + subscriptionGroupStatus["expirationDate"] = transaction.expirationDate.map { Int($0.timeIntervalSince1970 * 1000) } + + // Check for grace period + if status.state == .inGracePeriod, let renewalInfo = status.renewalInfo { + subscriptionGroupStatus["gracePeriodExpirationDate"] = Int(renewalInfo.expirationDate.timeIntervalSince1970 * 1000) + } + + // Return the first valid subscription we find + break + } + } + } catch { + continue // Skip unverified transactions + } + } + + invoke.resolve(subscriptionGroupStatus) + } catch { + print("[StorePlugin] Error getting subscription status: \(error.localizedDescription)") + invoke.reject("Failed to get subscription status: \(error.localizedDescription)") + } + } + } + + // MARK: - Helper Functions + + private func transactionTypeToString(_ transaction: Transaction) -> String { + switch transaction.productType { + case .consumable: + return "consumable" + case .nonConsumable: + return "non_consumable" + case .nonRenewable: + return "non_renewable_subscription" + case .autoRenewable: + return "auto_renewable_subscription" + @unknown default: + return "unknown" + } + } + + private func subscriptionStateToString(_ state: Product.SubscriptionInfo.RenewalState) -> String { + switch state { + case .subscribed: + return "subscribed" + case .expired: + return "expired" + case .inBillingRetryPeriod: + return "in_billing_retry_period" + case .inGracePeriod: + return "in_grace_period" + case .revoked: + return "revoked" + @unknown default: + return "unknown" + } + } + + private func environmentToString() -> String { + #if DEBUG + return "sandbox" + #else + return AppStore.currentStorefront != nil ? "production" : "sandbox" + #endif + } +} + +// MARK: - Extensions + +@available(iOS 15.0, *) +extension Product { + var jsonRepresentation: [String: Any] { + var dict: [String: Any] = [ + "id": id, + "title": displayName, + "description": description, + "price": displayPrice, + "priceValue": price, + "currencyCode": priceFormatStyle.currencyCode ?? "USD", + "type": productTypeToString() + ] + + // Add subscription-specific details if available + if let subscription = subscription { + dict["subscriptionPeriod"] = subscription.subscriptionPeriod.jsonRepresentation + + if let introductoryOffer = subscription.introductoryOffer { + dict["introductoryOffer"] = introductoryOffer.jsonRepresentation + } + + if let promotionalOffers = subscription.promotionalOffers, !promotionalOffers.isEmpty { + dict["promotionalOffers"] = promotionalOffers.map { $0.jsonRepresentation } + } + } + + return dict + } + + private func productTypeToString() -> String { + switch type { + case .consumable: + return "consumable" + case .nonConsumable: + return "non_consumable" + case .nonRenewable: + return "non_renewable_subscription" + case .autoRenewable: + return "auto_renewable_subscription" + @unknown default: + return "unknown" + } + } } +@available(iOS 15.0, *) +extension Product.SubscriptionPeriod { + var jsonRepresentation: [String: Any] { + return [ + "unit": unitToString(), + "value": value + ] + } + + private func unitToString() -> String { + switch unit { + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + @unknown default: + return "unknown" + } + } +} + +@available(iOS 15.0, *) +extension Product.SubscriptionOffer { + var jsonRepresentation: [String: Any] { + var dict: [String: Any] = [ + "id": id, + "displayPrice": displayPrice, + "period": subscriptionPeriod.jsonRepresentation, + "paymentMode": paymentModeToString(), + "type": offerTypeToString() + ] + + if let discount = discount { + dict["discountType"] = discountTypeToString(discount) + dict["discountPrice"] = discount.displayPrice + } + + return dict + } + + private func paymentModeToString() -> String { + switch paymentMode { + case .payAsYouGo: + return "pay_as_you_go" + case .payUpFront: + return "pay_up_front" + case .freeTrial: + return "free_trial" + @unknown default: + return "unknown" + } + } + + private func offerTypeToString() -> String { + switch type { + case .introductory: + return "introductory" + case .promotional: + return "promotional" + case .prepaid: + return "prepaid" + case .consumable: + return "consumable" + @unknown default: + return "unknown" + } + } + + private func discountTypeToString(_ discount: Product.SubscriptionOffer.Discount) -> String { + if let numericValue = discount.numericDiscount, numericValue > 0 { + // numericDiscount is a decimal percentage value (e.g., 0.5 for 50%) + return "percentage" + } else { + return "nominal" // Fixed amount discount + } + } +} + +@available(iOS 15.0, *) +extension Transaction { + var jsonRepresentation: [String: Any] { + return [ + "id": id, + "originalId": originalID, + "productId": productID, + "purchaseDate": Int(purchaseDate.timeIntervalSince1970 * 1000), + "expirationDate": expirationDate.map { Int($0.timeIntervalSince1970 * 1000) }, + "webOrderLineItemId": webOrderLineItemID ?? "", + "quantity": purchasedQuantity, + "type": productType.productTypeToString(), + "ownershipType": ownershipType == .purchased ? "purchased" : "familyShared", + "signedDate": Int(signedDate.timeIntervalSince1970 * 1000) + ] + } +} + +@available(iOS 15.0, *) +extension Product.ProductType { + func productTypeToString() -> String { + switch self { + case .consumable: + return "consumable" + case .nonConsumable: + return "non_consumable" + case .nonRenewable: + return "non_renewable_subscription" + case .autoRenewable: + return "auto_renewable_subscription" + @unknown default: + return "unknown" + } + } +} + +// MARK: - Init Plugin + @available(iOS 15.0, *) @_cdecl("init_plugin_store") func initPlugin() -> Plugin { diff --git a/plugins/store/permissions/autogenerated/commands/get_products.toml b/plugins/store/permissions/autogenerated/commands/get_products.toml new file mode 100644 index 00000000..088f5cf4 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/get_products.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-products" +description = "Enables the get_products command without any pre-configured scope." +commands.allow = ["get_products"] + +[[permission]] +identifier = "deny-get-products" +description = "Denies the get_products command without any pre-configured scope." +commands.deny = ["get_products"] diff --git a/plugins/store/permissions/autogenerated/commands/get_subscription_status.toml b/plugins/store/permissions/autogenerated/commands/get_subscription_status.toml new file mode 100644 index 00000000..49680892 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/get_subscription_status.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-subscription-status" +description = "Enables the get_subscription_status command without any pre-configured scope." +commands.allow = ["get_subscription_status"] + +[[permission]] +identifier = "deny-get-subscription-status" +description = "Denies the get_subscription_status command without any pre-configured scope." +commands.deny = ["get_subscription_status"] diff --git a/plugins/store/permissions/autogenerated/commands/get_transactions.toml b/plugins/store/permissions/autogenerated/commands/get_transactions.toml new file mode 100644 index 00000000..9ac36eb8 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/get_transactions.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-transactions" +description = "Enables the get_transactions command without any pre-configured scope." +commands.allow = ["get_transactions"] + +[[permission]] +identifier = "deny-get-transactions" +description = "Denies the get_transactions command without any pre-configured scope." +commands.deny = ["get_transactions"] diff --git a/plugins/store/permissions/autogenerated/commands/purchase.toml b/plugins/store/permissions/autogenerated/commands/purchase.toml new file mode 100644 index 00000000..925ad9f7 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/purchase.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-purchase" +description = "Enables the purchase command without any pre-configured scope." +commands.allow = ["purchase"] + +[[permission]] +identifier = "deny-purchase" +description = "Denies the purchase command without any pre-configured scope." +commands.deny = ["purchase"] diff --git a/plugins/store/permissions/autogenerated/commands/restore_purchases.toml b/plugins/store/permissions/autogenerated/commands/restore_purchases.toml new file mode 100644 index 00000000..151a44ed --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/restore_purchases.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-restore-purchases" +description = "Enables the restore_purchases command without any pre-configured scope." +commands.allow = ["restore_purchases"] + +[[permission]] +identifier = "deny-restore-purchases" +description = "Denies the restore_purchases command without any pre-configured scope." +commands.deny = ["restore_purchases"] diff --git a/plugins/store/permissions/autogenerated/commands/verify_purchase.toml b/plugins/store/permissions/autogenerated/commands/verify_purchase.toml new file mode 100644 index 00000000..f6eed3e1 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/verify_purchase.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-verify-purchase" +description = "Enables the verify_purchase command without any pre-configured scope." +commands.allow = ["verify_purchase"] + +[[permission]] +identifier = "deny-verify-purchase" +description = "Denies the verify_purchase command without any pre-configured scope." +commands.deny = ["verify_purchase"] diff --git a/plugins/store/permissions/autogenerated/reference.md b/plugins/store/permissions/autogenerated/reference.md index 1e605ef7..b5f2a5de 100644 --- a/plugins/store/permissions/autogenerated/reference.md +++ b/plugins/store/permissions/autogenerated/reference.md @@ -8,6 +8,32 @@ + + + +`store:allow-get-products` + + + + +Enables the get_products command without any pre-configured scope. + + + + + + + +`store:deny-get-products` + + + + +Denies the get_products command without any pre-configured scope. + + + + @@ -31,6 +57,136 @@ Enables the get_region command without any pre-configured scope. Denies the get_region command without any pre-configured scope. + + + + + + +`store:allow-get-subscription-status` + + + + +Enables the get_subscription_status command without any pre-configured scope. + + + + + + + +`store:deny-get-subscription-status` + + + + +Denies the get_subscription_status command without any pre-configured scope. + + + + + + + +`store:allow-get-transactions` + + + + +Enables the get_transactions command without any pre-configured scope. + + + + + + + +`store:deny-get-transactions` + + + + +Denies the get_transactions command without any pre-configured scope. + + + + + + + +`store:allow-purchase` + + + + +Enables the purchase command without any pre-configured scope. + + + + + + + +`store:deny-purchase` + + + + +Denies the purchase command without any pre-configured scope. + + + + + + + +`store:allow-restore-purchases` + + + + +Enables the restore_purchases command without any pre-configured scope. + + + + + + + +`store:deny-restore-purchases` + + + + +Denies the restore_purchases command without any pre-configured scope. + + + + + + + +`store:allow-verify-purchase` + + + + +Enables the verify_purchase command without any pre-configured scope. + + + + + + + +`store:deny-verify-purchase` + + + + +Denies the verify_purchase command without any pre-configured scope. + diff --git a/plugins/store/permissions/schemas/schema.json b/plugins/store/permissions/schemas/schema.json index 0cab263a..0d1e1f9c 100644 --- a/plugins/store/permissions/schemas/schema.json +++ b/plugins/store/permissions/schemas/schema.json @@ -294,6 +294,18 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the get_products command without any pre-configured scope.", + "type": "string", + "const": "allow-get-products", + "markdownDescription": "Enables the get_products command without any pre-configured scope." + }, + { + "description": "Denies the get_products command without any pre-configured scope.", + "type": "string", + "const": "deny-get-products", + "markdownDescription": "Denies the get_products command without any pre-configured scope." + }, { "description": "Enables the get_region command without any pre-configured scope.", "type": "string", @@ -305,6 +317,66 @@ "type": "string", "const": "deny-get-region", "markdownDescription": "Denies the get_region command without any pre-configured scope." + }, + { + "description": "Enables the get_subscription_status command without any pre-configured scope.", + "type": "string", + "const": "allow-get-subscription-status", + "markdownDescription": "Enables the get_subscription_status command without any pre-configured scope." + }, + { + "description": "Denies the get_subscription_status command without any pre-configured scope.", + "type": "string", + "const": "deny-get-subscription-status", + "markdownDescription": "Denies the get_subscription_status command without any pre-configured scope." + }, + { + "description": "Enables the get_transactions command without any pre-configured scope.", + "type": "string", + "const": "allow-get-transactions", + "markdownDescription": "Enables the get_transactions command without any pre-configured scope." + }, + { + "description": "Denies the get_transactions command without any pre-configured scope.", + "type": "string", + "const": "deny-get-transactions", + "markdownDescription": "Denies the get_transactions command without any pre-configured scope." + }, + { + "description": "Enables the purchase command without any pre-configured scope.", + "type": "string", + "const": "allow-purchase", + "markdownDescription": "Enables the purchase command without any pre-configured scope." + }, + { + "description": "Denies the purchase command without any pre-configured scope.", + "type": "string", + "const": "deny-purchase", + "markdownDescription": "Denies the purchase command without any pre-configured scope." + }, + { + "description": "Enables the restore_purchases command without any pre-configured scope.", + "type": "string", + "const": "allow-restore-purchases", + "markdownDescription": "Enables the restore_purchases command without any pre-configured scope." + }, + { + "description": "Denies the restore_purchases command without any pre-configured scope.", + "type": "string", + "const": "deny-restore-purchases", + "markdownDescription": "Denies the restore_purchases command without any pre-configured scope." + }, + { + "description": "Enables the verify_purchase command without any pre-configured scope.", + "type": "string", + "const": "allow-verify-purchase", + "markdownDescription": "Enables the verify_purchase command without any pre-configured scope." + }, + { + "description": "Denies the verify_purchase command without any pre-configured scope.", + "type": "string", + "const": "deny-verify-purchase", + "markdownDescription": "Denies the verify_purchase command without any pre-configured scope." } ] } diff --git a/plugins/store/src/commands.rs b/plugins/store/src/commands.rs index 419d1148..637f4762 100644 --- a/plugins/store/src/commands.rs +++ b/plugins/store/src/commands.rs @@ -8,4 +8,52 @@ pub(crate) async fn get_region( app: AppHandle, ) -> Result { app.store().get_region() +} + +#[command] +pub(crate) async fn get_products( + app: AppHandle, + product_ids: Vec, +) -> Result> { + app.store().get_products(product_ids) +} + +#[command] +pub(crate) async fn purchase( + app: AppHandle, + product_id: String, +) -> Result { + app.store().purchase(product_id) +} + +#[command] +pub(crate) async fn verify_purchase( + app: AppHandle, + product_id: String, + transaction_id: u64, +) -> Result { + app.store().verify_purchase(product_id, transaction_id) +} + +#[command] +pub(crate) async fn get_transactions( + app: AppHandle, + product_id: Option, +) -> Result> { + app.store().get_transactions(product_id) +} + +#[command] +pub(crate) async fn restore_purchases( + app: AppHandle, +) -> Result { + app.store().restore_purchases() +} + +#[command] +pub(crate) async fn get_subscription_status( + app: AppHandle, + product_id: String, +) -> Result { + app.store().get_subscription_status(product_id) } \ No newline at end of file diff --git a/plugins/store/src/lib.rs b/plugins/store/src/lib.rs index 7048ffd5..edf9b81d 100644 --- a/plugins/store/src/lib.rs +++ b/plugins/store/src/lib.rs @@ -32,7 +32,15 @@ impl> crate::StoreExt for T { /// Initializes the plugin. pub fn init() -> TauriPlugin { Builder::new("store") - .invoke_handler(tauri::generate_handler![commands::get_region]) + .invoke_handler(tauri::generate_handler![ + commands::get_region, + commands::get_products, + commands::purchase, + commands::verify_purchase, + commands::get_transactions, + commands::restore_purchases, + commands::get_subscription_status + ]) .setup(|app, api| { #[cfg(feature = "mobile")] let store = mobile::init(app, api)?; diff --git a/plugins/store/src/mobile.rs b/plugins/store/src/mobile.rs index 403aa963..445dc413 100644 --- a/plugins/store/src/mobile.rs +++ b/plugins/store/src/mobile.rs @@ -1,4 +1,5 @@ use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use tauri::{ plugin::{PluginApi, PluginHandle}, AppHandle, Runtime, @@ -28,11 +29,194 @@ pub fn init( /// Access to the store APIs. pub struct Store(PluginHandle); +// Transaction response type +#[derive(Debug, Serialize, Deserialize)] +pub struct Transaction { + pub id: u64, + #[serde(rename = "originalId")] + pub original_id: Option, + #[serde(rename = "productId")] + pub product_id: String, + #[serde(rename = "purchaseDate")] + pub purchase_date: i64, + #[serde(rename = "expirationDate")] + pub expiration_date: Option, + #[serde(rename = "webOrderLineItemId")] + pub web_order_line_item_id: String, + pub quantity: i32, + pub r#type: String, + #[serde(rename = "ownershipType")] + pub ownership_type: String, + #[serde(rename = "signedDate")] + pub signed_date: i64, +} + +// Product response type +#[derive(Debug, Serialize, Deserialize)] +pub struct Product { + pub id: String, + pub title: String, + pub description: String, + pub price: String, + #[serde(rename = "priceValue")] + pub price_value: f64, + #[serde(rename = "currencyCode")] + pub currency_code: String, + pub r#type: String, + #[serde(rename = "subscriptionPeriod", default, skip_serializing_if = "Option::is_none")] + pub subscription_period: Option, + #[serde(rename = "introductoryOffer", default, skip_serializing_if = "Option::is_none")] + pub introductory_offer: Option, + #[serde(rename = "promotionalOffers", default, skip_serializing_if = "Option::is_none")] + pub promotional_offers: Option>, +} + +// Subscription period type +#[derive(Debug, Serialize, Deserialize)] +pub struct SubscriptionPeriod { + pub unit: String, + pub value: i32, +} + +// Subscription offer type +#[derive(Debug, Serialize, Deserialize)] +pub struct SubscriptionOffer { + pub id: String, + #[serde(rename = "displayPrice")] + pub display_price: String, + pub period: SubscriptionPeriod, + #[serde(rename = "paymentMode")] + pub payment_mode: String, + pub r#type: String, + #[serde(rename = "discountType", default, skip_serializing_if = "Option::is_none")] + pub discount_type: Option, + #[serde(rename = "discountPrice", default, skip_serializing_if = "Option::is_none")] + pub discount_price: Option, +} + +// Purchase result type +#[derive(Debug, Serialize, Deserialize)] +pub struct PurchaseResult { + pub status: String, + #[serde(rename = "transactionId", default, skip_serializing_if = "Option::is_none")] + pub transaction_id: Option, + #[serde(rename = "originalTransactionId", default, skip_serializing_if = "Option::is_none")] + pub original_transaction_id: Option, + #[serde(rename = "productId", default, skip_serializing_if = "Option::is_none")] + pub product_id: Option, + #[serde(rename = "purchaseDate", default, skip_serializing_if = "Option::is_none")] + pub purchase_date: Option, + #[serde(rename = "expirationDate", default, skip_serializing_if = "Option::is_none")] + pub expiration_date: Option, + #[serde(rename = "webOrderLineItemId", default, skip_serializing_if = "Option::is_none")] + pub web_order_line_item_id: Option, + #[serde(rename = "quantity", default, skip_serializing_if = "Option::is_none")] + pub quantity: Option, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(rename = "ownershipType", default, skip_serializing_if = "Option::is_none")] + pub ownership_type: Option, + #[serde(rename = "signedDate", default, skip_serializing_if = "Option::is_none")] + pub signed_date: Option, + #[serde(rename = "environment", default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +// Verification result type +#[derive(Debug, Serialize, Deserialize)] +pub struct VerificationResult { + #[serde(rename = "isValid")] + pub is_valid: bool, + #[serde(rename = "expirationDate", default, skip_serializing_if = "Option::is_none")] + pub expiration_date: Option, + #[serde(rename = "purchaseDate", default, skip_serializing_if = "Option::is_none")] + pub purchase_date: Option, +} + +// Restore purchases result type +#[derive(Debug, Serialize, Deserialize)] +pub struct RestorePurchasesResult { + pub status: String, + pub transactions: Vec, +} + +// Subscription status type +#[derive(Debug, Serialize, Deserialize)] +pub struct SubscriptionStatus { + #[serde(rename = "productId")] + pub product_id: String, + pub status: String, + #[serde(rename = "willAutoRenew")] + pub will_auto_renew: bool, + #[serde(rename = "expirationDate", default, skip_serializing_if = "Option::is_none")] + pub expiration_date: Option, + #[serde(rename = "gracePeriodExpirationDate", default, skip_serializing_if = "Option::is_none")] + pub grace_period_expiration_date: Option, +} + impl Store { + // Get region code from the App Store pub fn get_region(&self) -> crate::Result { self.0 .run_mobile_plugin("getRegion", ()) .map_err(Into::into) } -} + // Get products from the App Store + pub fn get_products(&self, product_ids: Vec) -> crate::Result> { + self.0 + .run_mobile_plugin("getProducts", serde_json::json!({ "productIds": product_ids })) + .map_err(Into::into) + } + + // Make a purchase + pub fn purchase(&self, product_id: String) -> crate::Result { + self.0 + .run_mobile_plugin("purchase", serde_json::json!({ "productId": product_id })) + .map_err(Into::into) + } + + // Verify a purchase + pub fn verify_purchase(&self, product_id: String, transaction_id: u64) -> crate::Result { + self.0 + .run_mobile_plugin( + "verifyPurchase", + serde_json::json!({ + "productId": product_id, + "transactionId": transaction_id + }), + ) + .map_err(Into::into) + } + + // Get all transactions, optionally filtered by product ID + pub fn get_transactions(&self, product_id: Option) -> crate::Result> { + let args = match product_id { + Some(id) => serde_json::json!({ "productId": id }), + None => serde_json::json!({}), + }; + + self.0 + .run_mobile_plugin("getTransactions", args) + .map_err(Into::into) + } + + // Restore purchases + pub fn restore_purchases(&self) -> crate::Result { + self.0 + .run_mobile_plugin("restorePurchases", ()) + .map_err(Into::into) + } + + // Get subscription status + pub fn get_subscription_status(&self, product_id: String) -> crate::Result { + self.0 + .run_mobile_plugin( + "getSubscriptionStatus", + serde_json::json!({ "productId": product_id }), + ) + .map_err(Into::into) + } +} \ No newline at end of file