From 6178b05643bcb2d3d4c1cbadecbfd0081019be0c Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 18 Dec 2025 16:36:12 +0200 Subject: [PATCH] feat: ios in-app purchase methods (#1652) * Add in-app purchase methods * fix: init StoreKit controller on startup * fix: Add transaction details to StoreKit callbacks * nullpointer access fixed * feat: in app purchase for ios * feat: add IAP product fetching and logging for iOS platform * feat: iOS Simulator building pipeline made * feat: add support for multiple IAP product IDs and attempt purchase of the first valid one * feat: add support for retrieving Base64-encoded app receipt after successful IAP purchase * refactor: inapp-purchase code cleanup * feat: iap processing * refactor: move to storekit 2 * feat: add request to billing * chore: add ios ifdef * feat: remove iOS simulator specific code and exclusions * refactor: remove unused StoreKit 2 transaction observer and simplify IAP product fetching logic * feat: implement StoreKit 2 for iOS and macOS, add restore purchases functionality * fix: Restore Purchases button appearance updated * feat: enhance error handling and duplicate config detection in ApiConfigsController * feat: add support for Mac OS NE in-app purchases and StoreKitController * ci-cd fix * Revert "ci-cd fix" This reverts commit f22fd7a13bb093205a81561e4e397d2075776646. --------- Co-authored-by: vladimir.kuznetsov Co-authored-by: vkamn Co-authored-by: spectrum --- client/cmake/ios.cmake | 2 + client/cmake/macos_ne.cmake | 2 + client/core/defs.h | 1 + client/core/errorstrings.cpp | 1 + client/platforms/ios/StoreKitController.h | 39 ++ client/platforms/ios/StoreKitController.mm | 264 +++++++++++++ client/platforms/ios/ios_controller.h | 21 ++ client/platforms/ios/ios_controller.mm | 133 +++++++ client/translations/amneziavpn_ar_EG.ts | 5 + client/translations/amneziavpn_fa_IR.ts | 5 + client/translations/amneziavpn_hi_IN.ts | 5 + client/translations/amneziavpn_my_MM.ts | 5 + client/translations/amneziavpn_ru_RU.ts | 5 + client/translations/amneziavpn_uk_UA.ts | 5 + client/translations/amneziavpn_ur_PK.ts | 5 + client/translations/amneziavpn_zh_CN.ts | 5 + .../controllers/api/apiConfigsController.cpp | 352 +++++++++++++++++- .../ui/controllers/api/apiConfigsController.h | 3 + client/ui/models/servers_model.cpp | 26 ++ client/ui/models/servers_model.h | 1 + .../Pages2/PageSetupWizardApiServiceInfo.qml | 20 +- .../Pages2/PageSetupWizardConfigSource.qml | 19 +- 22 files changed, 910 insertions(+), 14 deletions(-) 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 ab820c712..b605de484 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 ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm ) diff --git a/client/cmake/macos_ne.cmake b/client/cmake/macos_ne.cmake index 90876a354..749053757 100644 --- a/client/cmake/macos_ne.cmake +++ b/client/cmake/macos_ne.cmake @@ -35,6 +35,7 @@ set(HEADERS ${HEADERS} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h ) @@ -45,6 +46,7 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm + ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm ${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm diff --git a/client/core/defs.h b/client/core/defs.h index 75dd26afd..f5d602104 100644 --- a/client/core/defs.h +++ b/client/core/defs.h @@ -121,6 +121,7 @@ namespace amnezia ApiMigrationError = 1110, ApiUpdateRequestError = 1111, ApiSubscriptionExpiredError = 1112, + ApiPurchaseError = 1113, // QFile errors OpenError = 1200, diff --git a/client/core/errorstrings.cpp b/client/core/errorstrings.cpp index c29187fb0..10449640e 100644 --- a/client/core/errorstrings.cpp +++ b/client/core/errorstrings.cpp @@ -78,6 +78,7 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiMigrationError): errorMessage = QObject::tr("A migration error has occurred. Please contact our technical support"); break; case (ErrorCode::ApiUpdateRequestError): errorMessage = QObject::tr("Please update the application to use this feature"); break; case (ErrorCode::ApiSubscriptionExpiredError): errorMessage = QObject::tr("Your Amnezia Premium subscription has expired.\n Please check your email for renewal instructions.\n If you haven't received an email, please contact our support."); break; + case (ErrorCode::ApiPurchaseError): errorMessage = QObject::tr("Unable to process purchase"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/platforms/ios/StoreKitController.h b/client/platforms/ios/StoreKitController.h new file mode 100644 index 000000000..ea621889d --- /dev/null +++ b/client/platforms/ios/StoreKitController.h @@ -0,0 +1,39 @@ +/* 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 +#import + +@class Product; +@class Transaction; +@class VerificationResult; + +API_AVAILABLE(ios(15.0), macos(12.0)) +@interface StoreKitController : NSObject + ++ (instancetype)sharedInstance; + +- (void)purchaseProduct:(NSString *)productIdentifier + completion:(void (^)(BOOL success, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSString *_Nullable originalTransactionId, + NSError *_Nullable error))completion; + +- (void)restorePurchasesWithCompletion:(void (^)(BOOL success, + NSArray *_Nullable restoredTransactions, + NSError *_Nullable error))completion; + +// Fetch product information for a set of identifiers without initiating a purchase +- (void)fetchProductsWithIdentifiers:(NSSet *)productIdentifiers + completion:(void (^)(NSArray *products, + NSArray *invalidIdentifiers, + 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 000000000..0a512d023 --- /dev/null +++ b/client/platforms/ios/StoreKitController.mm @@ -0,0 +1,264 @@ +/* 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 + +#include +#include + +API_AVAILABLE(ios(15.0), macos(12.0)) +@interface StoreKitController () +@property (nonatomic, copy) void (^purchaseCompletion)(BOOL success, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSString *_Nullable originalTransactionId, + NSError *_Nullable error); +@property (nonatomic, copy) void (^restoreCompletion)(BOOL success, + NSArray *_Nullable restoredTransactions, + NSError *_Nullable error); +@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray *products, + NSArray *invalidIdentifiers, + NSError *_Nullable error); +@property (nonatomic, strong) SKProductsRequest *productsRequest; +@property (nonatomic, strong) NSMutableArray *restoredTransactions; +@end + +@implementation StoreKitController + ++ (instancetype)sharedInstance +{ + static dispatch_once_t onceToken; + static StoreKitController *instance; + dispatch_once(&onceToken, ^{ + if (@available(iOS 15.0, macOS 12.0, *)) { + instance = [[StoreKitController alloc] init]; + } + }); + return instance; +} + +- (instancetype)init API_AVAILABLE(ios(15.0), macos(12.0)) +{ + 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, + NSString *_Nullable transactionId, + NSString *_Nullable productId, + NSString *_Nullable originalTransactionId, + NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) +{ + self.purchaseCompletion = completion; + + qInfo().noquote() << "[IAP][StoreKit] Starting purchase for" << QString::fromUtf8(productIdentifier.UTF8String); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self performPurchaseAsync:productIdentifier]; + }); +} + +- (void)performPurchaseAsync:(NSString *)productIdentifier API_AVAILABLE(ios(15.0), macos(12.0)) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + @try { + SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithObject:productIdentifier]]; + request.delegate = self; + [request start]; + + } @catch (NSException *exception) { + NSError *error = [NSError errorWithDomain:@"StoreKitController" + code:1 + userInfo:@{ NSLocalizedDescriptionKey : exception.reason ?: @"Purchase failed" }]; + if (self.purchaseCompletion) { + self.purchaseCompletion(NO, nil, nil, nil, error); + self.purchaseCompletion = nil; + } + } + }); +} + +- (void)restorePurchasesWithCompletion:(void (^)(BOOL success, + NSArray *_Nullable restoredTransactions, + NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) +{ + self.restoreCompletion = completion; + self.restoredTransactions = [NSMutableArray array]; + [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; +} + +- (void)fetchProductsWithIdentifiers:(NSSet *)productIdentifiers + completion:(void (^)(NSArray *products, + NSArray *invalidIdentifiers, + NSError *_Nullable error))completion API_AVAILABLE(ios(15.0), macos(12.0)) +{ + self.productsFetchCompletion = completion; + self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers]; + self.productsRequest.delegate = self; + [self.productsRequest start]; +} + +#pragma mark - SKProductsRequestDelegate / SKRequestDelegate + +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response +{ + if (self.purchaseCompletion) { + SKProduct *product = response.products.firstObject; + if (!product) { + NSError *error = [NSError errorWithDomain:@"StoreKitController" + code:0 + userInfo:@{ NSLocalizedDescriptionKey : @"Product not found" }]; + self.purchaseCompletion(NO, nil, nil, nil, error); + self.purchaseCompletion = nil; + self.productsRequest = nil; + return; + } + NSString *currencyCode = [product.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; + NSString *priceString = [product.price stringValue] ?: @""; + qInfo().noquote() << "[IAP][StoreKit] Received product" << QString::fromUtf8(product.productIdentifier.UTF8String) + << "price=" << QString::fromUtf8(priceString.UTF8String) + << "currency=" << QString::fromUtf8(currencyCode.UTF8String); + SKPayment *payment = [SKPayment paymentWithProduct:product]; + [[SKPaymentQueue defaultQueue] addPayment:payment]; + self.productsRequest = nil; + return; + } + + if (self.productsFetchCompletion) { + NSMutableArray *productDicts = [NSMutableArray array]; + for (SKProduct *p in response.products) { + NSDictionary *productDict = @{ + @"productId": p.productIdentifier, + @"title": p.localizedTitle, + @"description": p.localizedDescription, + @"price": p.price.stringValue, + @"currencyCode": [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @"" + }; + [productDicts addObject:productDict]; + NSString *productCurrency = [p.priceLocale objectForKey:NSLocaleCurrencyCode] ?: @""; + NSString *productPrice = [p.price stringValue] ?: @""; + qInfo().noquote() << "[IAP][StoreKit] Fetched product info" << QString::fromUtf8(p.productIdentifier.UTF8String) + << "price=" << QString::fromUtf8(productPrice.UTF8String) + << "currency=" << QString::fromUtf8(productCurrency.UTF8String); + } + + self.productsFetchCompletion(productDicts, response.invalidProductIdentifiers, nil); + self.productsFetchCompletion = nil; + self.productsRequest = nil; + return; + } +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error +{ + if (self.purchaseCompletion) { + self.purchaseCompletion(NO, nil, nil, nil, error); + self.purchaseCompletion = nil; + } + if (self.productsFetchCompletion) { + self.productsFetchCompletion(@[], @[], error); + self.productsFetchCompletion = nil; + } + self.productsRequest = nil; +} + +#pragma mark - SKPaymentTransactionObserver + +- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions +{ + for (SKPaymentTransaction *transaction in transactions) { + switch (transaction.transactionState) { + case SKPaymentTransactionStatePurchased: { + NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transaction.transactionIdentifier; + qInfo().noquote() << "[IAP][StoreKit] Transaction purchased" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) + << "original=" << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) + << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String); + + if (self.purchaseCompletion) { + self.purchaseCompletion(YES, + transaction.transactionIdentifier, + transaction.payment.productIdentifier, + originalTransactionId, + nil); + self.purchaseCompletion = nil; + } + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + break; + } + case SKPaymentTransactionStateFailed: + qInfo().noquote() << "[IAP][StoreKit] Transaction failed" << QString::fromUtf8(transaction.transactionIdentifier.UTF8String) + << "product=" << QString::fromUtf8(transaction.payment.productIdentifier.UTF8String) + << "error=" << QString::fromUtf8(transaction.error.localizedDescription.UTF8String); + if (self.purchaseCompletion) { + self.purchaseCompletion(NO, + transaction.transactionIdentifier, + transaction.payment.productIdentifier, + nil, + transaction.error); + self.purchaseCompletion = nil; + } + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + break; + case SKPaymentTransactionStateRestored: { + if (self.restoreCompletion) { + NSString *transactionId = transaction.transactionIdentifier ?: @""; + NSString *originalTransactionId = transaction.originalTransaction.transactionIdentifier ?: transactionId; + NSString *productId = transaction.payment.productIdentifier ?: @""; + + qInfo().noquote() << "[IAP][StoreKit] Transaction restored" + << QString::fromUtf8(transactionId.UTF8String) + << "original=" + << QString::fromUtf8((originalTransactionId ?: @"").UTF8String) + << "product=" + << QString::fromUtf8((productId ?: @"").UTF8String); + + NSDictionary *info = @{ + @"transactionId": transactionId, + @"originalTransactionId": originalTransactionId ?: @"", + @"productId": productId ?: @"" + }; + if (!self.restoredTransactions) { + self.restoredTransactions = [NSMutableArray array]; + } + [self.restoredTransactions addObject:info]; + } + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + break; + } + case SKPaymentTransactionStatePurchasing: + case SKPaymentTransactionStateDeferred: + break; + } + } +} + +- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue +{ + if (self.restoreCompletion) { + NSArray *transactions = [self.restoredTransactions copy]; + self.restoreCompletion(YES, transactions, nil); + self.restoreCompletion = nil; + self.restoredTransactions = nil; + } +} + +- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error +{ + if (self.restoreCompletion) { + self.restoreCompletion(NO, nil, error); + self.restoreCompletion = nil; + self.restoredTransactions = nil; + } +} + +@end diff --git a/client/platforms/ios/ios_controller.h b/client/platforms/ios/ios_controller.h index 7e815bde8..718e245fe 100644 --- a/client/platforms/ios/ios_controller.h +++ b/client/platforms/ios/ios_controller.h @@ -2,6 +2,11 @@ #define IOS_CONTROLLER_H #include "protocols/vpnprotocol.h" +#include +#include +#include +#include +#include #ifdef __OBJC__ #import @@ -55,6 +60,22 @@ public: bool shareText(const QStringList &filesToSend); QString openFile(); + void purchaseProduct(const QString &productId, + std::function &&callback); + void restorePurchases(std::function &transactions, + const QString &errorString)> &&callback); + + // Fetch product info for given product identifiers and return basic fields for logging + void fetchProducts(const QStringList &productIds, + std::function &products, + const QStringList &invalidIds, + const QString &errorString)> &&callback); + 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 1ef1a5ccf..af20a537b 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"; @@ -101,6 +102,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] @@ -909,6 +913,135 @@ QString IosController::openFile() { return filePath; } +void IosController::purchaseProduct(const QString &productId, + std::function &&callback) +{ + qInfo().noquote() << "[IAP][IosController] purchaseProduct called" << productId; + if (@available(iOS 15.0, macOS 12.0, *)) { + StoreKitController *controller = [StoreKitController sharedInstance]; + __block auto cb = std::move(callback); + [controller purchaseProduct:productId.toNSString() completion:^(BOOL s, + NSString * _Nullable transactionId, + NSString * _Nullable prodId, + NSString * _Nullable originalTxId, + NSError * _Nullable error) { + const QString txId = QString::fromUtf8((transactionId ?: @"").UTF8String); + const QString pId = QString::fromUtf8((prodId ?: @"").UTF8String); + const QString origTxId = QString::fromUtf8((originalTxId ?: @"").UTF8String); + const QString err = QString::fromUtf8((error.localizedDescription ?: @"").UTF8String); + + qInfo().noquote() << "[IAP][IosController] purchase completion" << "success=" << s + << "transactionId=" << txId << "originalTransactionId=" << origTxId + << "productId=" << pId << "error=" << err; + + if (cb) { + cb(s, txId, pId, origTxId, err); + } + }]; + } else { + if (callback) { + callback(false, QString(), QString(), QString(), "StoreKit 2 requires iOS 15.0 or later"); + } + } +} + +void IosController::restorePurchases(std::function &transactions, + const QString &errorString)> &&callback) +{ + if (@available(iOS 15.0, macOS 12.0, *)) { + StoreKitController *controller = [StoreKitController sharedInstance]; + __block auto cb = std::move(callback); + [controller restorePurchasesWithCompletion:^(BOOL s, + NSArray * _Nullable restoredTransactions, + NSError * _Nullable error) { + QString err; + if (error) { + err = QString::fromUtf8(error.localizedDescription.UTF8String); + } + QList transactions; + for (NSDictionary *dict in restoredTransactions ?: @[]) { + QVariantMap transaction; + NSString *transactionId = dict[@"transactionId"]; + NSString *productId = dict[@"productId"]; + NSString *originalTransactionId = dict[@"originalTransactionId"]; + + if (transactionId) { + transaction.insert(QStringLiteral("transactionId"), QString::fromUtf8(transactionId.UTF8String)); + } + if (productId) { + transaction.insert(QStringLiteral("productId"), QString::fromUtf8(productId.UTF8String)); + } + if (originalTransactionId) { + transaction.insert(QStringLiteral("originalTransactionId"), + QString::fromUtf8(originalTransactionId.UTF8String)); + } + transactions.push_back(transaction); + } + if (cb) { + cb(s, transactions, err); + } + }]; + } else { + if (callback) { + callback(false, QList(), "StoreKit 2 requires iOS 15.0 or later"); + } + } +} + +void IosController::fetchProducts(const QStringList &productIds, + std::function &products, + const QStringList &invalidIds, + const QString &errorString)> &&callback) +{ + if (@available(iOS 15.0, macOS 12.0, *)) { + StoreKitController *controller = [StoreKitController sharedInstance]; + NSMutableSet *ids = [NSMutableSet setWithCapacity:productIds.size()]; + for (const auto &pid : productIds) { + [ids addObject:pid.toNSString()]; + } + __block auto cb = std::move(callback); + + [controller fetchProductsWithIdentifiers:ids + completion:^(NSArray * _Nonnull products, + NSArray * _Nonnull invalidIdentifiers, + NSError * _Nullable error) { + QList outProducts; + for (NSDictionary *p in products) { + QVariantMap m; + m["productId"] = QString::fromUtf8([p[@"productId"] UTF8String]); + m["title"] = QString::fromUtf8([p[@"title"] UTF8String]); + m["description"] = QString::fromUtf8([p[@"description"] UTF8String]); + m["price"] = QString::fromUtf8([p[@"price"] UTF8String]); + m["currencyCode"] = QString::fromUtf8([p[@"currencyCode"] UTF8String]); + outProducts.push_back(m); + } + + QStringList invalid; + for (NSString *inv in invalidIdentifiers) { + invalid.push_back(QString::fromUtf8(inv.UTF8String)); + } + + QString err; + if (error) { + err = QString::fromUtf8(error.localizedDescription.UTF8String); + } + + if (cb) { + cb(outProducts, invalid, err); + } + }]; + } else { + if (callback) { + callback(QList(), QStringList(), "StoreKit 2 requires iOS 15.0 or later"); + } + } +} + void IosController::requestInetAccess() { NSURL *url = [NSURL URLWithString:@"http://captive.apple.com/generate_204"]; if (!url) { diff --git a/client/translations/amneziavpn_ar_EG.ts b/client/translations/amneziavpn_ar_EG.ts index 4b84502b5..ae14dc895 100644 --- a/client/translations/amneziavpn_ar_EG.ts +++ b/client/translations/amneziavpn_ar_EG.ts @@ -2849,6 +2849,11 @@ Already installed containers were found on the server. All installed containers Site Amnezia + + + Restore purchases + استعادة المشتريات + VPN by Amnezia diff --git a/client/translations/amneziavpn_fa_IR.ts b/client/translations/amneziavpn_fa_IR.ts index c1155b9b9..9aa79e442 100644 --- a/client/translations/amneziavpn_fa_IR.ts +++ b/client/translations/amneziavpn_fa_IR.ts @@ -2973,6 +2973,11 @@ It's okay as long as it's from someone you trust. Site Amnezia + + + Restore purchases + بازیابی خریدها + VPN by Amnezia diff --git a/client/translations/amneziavpn_hi_IN.ts b/client/translations/amneziavpn_hi_IN.ts index 18dcfc654..c6255cca3 100644 --- a/client/translations/amneziavpn_hi_IN.ts +++ b/client/translations/amneziavpn_hi_IN.ts @@ -2865,6 +2865,11 @@ Already installed containers were found on the server. All installed containers Site Amnezia + + + Restore purchases + खरीदारी पुनर्स्थापित करें + VPN by Amnezia diff --git a/client/translations/amneziavpn_my_MM.ts b/client/translations/amneziavpn_my_MM.ts index 1e81c5aa0..ad275d736 100644 --- a/client/translations/amneziavpn_my_MM.ts +++ b/client/translations/amneziavpn_my_MM.ts @@ -2867,6 +2867,11 @@ Already installed containers were found on the server. All installed containers Site Amnezia + + + Restore purchases + ဝယ်ယူထားသည့်များကို ပြန်လည်ရယူမည် + VPN by Amnezia diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index 185c054ea..68bda6fe4 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -3244,6 +3244,11 @@ Thank you for staying with us! Site Amnezia Сайт Amnezia + + + Restore purchases + Восстановить покупки + VPN by Amnezia diff --git a/client/translations/amneziavpn_uk_UA.ts b/client/translations/amneziavpn_uk_UA.ts index 3fa42c9ff..e0fabc6b9 100644 --- a/client/translations/amneziavpn_uk_UA.ts +++ b/client/translations/amneziavpn_uk_UA.ts @@ -3131,6 +3131,11 @@ It's okay as long as it's from someone you trust. Site Amnezia + + + Restore purchases + Відновити покупки + VPN by Amnezia diff --git a/client/translations/amneziavpn_ur_PK.ts b/client/translations/amneziavpn_ur_PK.ts index 3a8b81724..f0d63b4bc 100644 --- a/client/translations/amneziavpn_ur_PK.ts +++ b/client/translations/amneziavpn_ur_PK.ts @@ -2857,6 +2857,11 @@ Already installed containers were found on the server. All installed containers Site Amnezia + + + Restore purchases + خریداری بحال کریں + VPN by Amnezia diff --git a/client/translations/amneziavpn_zh_CN.ts b/client/translations/amneziavpn_zh_CN.ts index 2b0cf8481..d3b2138bf 100644 --- a/client/translations/amneziavpn_zh_CN.ts +++ b/client/translations/amneziavpn_zh_CN.ts @@ -3008,6 +3008,11 @@ It's okay as long as it's from someone you trust. Site Amnezia + + + Restore purchases + 恢复购买 + VPN by Amnezia diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index ea2b41b1a..d4a2c105a 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -1,8 +1,9 @@ #include "apiConfigsController.h" #include +#include #include - +#include #include "amnezia_application.h" #include "configurators/wireguard_configurator.h" #include "core/api/apiDefs.h" @@ -12,6 +13,8 @@ #include "ui/controllers/systemController.h" #include "version.h" +#include "platforms/ios/ios_controller.h" + namespace { namespace configKey @@ -173,7 +176,7 @@ namespace auto clientProtocolConfig = QJsonDocument::fromJson(serverProtocolConfig.value(config_key::last_config).toString().toUtf8()).object(); - // TODO looks like this block can be removed after v1 configs EOL + //TODO looks like this block can be removed after v1 configs EOL serverProtocolConfig[config_key::junkPacketCount] = clientProtocolConfig.value(config_key::junkPacketCount); serverProtocolConfig[config_key::junkPacketMinSize] = clientProtocolConfig.value(config_key::junkPacketMinSize); @@ -397,6 +400,259 @@ bool ApiConfigsController::fillAvailableServices() QJsonObject data = QJsonDocument::fromJson(responseBody).object(); m_apiServicesModel->updateModel(data); + if (m_apiServicesModel->rowCount() > 0) { + m_apiServicesModel->setServiceIndex(0); + } + return true; +} + +bool ApiConfigsController::importSerivceFromAppStore() +{ +#if defined(Q_OS_IOS) || defined(MACOS_NE) + QString chosenProductId; + { + const QStringList productIds = { QStringLiteral("com.amnezia.amneziavpn.1_month"), QStringLiteral("com.amnezia.AmneziaVPN.6_month") }; + qInfo().noquote() << "[IAP] Fetching products" << productIds; + + QList products; + QString fetchError; + QEventLoop waitFetch; + IosController::Instance()->fetchProducts(productIds, + [&](const QList &prods, const QStringList &invalid, const QString &err) { + products = prods; + fetchError = err; + qInfo().noquote() << "[IAP] Fetch callback" << "invalid=" << invalid + << "error=" << err; + waitFetch.quit(); + }); + waitFetch.exec(); + + qInfo().noquote() << "[IAP] Product fetch completed; success =" << fetchError.isEmpty() + << "returned =" << products.size() << "invalid =" << !fetchError.isEmpty(); + + if (fetchError.isEmpty() && !products.isEmpty()) { + chosenProductId = products.first().value("productId").toString(); + } + if (chosenProductId.isEmpty() && !productIds.isEmpty()) { + chosenProductId = productIds.first(); + } + qInfo().noquote() << "[IAP] Chosen product =" << chosenProductId; + } + + bool purchaseOk = false; + QString originalTransactionId; + QString storeTransactionId; + QString storeProductId; + QString purchaseError; + QEventLoop waitPurchase; + IosController::Instance()->purchaseProduct(chosenProductId, + [&](bool success, const QString &txId, const QString &purchasedProductId, + const QString &originalTxId, const QString &errorString) { + purchaseOk = success; + originalTransactionId = originalTxId; + storeTransactionId = txId; + storeProductId = purchasedProductId; + purchaseError = errorString; + waitPurchase.quit(); + }); + waitPurchase.exec(); + + if (!purchaseOk || originalTransactionId.isEmpty()) { + qDebug() << "IAP purchase failed:" << purchaseError; + emit errorOccurred(ErrorCode::ApiPurchaseError); + return false; + } + qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId + << "originalTransactionId =" << originalTransactionId + << "productId =" << storeProductId; + + GatewayRequestData gatewayRequestData { QSysInfo::productType(), + QString(APP_VERSION), + m_settings->getInstallationUuid(true), + m_apiServicesModel->getCountryCode(), + "", + m_apiServicesModel->getSelectedServiceType(), + m_apiServicesModel->getSelectedServiceProtocol(), + QJsonObject() }; + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + apiPayload[apiDefs::key::transactionId] = originalTransactionId; + qInfo().noquote() << "[IAP] Sending subscription request. Payload:" + << QJsonDocument(apiPayload).toJson(QJsonDocument::Compact); + + ErrorCode errorCode; + QByteArray responseBody; + errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + emit errorOccurred(errorCode); + return false; + } + + ErrorCode installError = ErrorCode::NoError; + if (!installServerFromSubscriptionResponse(responseBody, &installError)) { + const ErrorCode errorToEmit = installError == ErrorCode::NoError ? ErrorCode::ApiPurchaseError : installError; + emit errorOccurred(errorToEmit); + return false; + } + + qInfo().noquote() << "[IAP] Subscription config installed after purchase"; + emit installServerFromApiFinished(tr("%1 installed successfully.").arg(m_apiServicesModel->getSelectedServiceName())); +#endif + return true; +} + +bool ApiConfigsController::restoreSerivceFromAppStore() +{ +#if defined(Q_OS_IOS) || defined(MACOS_NE) + const QString premiumServiceType = QStringLiteral("amnezia-premium"); + const QString originalServiceType = m_apiServicesModel->rowCount() > 0 ? m_apiServicesModel->getSelectedServiceType() : QString(); + + if (m_apiServicesModel->rowCount() <= 0) { + qInfo().noquote() << "[IAP] Services model is empty before restore, requesting available services"; + if (!fillAvailableServices()) { + qWarning().noquote() << "[IAP] Unable to fetch services list before restore"; + emit errorOccurred(ErrorCode::ApiServicesMissingError); + return false; + } + } + + if (m_apiServicesModel->rowCount() <= 0) { + qWarning().noquote() << "[IAP] Restore aborted: services list is still empty"; + emit errorOccurred(ErrorCode::ApiServicesMissingError); + return false; + } + // Ensure we have a valid premium selection for gateway requests + bool premiumSelected = false; + for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { + m_apiServicesModel->setServiceIndex(i); + if (m_apiServicesModel->getSelectedServiceType() == premiumServiceType) { + premiumSelected = true; + break; + } + } + if (!premiumSelected) { + m_apiServicesModel->setServiceIndex(0); + } + + bool restoreSuccess = false; + QList restoredTransactions; + QString restoreError; + QEventLoop waitRestore; + + IosController::Instance()->restorePurchases([&](bool success, + const QList &transactions, + const QString &errorString) { + restoreSuccess = success; + restoredTransactions = transactions; + restoreError = errorString; + waitRestore.quit(); + }); + waitRestore.exec(); + + if (!restoreSuccess) { + qWarning().noquote() << "[IAP] Restore failed:" << restoreError; + emit errorOccurred(ErrorCode::ApiPurchaseError); + return false; + } + + if (restoredTransactions.isEmpty()) { + qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned"; + emit errorOccurred(ErrorCode::ApiPurchaseError); + return false; + } + + bool hasInstalledConfig = false; + bool duplicateConfigAlreadyPresent = false; + int duplicateCount = 0; + QSet processedTransactions; + for (const QVariantMap &transaction : restoredTransactions) { + const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString(); + const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString(); + const QString productId = transaction.value(QStringLiteral("productId")).toString(); + + if (originalTransactionId.isEmpty()) { + qWarning().noquote() << "[IAP] Skipping restored transaction without originalTransactionId" + << transactionId; + continue; + } + + if (processedTransactions.contains(originalTransactionId)) { + duplicateCount++; + continue; + } + processedTransactions.insert(originalTransactionId); + + qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId + << "originalTransactionId =" << originalTransactionId + << "productId =" << productId; + + GatewayRequestData gatewayRequestData { QSysInfo::productType(), + QString(APP_VERSION), + m_settings->getInstallationUuid(true), + m_apiServicesModel->getCountryCode(), + "", + m_apiServicesModel->getSelectedServiceType(), + m_apiServicesModel->getSelectedServiceProtocol(), + QJsonObject() }; + + QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + apiPayload[apiDefs::key::transactionId] = originalTransactionId; + + QByteArray responseBody; + ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody); + if (errorCode != ErrorCode::NoError) { + qWarning().noquote() << "[IAP] Failed to restore transaction" << originalTransactionId + << "errorCode =" << static_cast(errorCode); + continue; + } + + ErrorCode installError = ErrorCode::NoError; + if (!installServerFromSubscriptionResponse(responseBody, &installError)) { + if (installError == ErrorCode::ApiConfigAlreadyAdded) { + duplicateConfigAlreadyPresent = true; + qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId + << "because subscription config with the same vpn_key already exists"; + } else { + qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" + << originalTransactionId; + } + continue; + } + + hasInstalledConfig = true; + } + + if (!hasInstalledConfig) { + const ErrorCode restoreError = duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError; + emit errorOccurred(restoreError); + // Restore previous selection so that start page state is unchanged. + if (!originalServiceType.isEmpty()) { + for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { + m_apiServicesModel->setServiceIndex(i); + if (m_apiServicesModel->getSelectedServiceType() == originalServiceType) { + break; + } + } + } + return false; + } + + emit installServerFromApiFinished(tr("Subscription restored successfully.")); + if (duplicateCount > 0) { + qInfo().noquote() << "[IAP] Skipped" << duplicateCount + << "duplicate restored transactions for original transaction IDs already processed"; + } + + // Restore previous selection if it differs from premium + if (!originalServiceType.isEmpty() && originalServiceType != premiumServiceType) { + for (int i = 0; i < m_apiServicesModel->rowCount(); ++i) { + m_apiServicesModel->setServiceIndex(i); + if (m_apiServicesModel->getSelectedServiceType() == originalServiceType) { + break; + } + } + } +#endif return true; } @@ -423,8 +679,10 @@ bool ApiConfigsController::importServiceFromGateway() QJsonObject apiPayload = gatewayRequestData.toJsonObject(); appendProtocolDataToApiPayload(gatewayRequestData.serviceProtocol, protocolData, apiPayload); + ErrorCode errorCode; QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + + errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); QJsonObject serverConfig; if (errorCode == ErrorCode::NoError) { @@ -706,7 +964,7 @@ QList ApiConfigsController::getQrCodes() int ApiConfigsController::getQrCodesCount() { - return m_qrCodes.size(); + return static_cast(m_qrCodes.size()); } QString ApiConfigsController::getVpnKey() @@ -714,6 +972,92 @@ QString ApiConfigsController::getVpnKey() return m_vpnKey; } +bool ApiConfigsController::installServerFromSubscriptionResponse(const QByteArray &responseBody, ErrorCode *errorOut) +{ +#ifdef Q_OS_IOS + if (errorOut) { + *errorOut = ErrorCode::NoError; + } + QJsonParseError parseError {}; + QJsonDocument responseDoc = QJsonDocument::fromJson(responseBody, &parseError); + if (parseError.error == QJsonParseError::NoError) { + qInfo().noquote() << "[IAP] Subscription raw response" << responseDoc.toJson(QJsonDocument::Compact); + } else { + qWarning().noquote() << "[IAP] Subscription raw response parse error:" << parseError.errorString() + << "raw=" << QString::fromUtf8(responseBody); + } + + const QJsonObject responseObject = responseDoc.object(); + QString key = responseObject.value(QStringLiteral("key")).toString(); + if (key.isEmpty()) { + qWarning().noquote() << "[IAP] Subscription response does not contain a key field"; + if (errorOut) { + *errorOut = ErrorCode::ApiPurchaseError; + } + return false; + } + + if (m_serversModel->hasServerWithVpnKey(key)) { + qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists"; + if (errorOut) { + *errorOut = ErrorCode::ApiConfigAlreadyAdded; + } + return false; + } + + QString normalizedKey = key; + normalizedKey.replace(QStringLiteral("vpn://"), QString()); + + QByteArray config = QByteArray::fromBase64(normalizedKey.toUtf8(), + QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); + QByteArray configUncompressed = qUncompress(config); + if (!configUncompressed.isEmpty()) { + config = configUncompressed; + } + if (config.isEmpty()) { + qWarning().noquote() << "[IAP] Subscription response config payload is empty"; + if (errorOut) { + *errorOut = ErrorCode::ApiPurchaseError; + } + return false; + } + + QJsonParseError configParseError {}; + QJsonDocument configDoc = QJsonDocument::fromJson(config, &configParseError); + if (configParseError.error != QJsonParseError::NoError) { + qWarning().noquote() << "[IAP] Failed to parse subscription config:" << configParseError.errorString(); + if (errorOut) { + *errorOut = ErrorCode::ApiPurchaseError; + } + return false; + } + + QJsonObject configJson = configDoc.object(); + + quint16 crc = qChecksum(QJsonDocument(configJson).toJson()); + auto apiConfig = configJson.value(apiDefs::key::apiConfig).toObject(); + apiConfig[apiDefs::key::vpnKey] = normalizedKey; + auto subscriptionObject = apiConfig.value(configKey::subscription).toObject(); + qInfo().noquote() << "[IAP] Subscription payload details" << "serviceType=" + << apiConfig.value(configKey::serviceType).toString() + << "serviceProtocol=" << apiConfig.value(configKey::serviceProtocol).toString() + << "subscriptionEnd=" << subscriptionObject.value(apiDefs::key::subscriptionEndDate).toString() + << "subscriptionType=" << subscriptionObject.value(QStringLiteral("type")).toString(); + configJson.insert(apiDefs::key::apiConfig, apiConfig); + configJson.insert(config_key::crc, crc); + m_serversModel->addServer(configJson); + + qDebug() << configJson; + return true; +#else + Q_UNUSED(responseBody) + if (errorOut) { + *errorOut = ErrorCode::ApiPurchaseError; + } + return false; +#endif +} + ErrorCode ApiConfigsController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody) { GatewayController gatewayController(m_settings->getGatewayEndpoint(), m_settings->isDevGatewayEnv(), apiDefs::requestTimeoutMsecs, diff --git a/client/ui/controllers/api/apiConfigsController.h b/client/ui/controllers/api/apiConfigsController.h index 2a4cd400b..24d4f203f 100644 --- a/client/ui/controllers/api/apiConfigsController.h +++ b/client/ui/controllers/api/apiConfigsController.h @@ -26,6 +26,8 @@ public slots: void copyVpnKeyToClipboard(); bool fillAvailableServices(); + bool importSerivceFromAppStore(); + bool restoreSerivceFromAppStore(); bool importServiceFromGateway(); bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig = false); @@ -54,6 +56,7 @@ private: QString getVpnKey(); ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody); + bool installServerFromSubscriptionResponse(const QByteArray &responseBody, ErrorCode *errorOut = nullptr); QList m_qrCodes; QString m_vpnKey; diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 62aeb4722..3af2a09a5 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -26,6 +26,15 @@ namespace constexpr char publicKeyInfo[] = "public_key"; constexpr char expiresAt[] = "expires_at"; } + + QString normalizeVpnKey(const QString &vpnKey) + { + QString normalized = vpnKey.trimmed(); + if (normalized.startsWith(QStringLiteral("vpn://"), Qt::CaseInsensitive)) { + normalized = normalized.mid(QStringLiteral("vpn://").size()); + } + return normalized; + } } ServersModel::ServersModel(std::shared_ptr settings, QObject *parent) : m_settings(settings), QAbstractListModel(parent) @@ -718,6 +727,23 @@ bool ServersModel::isServerFromApiAlreadyExists(const QString &userCountryCode, return false; } +bool ServersModel::hasServerWithVpnKey(const QString &vpnKey) const +{ + const QString normalizedInput = normalizeVpnKey(vpnKey); + if (normalizedInput.isEmpty()) { + return false; + } + + for (const auto &server : std::as_const(m_servers)) { + const auto apiConfig = server.toObject().value(configKey::apiConfig).toObject(); + const QString existingKey = normalizeVpnKey(apiConfig.value(apiDefs::key::vpnKey).toString()); + if (!existingKey.isEmpty() && existingKey == normalizedInput) { + return true; + } + } + return false; +} + bool ServersModel::serverHasInstalledContainers(const int serverIndex) const { QJsonObject server = m_servers.at(serverIndex).toObject(); diff --git a/client/ui/models/servers_model.h b/client/ui/models/servers_model.h index 8a0406bbe..66779bc2e 100644 --- a/client/ui/models/servers_model.h +++ b/client/ui/models/servers_model.h @@ -140,6 +140,7 @@ public slots: bool isServerFromApiAlreadyExists(const quint16 crc); bool isServerFromApiAlreadyExists(const QString &userCountryCode, const QString &serviceType, const QString &serviceProtocol); + bool hasServerWithVpnKey(const QString &vpnKey) const; QVariant getDefaultServerData(const QString roleString); diff --git a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml index 13890666e..63edc53c8 100644 --- a/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml +++ b/client/ui/qml/Pages2/PageSetupWizardApiServiceInfo.qml @@ -106,14 +106,18 @@ PageType { Layout.leftMargin: 16 Layout.rightMargin: 16 - text: qsTr("Connect") - - clickedFunc: function() { - var endpoint = ApiServicesModel.getStoreEndpoint() - if (endpoint !== undefined && endpoint !== "") { - Qt.openUrlExternally(endpoint) - PageController.closePage() - PageController.closePage() + text: qsTr("Connect") + + clickedFunc: function() { + var endpoint = ApiServicesModel.getStoreEndpoint() + if (endpoint !== undefined && endpoint !== "" && Qt.platform.os !== "ios" && !IsMacOsNeBuild) { + Qt.openUrlExternally(endpoint) + PageController.closePage() + PageController.closePage() + } else if (Qt.platform.os === "ios" || IsMacOsNeBuild) { + PageController.showBusyIndicator(true) + ApiConfigsController.importSerivceFromAppStore() + PageController.showBusyIndicator(false) } else { PageController.showBusyIndicator(true) ApiConfigsController.importServiceFromGateway() diff --git a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml index 8ee6babe2..160177b6c 100644 --- a/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml +++ b/client/ui/qml/Pages2/PageSetupWizardConfigSource.qml @@ -242,7 +242,7 @@ PageType { Layout.alignment: Qt.AlignHCenter implicitHeight: 32 - visible: Qt.platform.os !== "ios" + visible: Qt.platform.os !== "ios" && !IsMacOsNeBuild defaultColor: AmneziaStyle.color.transparent hoveredColor: AmneziaStyle.color.translucentWhite @@ -267,6 +267,7 @@ PageType { backupRestore, fileOpen, qrScan, + restorePurchases, siteLink ] @@ -351,13 +352,27 @@ PageType { } } + QtObject { + id: restorePurchases + + property string title: qsTr("Restore purchases") + property string description: qsTr("") + property string imageSource: "qrc:/images/controls/refresh-cw.svg" + property bool isVisible: Qt.platform.os === "ios" || IsMacOsNeBuild + property var handler: function() { + PageController.showBusyIndicator(true) + ApiConfigsController.restoreSerivceFromAppStore() + PageController.showBusyIndicator(false) + } + } + QtObject { id: siteLink property string title: qsTr("I have nothing") property string description: qsTr("") property string imageSource: "qrc:/images/controls/help-circle.svg" - property bool isVisible: PageController.isStartPageVisible() && Qt.platform.os !== "ios" + property bool isVisible: PageController.isStartPageVisible() && Qt.platform.os !== "ios" && !IsMacOsNeBuild property var handler: function() { Qt.openUrlExternally(LanguageModel.getCurrentSiteUrl()) }