From 31f0285952567234448e3a5656b74473a777a8a6 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 22:32:48 +0900 Subject: [PATCH 1/3] fix(apple): use compiler guards Compile StoreKit feature paths based on the Swift compiler toolchain instead of the target Swift language mode so Swift 5 language-mode apps do not strip newer StoreKit code when built with capable Xcode versions. Also align the newly compiled StoreKit 2 API paths with the Xcode 26.5 SDK signatures and add a regression test for compiler guards. Closes #216 --- .../Sources/Helpers/StoreKitTypesBridge.swift | 173 +++++++++++------- packages/apple/Sources/OpenIapModule.swift | 10 +- .../OpenIapTests/CompilerGuardTests.swift | 30 +++ 3 files changed, 143 insertions(+), 70 deletions(-) create mode 100644 packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index c83bee127..fb1f84e78 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -269,24 +269,7 @@ enum StoreKitTypesBridge { // Only return pendingUpgradeProductId if it's different from current return info.autoRenewPreference != current ? info.autoRenewPreference : nil }() - let offerInfo: (id: String?, type: String?)? - #if swift(>=6.1) - if #available(iOS 18.0, macOS 15.0, *) { - // Map type only when present to avoid "nil" literal strings - let offerTypeString = info.offer.map { String(describing: $0.type) } - offerInfo = (id: info.offer?.id, type: offerTypeString) - } else { - #endif - // Fallback to deprecated properties - #if compiler(>=5.9) - let offerTypeString = info.offerType.map { String(describing: $0) } - offerInfo = (id: info.offerID, type: offerTypeString) - #else - offerInfo = nil - #endif - #if swift(>=6.1) - } - #endif + let offerInfo = renewalOfferInfoIOS(from: info) // priceIncreaseStatus only available on iOS 15.0+ let priceIncrease: String? = { if #available(iOS 15.0, macOS 12.0, *) { @@ -323,24 +306,7 @@ enum StoreKitTypesBridge { guard let current = currentProductId else { return nil } return info.autoRenewPreference != current ? info.autoRenewPreference : nil }() - let offerInfo: (id: String?, type: String?)? - #if swift(>=6.1) - if #available(iOS 18.0, macOS 15.0, *) { - // Map type only when present to avoid "nil" literal strings - let offerTypeString = info.offer.map { String(describing: $0.type) } - offerInfo = (id: info.offer?.id, type: offerTypeString) - } else { - #endif - // Fallback to deprecated properties - #if compiler(>=5.9) - let offerTypeString = info.offerType.map { String(describing: $0) } - offerInfo = (id: info.offerID, type: offerTypeString) - #else - offerInfo = nil - #endif - #if swift(>=6.1) - } - #endif + let offerInfo = renewalOfferInfoIOS(from: info) // priceIncreaseStatus only available on iOS 15.0+ let priceIncrease: String? = { if #available(iOS 15.0, macOS 12.0, *) { @@ -408,7 +374,7 @@ enum StoreKitTypesBridge { if let subscriptionProps = props as? RequestSubscriptionIosProps { // Billing plan selection for annual subscriptions with monthly commitment (iOS 26.4+) if let billingPlanType = subscriptionProps.billingPlanType { - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { switch billingPlanType { case .monthly: @@ -433,7 +399,7 @@ enum StoreKitTypesBridge { throw PurchaseError.make( code: .featureNotSupported, productId: props.sku, - message: "billingPlanType requires Xcode 26.4+ / Swift 6.3+." + message: "billingPlanType requires Xcode 26.4+ / Swift 6.3 compiler+." ) #endif } @@ -487,38 +453,43 @@ enum StoreKitTypesBridge { // JWS Promotional Offer (iOS 15+, WWDC 2025) // New signature format using compact JWS string for promotional offers - // Back-deployed to iOS 15, but requires Xcode 16.4+ / Swift 6.1+ to compile + // Back-deployed to iOS 15, but requires Xcode 26+ / Swift 6.3 compiler+ to compile. if let jwsOffer = subscriptionProps.promotionalOfferJWS { - #if swift(>=6.1) - // Swift 6.1+ implementation - options.insert(.promotionalOffer(compactJWS: jwsOffer.jws)) + #if compiler(>=6.3) + // Swift 6.3 compiler+ implementation + options.formUnion( + StoreKit.Product.PurchaseOption.promotionalOffer( + jwsOffer.offerId, + compactJWS: jwsOffer.jws + ) + ) OpenIapLog.debug("✅ Added JWS promotional offer: \(jwsOffer.offerId)") #else - // Swift < 6.1: API not available, throw error to fail fast - OpenIapLog.error("❌ JWS promotional offers require Xcode 16.4+ / Swift 6.1+") + // Swift compiler < 6.3: API not available, throw error to fail fast. + OpenIapLog.error("❌ JWS promotional offers require Xcode 26+ / Swift 6.3 compiler+") throw PurchaseError.make( code: .developerError, productId: props.sku, - message: "JWS promotional offers require Xcode 16.4+ / Swift 6.1+. Use withOffer with signature-based promotional offers instead." + message: "JWS promotional offers require Xcode 26+ / Swift 6.3 compiler+. Use withOffer with signature-based promotional offers instead." ) #endif } // Introductory Offer Eligibility Override (iOS 15+, WWDC 2025) // Allows overriding the system's eligibility check with a server-signed JWS - // Back-deployed to iOS 15, but requires Xcode 16.4+ / Swift 6.1+ to compile + // Back-deployed to iOS 15, but requires Xcode 16.4+ / Swift 6.1 compiler+ to compile. if let compactJWS = subscriptionProps.compactJWS { - #if swift(>=6.1) - // Swift 6.1+ implementation + #if compiler(>=6.1) + // Swift 6.1 compiler+ implementation options.insert(.introductoryOfferEligibility(compactJWS: compactJWS)) OpenIapLog.debug("✅ Added introductory offer eligibility override") #else - // Swift < 6.1: API not available, throw error to fail fast - OpenIapLog.error("❌ Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1+") + // Swift compiler < 6.1: API not available, throw error to fail fast. + OpenIapLog.error("❌ Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1 compiler+") throw PurchaseError.make( code: .developerError, productId: props.sku, - message: "Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1+. The system will determine eligibility automatically." + message: "Introductory offer eligibility override requires Xcode 16.4+ / Swift 6.1 compiler+. The system will determine eligibility automatically." ) #endif } @@ -560,8 +531,24 @@ enum StoreKitTypesBridge { #endif } + static func renewalOfferInfoIOS(from info: StoreKit.Product.SubscriptionInfo.RenewalInfo) -> (id: String?, type: String?)? { + #if compiler(>=6.1) + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + let offerTypeString = info.offer.map { String(describing: $0.type) } + return (id: info.offer?.id, type: offerTypeString) + } + #endif + + #if compiler(>=5.9) + let offerTypeString = info.offerType.map { String(describing: $0) } + return (id: info.offerID, type: offerTypeString) + #else + return nil + #endif + } + static func renewalCommitmentInfoIOS(from info: StoreKit.Product.SubscriptionInfo.RenewalInfo) -> RenewalCommitmentInfoIOS? { - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { guard let commitment = info.commitmentInfo else { return nil } return RenewalCommitmentInfoIOS( @@ -585,7 +572,7 @@ enum StoreKitTypesBridge { } static func renewalBillingPlanTypeIOS(from info: StoreKit.Product.SubscriptionInfo.RenewalInfo) -> SubscriptionBillingPlanTypeIOS? { - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { return info.renewalBillingPlanType.map { billingPlanTypeIOS(from: $0) } } @@ -633,7 +620,7 @@ private extension StoreKitTypesBridge { static func makeSubscriptionPricingTerms(from info: StoreKit.Product.SubscriptionInfo?) -> [SubscriptionPricingTermsIOS]? { guard let info else { return nil } - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { let terms = info.pricingTerms.map { makeSubscriptionPricingTerm(from: $0) } return terms.isEmpty ? nil : terms @@ -642,7 +629,7 @@ private extension StoreKitTypesBridge { return nil } - #if swift(>=6.3) + #if compiler(>=6.3) @available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) static func makeSubscriptionPricingTerm(from terms: StoreKit.Product.SubscriptionInfo.PricingTerms) -> SubscriptionPricingTermsIOS { let offers = terms.subscriptionOffers.map { offer in @@ -669,7 +656,7 @@ private extension StoreKitTypesBridge { } #endif - #if swift(>=6.3) + #if compiler(>=6.3) @available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) static func billingPlanTypeIOS(from type: StoreKit.Product.SubscriptionInfo.BillingPlanType) -> SubscriptionBillingPlanTypeIOS { if type == .monthly { @@ -683,7 +670,7 @@ private extension StoreKitTypesBridge { #endif static func billingPlanTypeIOS(from transaction: StoreKit.Transaction) -> SubscriptionBillingPlanTypeIOS? { - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { return transaction.billingPlanType.map { billingPlanTypeIOS(from: $0) } } @@ -692,7 +679,7 @@ private extension StoreKitTypesBridge { } static func transactionCommitmentInfoIOS(from transaction: StoreKit.Transaction) -> TransactionCommitmentInfoIOS? { - #if swift(>=6.3) + #if compiler(>=6.3) if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { guard let commitment = transaction.commitmentInfo else { return nil } return TransactionCommitmentInfoIOS( @@ -756,7 +743,7 @@ private extension StoreKitTypesBridge { } static func discountOfferType(from offer: StoreKit.Product.SubscriptionOffer) -> DiscountOfferType { - #if swift(>=6.1) + #if compiler(>=6.1) if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { return offer.type == .introductory ? .introductory : .promotional } @@ -1059,16 +1046,16 @@ private extension StoreKitTypesBridge { // MARK: - Advanced Commerce Info (iOS 18.4+) static func extractAdvancedCommerceInfo(from transaction: StoreKit.Transaction) -> AdvancedCommerceInfoIOS? { - #if swift(>=6.1) + #if compiler(>=6.1) if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { guard let info = transaction.advancedCommerceInfo else { return nil } let items: [AdvancedCommerceItemIOS] = info.items.map { item in let details = AdvancedCommerceItemDetailsIOS( - jsonRepresentation: String(data: item.details.jsonRepresentation, encoding: .utf8) + jsonRepresentation: advancedCommerceDetailsJSON(from: item.details) ) let refunds: [AdvancedCommerceRefundIOS]? = item.refunds?.map { refund in AdvancedCommerceRefundIOS( - jsonRepresentation: String(data: refund.jsonRepresentation, encoding: .utf8) + jsonRepresentation: advancedCommerceRefundJSON(from: refund) ) } return AdvancedCommerceItemIOS( @@ -1080,17 +1067,73 @@ private extension StoreKitTypesBridge { return AdvancedCommerceInfoIOS( description: info.description, displayName: info.displayName, - estimatedTax: info.estimatedTax.map { "\($0)" }, + estimatedTax: decimalString(info.estimatedTax), items: items, requestReferenceId: info.requestReferenceID, taxCode: info.taxCode, - taxExclusivePrice: info.taxExclusivePrice.map { "\($0)" }, - taxRate: info.taxRate.map { "\($0)" } + taxExclusivePrice: decimalString(info.taxExclusivePrice), + taxRate: decimalString(info.taxRate) ) } #endif return nil } + + static func decimalString(_ value: Decimal) -> String { + NSDecimalNumber(decimal: value).stringValue + } + + #if compiler(>=6.1) + @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) + static func advancedCommerceDetailsJSON(from details: StoreKit.Transaction.AdvancedCommerceInfo.Item.Details) -> String? { + let offer = details.offer.map { offer in + [ + "period": offer.period.iso8601, + "periodCount": offer.periodCount, + "price": decimalString(offer.price), + "reason": offer.reason.rawValue, + ] as [String: Any?] + } + + return compactJSONString(from: [ + "description": details.description, + "displayName": details.displayName, + "offer": offer, + "price": decimalString(details.price), + "sku": details.sku, + ]) + } + + @available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) + static func advancedCommerceRefundJSON(from refund: StoreKit.Transaction.AdvancedCommerceInfo.Refund) -> String? { + compactJSONString(from: [ + "amount": decimalString(refund.amount), + "date": refund.date.milliseconds, + "reason": refund.reason.rawValue, + "type": refund.type.rawValue, + ]) + } + #endif + + static func compactJSONString(from object: [String: Any?]) -> String? { + let compacted = compactJSONDictionary(object) + guard JSONSerialization.isValidJSONObject(compacted), + let data = try? JSONSerialization.data(withJSONObject: compacted, options: [.sortedKeys]) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + static func compactJSONDictionary(_ object: [String: Any?]) -> [String: Any] { + object.reduce(into: [String: Any]()) { result, entry in + guard let value = entry.value else { return } + if let dictionary = value as? [String: Any?] { + result[entry.key] = compactJSONDictionary(dictionary) + } else { + result[entry.key] = value + } + } + } } @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index ddbc78e99..c6c4ae821 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -2129,18 +2129,18 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let appVersion = transaction.appVersion let appId = transaction.appID.map(Double.init) ?? 0 - // iOS 18.4+ properties - only compile with Xcode 16.4+ (Swift 6.1+) + // iOS 18.4+ properties - only compile with Xcode 16.4+ (Swift 6.1 compiler+) // This prevents build failures on Xcode 16.3 and below var appTransactionId: String? = nil var originalPlatformValue: String? = nil - // Swift 6.1+ (Xcode 16.4+): AppTransaction.appTransactionID and originalPlatform available - #if swift(>=6.1) - if #available(iOS 18.4, *) { + // Swift 6.1 compiler+ (Xcode 16.4+): AppTransaction.appTransactionID and originalPlatform available + #if compiler(>=6.1) + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { appTransactionId = String(transaction.appTransactionID) originalPlatformValue = transaction.originalPlatform.rawValue } - #endif // swift(>=6.1) + #endif // compiler(>=6.1) return AppTransaction( appId: appId, diff --git a/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift b/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift new file mode 100644 index 000000000..5eefabc97 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/CompilerGuardTests.swift @@ -0,0 +1,30 @@ +import XCTest + +final class CompilerGuardTests: XCTestCase { + func testStoreKitFeatureGuardsUseCompilerVersion() throws { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceFiles = [ + "Sources/Helpers/StoreKitTypesBridge.swift", + "Sources/OpenIapModule.swift", + ] + let forbiddenGuard = "#if " + "swi" + "ft(>=" + let forbiddenEndifComment = "#endif // " + "swi" + "ft(" + + for sourceFile in sourceFiles { + let sourceURL = packageRoot.appendingPathComponent(sourceFile) + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + XCTAssertFalse( + source.contains(forbiddenGuard), + "\(sourceFile) must use compiler(>=...) for StoreKit SDK feature guards so Swift 5 language-mode builds do not strip modern toolchain code." + ) + XCTAssertFalse( + source.contains(forbiddenEndifComment), + "\(sourceFile) must not leave stale swift(...) conditional comments behind." + ) + } + } +} From a7630d13a5a262efeedf80e885cada9a63f60e5e Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 22:52:47 +0900 Subject: [PATCH 2/3] fix(apple): address StoreKit review feedback --- .../Sources/Helpers/StoreKitTypesBridge.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index fb1f84e78..784e1dc2d 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -534,17 +534,19 @@ enum StoreKitTypesBridge { static func renewalOfferInfoIOS(from info: StoreKit.Product.SubscriptionInfo.RenewalInfo) -> (id: String?, type: String?)? { #if compiler(>=6.1) if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { - let offerTypeString = info.offer.map { String(describing: $0.type) } - return (id: info.offer?.id, type: offerTypeString) + if let offer = info.offer { + return (id: offer.id, type: String(describing: offer.type)) + } } #endif #if compiler(>=5.9) let offerTypeString = info.offerType.map { String(describing: $0) } - return (id: info.offerID, type: offerTypeString) - #else - return nil + if info.offerID != nil || offerTypeString != nil { + return (id: info.offerID, type: offerTypeString) + } #endif + return nil } static func renewalCommitmentInfoIOS(from info: StoreKit.Product.SubscriptionInfo.RenewalInfo) -> RenewalCommitmentInfoIOS? { @@ -1129,6 +1131,8 @@ private extension StoreKitTypesBridge { guard let value = entry.value else { return } if let dictionary = value as? [String: Any?] { result[entry.key] = compactJSONDictionary(dictionary) + } else if let array = value as? [[String: Any?]] { + result[entry.key] = array.map { compactJSONDictionary($0) } } else { result[entry.key] = value } From d5b3a88a97575264d554501226e90225b3947fb9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:00:54 +0900 Subject: [PATCH 3/3] fix(apple): handle optional commerce decimals --- packages/apple/Sources/Helpers/StoreKitTypesBridge.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index 784e1dc2d..9d3e1ab1b 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -1081,8 +1081,9 @@ private extension StoreKitTypesBridge { return nil } - static func decimalString(_ value: Decimal) -> String { - NSDecimalNumber(decimal: value).stringValue + static func decimalString(_ value: Decimal?) -> String? { + guard let value else { return nil } + return NSDecimalNumber(decimal: value).stringValue } #if compiler(>=6.1)