From 63cd18dd9ed827d9fcdfebf8f9d8740bb55ce88d Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Tue, 10 Jun 2025 20:38:39 +0300 Subject: [PATCH 1/3] Add in-app purchase methods --- client/cmake/ios.cmake | 2 + client/platforms/ios/StoreKitController.h | 21 ++++ client/platforms/ios/StoreKitController.mm | 129 +++++++++++++++++++++ client/platforms/ios/ios_controller.h | 3 + client/platforms/ios/ios_controller.mm | 31 +++++ 5 files changed, 186 insertions(+) create mode 100644 client/platforms/ios/StoreKitController.h create mode 100644 client/platforms/ios/StoreKitController.mm diff --git a/client/cmake/ios.cmake b/client/cmake/ios.cmake index 58192237..e21b2ab1 100644 --- a/client/cmake/ios.cmake +++ b/client/cmake/ios.cmake @@ -34,6 +34,7 @@ set(HEADERS ${HEADERS} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h ) set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h PROPERTIES OBJECTIVE_CPP_HEADER TRUE) @@ -46,6 +47,7 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm ) diff --git a/client/platforms/ios/StoreKitController.h b/client/platforms/ios/StoreKitController.h new file mode 100644 index 00000000..d8d73c46 --- /dev/null +++ b/client/platforms/ios/StoreKitController.h @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef STOREKITCONTROLLER_H +#define STOREKITCONTROLLER_H + +#import + +@interface StoreKitController : NSObject + ++ (instancetype)sharedInstance; + +- (void)purchaseProduct:(NSString *)productIdentifier + completion:(void (^)(BOOL success, NSError *_Nullable error))completion; + +- (void)restorePurchasesWithCompletion:(void (^)(BOOL success, NSError *_Nullable error))completion; + +@end + +#endif // STOREKITCONTROLLER_H diff --git a/client/platforms/ios/StoreKitController.mm b/client/platforms/ios/StoreKitController.mm new file mode 100644 index 00000000..42eb4eb6 --- /dev/null +++ b/client/platforms/ios/StoreKitController.mm @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#import "StoreKitController.h" +#import + +@interface StoreKitController () +@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, NSError *_Nullable error); +@property (nonatomic, copy) void (^restoreCompletion)(BOOL success, NSError *_Nullable error); +@property (nonatomic, strong) SKProductsRequest *productsRequest; +@end + +@implementation StoreKitController + ++ (instancetype)sharedInstance +{ + static dispatch_once_t onceToken; + static StoreKitController *instance; + dispatch_once(&onceToken, ^{ + instance = [[StoreKitController alloc] init]; + }); + return instance; +} + +- (instancetype)init +{ + self = [super init]; + if (self) { + [[SKPaymentQueue defaultQueue] addTransactionObserver:self]; + } + return self; +} + +- (void)dealloc +{ + [[SKPaymentQueue defaultQueue] removeTransactionObserver:self]; +} + +- (void)purchaseProduct:(NSString *)productIdentifier + completion:(void (^)(BOOL success, NSError *_Nullable error))completion +{ + self.purchaseCompletion = completion; + self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]]; + self.productsRequest.delegate = self; + [self.productsRequest start]; +} + +- (void)restorePurchasesWithCompletion:(void (^)(BOOL success, NSError *_Nullable error))completion +{ + self.restoreCompletion = completion; + [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; +} + +#pragma mark - SKProductsRequestDelegate + +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response +{ + SKProduct *product = response.products.firstObject; + if (!product) { + if (self.purchaseCompletion) { + NSError *error = [NSError errorWithDomain:@"StoreKitController" + code:0 + userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }]; + self.purchaseCompletion(NO, error); + self.purchaseCompletion = nil; + } + return; + } + SKPayment *payment = [SKPayment paymentWithProduct:product]; + [[SKPaymentQueue defaultQueue] addPayment:payment]; + self.productsRequest = nil; +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ + if (self.purchaseCompletion) { + self.purchaseCompletion(NO, error); + self.purchaseCompletion = nil; + } + self.productsRequest = nil; +} + +#pragma mark - SKPaymentTransactionObserver + +- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions +{ + for (SKPaymentTransaction *transaction in transactions) { + switch (transaction.transactionState) { + case SKPaymentTransactionStatePurchased: + if (self.purchaseCompletion) { + self.purchaseCompletion(YES, nil); + self.purchaseCompletion = nil; + } + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + self.productsRequest = nil; + break; + case SKPaymentTransactionStateFailed: + if (self.purchaseCompletion) { + self.purchaseCompletion(NO, transaction.error); + self.purchaseCompletion = nil; + } + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + self.productsRequest = nil; + break; + case SKPaymentTransactionStateRestored: [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; break; + case SKPaymentTransactionStatePurchasing: + case SKPaymentTransactionStateDeferred: break; + } + } +} + +- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue +{ + if (self.restoreCompletion) { + self.restoreCompletion(YES, nil); + self.restoreCompletion = nil; + } +} + +- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error +{ + if (self.restoreCompletion) { + self.restoreCompletion(NO, error); + self.restoreCompletion = nil; + } +} + +@end diff --git a/client/platforms/ios/ios_controller.h b/client/platforms/ios/ios_controller.h index 85580769..abcf0782 100644 --- a/client/platforms/ios/ios_controller.h +++ b/client/platforms/ios/ios_controller.h @@ -54,6 +54,9 @@ public: bool shareText(const QStringList &filesToSend); QString openFile(); + bool purchaseProduct(const QString &productId); + bool restorePurchases(); + void requestInetAccess(); signals: void connectionStateChanged(Vpn::ConnectionState state); diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 85fb50b7..207b6181 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -10,6 +10,7 @@ #include "../protocols/vpnprotocol.h" #import "ios_controller_wrapper.h" +#import "StoreKitController.h" const char* Action::start = "start"; const char* Action::restart = "restart"; @@ -845,6 +846,36 @@ QString IosController::openFile() { return filePath; } +bool IosController::purchaseProduct(const QString &productId) +{ + __block BOOL success = NO; + StoreKitController *controller = [StoreKitController sharedInstance]; + QEventLoop wait; + [controller purchaseProduct:productId.toNSString() completion:^(BOOL s, NSError * _Nullable error) { + Q_UNUSED(error); + success = s; + emit finished(); + }]; + QObject::connect(this, &IosController::finished, &wait, &QEventLoop::quit); + wait.exec(); + return success; +} + +bool IosController::restorePurchases() +{ + __block BOOL success = NO; + StoreKitController *controller = [StoreKitController sharedInstance]; + QEventLoop wait; + [controller restorePurchasesWithCompletion:^(BOOL s, NSError * _Nullable error) { + Q_UNUSED(error); + success = s; + emit finished(); + }]; + QObject::connect(this, &IosController::finished, &wait, &QEventLoop::quit); + wait.exec(); + return success; +} + void IosController::requestInetAccess() { NSURL *url = [NSURL URLWithString:@"http://captive.apple.com/generate_204"]; if (!url) { From 95aad7ac820269c6984412039d0829ed0acdb924 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Sun, 15 Jun 2025 19:32:06 +0300 Subject: [PATCH 2/3] fix: init StoreKit controller on startup --- client/platforms/ios/ios_controller.mm | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 207b6181..20d88aeb 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -66,6 +66,9 @@ IosController::IosController() : QObject() s_instance = this; m_iosControllerWrapper = [[IosControllerWrapper alloc] initWithCppController:this]; + // Initialize StoreKitController early to start observing the payment queue + [StoreKitController sharedInstance]; + [[NSNotificationCenter defaultCenter] removeObserver: (__bridge NSObject *)m_iosControllerWrapper]; [[NSNotificationCenter defaultCenter] From 15607f0beb5aae3bc3f3e90fd4c776699f7b2b3d Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Mon, 16 Jun 2025 19:05:18 +0300 Subject: [PATCH 3/3] fix: Add transaction details to StoreKit callbacks --- client/platforms/ios/StoreKitController.h | 5 ++- client/platforms/ios/StoreKitController.mm | 24 +++++++--- client/platforms/ios/ios_controller.h | 10 ++++- client/platforms/ios/ios_controller.mm | 52 ++++++++++++++-------- 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/client/platforms/ios/StoreKitController.h b/client/platforms/ios/StoreKitController.h index d8d73c46..7a29d11b 100644 --- a/client/platforms/ios/StoreKitController.h +++ b/client/platforms/ios/StoreKitController.h @@ -12,7 +12,10 @@ + (instancetype)sharedInstance; - (void)purchaseProduct:(NSString *)productIdentifier - completion:(void (^)(BOOL success, NSError *_Nullable error))completion; + completion:(void (^)(BOOL success, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSError *_Nullable error))completion; - (void)restorePurchasesWithCompletion:(void (^)(BOOL success, NSError *_Nullable error))completion; diff --git a/client/platforms/ios/StoreKitController.mm b/client/platforms/ios/StoreKitController.mm index 42eb4eb6..8edaae79 100644 --- a/client/platforms/ios/StoreKitController.mm +++ b/client/platforms/ios/StoreKitController.mm @@ -6,7 +6,10 @@ #import @interface StoreKitController () -@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, NSError *_Nullable error); +@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSError *_Nullable error); @property (nonatomic, copy) void (^restoreCompletion)(BOOL success, NSError *_Nullable error); @property (nonatomic, strong) SKProductsRequest *productsRequest; @end @@ -38,7 +41,10 @@ } - (void)purchaseProduct:(NSString *)productIdentifier - completion:(void (^)(BOOL success, NSError *_Nullable error))completion + completion:(void (^)(BOOL success, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSError *_Nullable error))completion { self.purchaseCompletion = completion; self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]]; @@ -62,7 +68,7 @@ NSError *error = [NSError errorWithDomain:@"StoreKitController" code:0 userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }]; - self.purchaseCompletion(NO, error); + self.purchaseCompletion(NO, nil, nil, error); self.purchaseCompletion = nil; } return; @@ -75,7 +81,7 @@ - (void)request:(SKRequest *)request didFailWithError:(NSError *)error { if (self.purchaseCompletion) { - self.purchaseCompletion(NO, error); + self.purchaseCompletion(NO, nil, nil, error); self.purchaseCompletion = nil; } self.productsRequest = nil; @@ -89,7 +95,10 @@ switch (transaction.transactionState) { case SKPaymentTransactionStatePurchased: if (self.purchaseCompletion) { - self.purchaseCompletion(YES, nil); + self.purchaseCompletion(YES, + transaction.transactionIdentifier, + transaction.payment.productIdentifier, + nil); self.purchaseCompletion = nil; } [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; @@ -97,7 +106,10 @@ break; case SKPaymentTransactionStateFailed: if (self.purchaseCompletion) { - self.purchaseCompletion(NO, transaction.error); + self.purchaseCompletion(NO, + transaction.transactionIdentifier, + transaction.payment.productIdentifier, + transaction.error); self.purchaseCompletion = nil; } [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; diff --git a/client/platforms/ios/ios_controller.h b/client/platforms/ios/ios_controller.h index abcf0782..3d9ccb29 100644 --- a/client/platforms/ios/ios_controller.h +++ b/client/platforms/ios/ios_controller.h @@ -2,6 +2,7 @@ #define IOS_CONTROLLER_H #include "protocols/vpnprotocol.h" +#include #ifdef __OBJC__ #import @@ -54,8 +55,13 @@ public: bool shareText(const QStringList &filesToSend); QString openFile(); - bool purchaseProduct(const QString &productId); - bool restorePurchases(); + void purchaseProduct(const QString &productId, + std::function &&callback); + void restorePurchases(std::function &&callback); void requestInetAccess(); signals: diff --git a/client/platforms/ios/ios_controller.mm b/client/platforms/ios/ios_controller.mm index 20d88aeb..8a822346 100644 --- a/client/platforms/ios/ios_controller.mm +++ b/client/platforms/ios/ios_controller.mm @@ -849,34 +849,48 @@ QString IosController::openFile() { return filePath; } -bool IosController::purchaseProduct(const QString &productId) +void IosController::purchaseProduct(const QString &productId, + std::function &&callback) { - __block BOOL success = NO; StoreKitController *controller = [StoreKitController sharedInstance]; - QEventLoop wait; - [controller purchaseProduct:productId.toNSString() completion:^(BOOL s, NSError * _Nullable error) { - Q_UNUSED(error); - success = s; - emit finished(); + [controller purchaseProduct:productId.toNSString() completion:^(BOOL s, + NSString * _Nullable transactionId, + NSString * _Nullable prodId, + NSError * _Nullable error) { + QString txId; + QString pId; + QString err; + if (transactionId) { + txId = QString::fromUtf8(transactionId.UTF8String); + } + if (prodId) { + pId = QString::fromUtf8(prodId.UTF8String); + } + if (error) { + err = QString::fromUtf8(error.localizedDescription.UTF8String); + } + if (callback) { + callback(s, txId, pId, err); + } }]; - QObject::connect(this, &IosController::finished, &wait, &QEventLoop::quit); - wait.exec(); - return success; } -bool IosController::restorePurchases() +void IosController::restorePurchases(std::function &&callback) { - __block BOOL success = NO; StoreKitController *controller = [StoreKitController sharedInstance]; - QEventLoop wait; [controller restorePurchasesWithCompletion:^(BOOL s, NSError * _Nullable error) { - Q_UNUSED(error); - success = s; - emit finished(); + QString err; + if (error) { + err = QString::fromUtf8(error.localizedDescription.UTF8String); + } + if (callback) { + callback(s, err); + } }]; - QObject::connect(this, &IosController::finished, &wait, &QEventLoop::quit); - wait.exec(); - return success; } void IosController::requestInetAccess() {