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 f22fd7a13b.

---------

Co-authored-by: vladimir.kuznetsov <nethiuswork@gmail.com>
Co-authored-by: vkamn <vk@amnezia.org>
Co-authored-by: spectrum <yyy@amnezia.org>
This commit is contained in:
Yaroslav
2025-12-18 16:36:12 +02:00
committed by GitHub
parent 46ce22b85c
commit 6178b05643
22 changed files with 910 additions and 14 deletions

View File

@@ -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 <Foundation/Foundation.h>
#import <StoreKit/StoreKit.h>
@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<NSDictionary *> *_Nullable restoredTransactions,
NSError *_Nullable error))completion;
// Fetch product information for a set of identifiers without initiating a purchase
- (void)fetchProductsWithIdentifiers:(NSSet<NSString *> *)productIdentifiers
completion:(void (^)(NSArray<NSDictionary *> *products,
NSArray<NSString *> *invalidIdentifiers,
NSError *_Nullable error))completion;
@end
#endif // STOREKITCONTROLLER_H

View File

@@ -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 <StoreKit/StoreKit.h>
#include <QtCore/QDebug>
#include <QtCore/QString>
API_AVAILABLE(ios(15.0), macos(12.0))
@interface StoreKitController () <SKProductsRequestDelegate, SKPaymentTransactionObserver>
@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<NSDictionary *> *_Nullable restoredTransactions,
NSError *_Nullable error);
@property (nonatomic, copy) void (^productsFetchCompletion)(NSArray<NSDictionary *> *products,
NSArray<NSString *> *invalidIdentifiers,
NSError *_Nullable error);
@property (nonatomic, strong) SKProductsRequest *productsRequest;
@property (nonatomic, strong) NSMutableArray<NSDictionary *> *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<NSDictionary *> *_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<NSString *> *)productIdentifiers
completion:(void (^)(NSArray<NSDictionary *> *products,
NSArray<NSString *> *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<NSDictionary *> *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<SKPaymentTransaction *> *)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<NSDictionary *> *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

View File

@@ -2,6 +2,11 @@
#define IOS_CONTROLLER_H
#include "protocols/vpnprotocol.h"
#include <functional>
#include <QVariant>
#include <QVariantMap>
#include <QStringList>
#include <QList>
#ifdef __OBJC__
#import <Foundation/Foundation.h>
@@ -55,6 +60,22 @@ public:
bool shareText(const QStringList &filesToSend);
QString openFile();
void purchaseProduct(const QString &productId,
std::function<void(bool success,
const QString &transactionId,
const QString &purchasedProductId,
const QString &originalTransactionId,
const QString &errorString)> &&callback);
void restorePurchases(std::function<void(bool success,
const QList<QVariantMap> &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<void(const QList<QVariantMap> &products,
const QStringList &invalidIds,
const QString &errorString)> &&callback);
void requestInetAccess();
signals:
void connectionStateChanged(Vpn::ConnectionState state);

View File

@@ -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<void(bool success,
const QString &transactionId,
const QString &purchasedProductId,
const QString &originalTransactionId,
const QString &errorString)> &&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<void(bool success,
const QList<QVariantMap> &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<NSDictionary *> * _Nullable restoredTransactions,
NSError * _Nullable error) {
QString err;
if (error) {
err = QString::fromUtf8(error.localizedDescription.UTF8String);
}
QList<QVariantMap> 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<QVariantMap>(), "StoreKit 2 requires iOS 15.0 or later");
}
}
}
void IosController::fetchProducts(const QStringList &productIds,
std::function<void(const QList<QVariantMap> &products,
const QStringList &invalidIds,
const QString &errorString)> &&callback)
{
if (@available(iOS 15.0, macOS 12.0, *)) {
StoreKitController *controller = [StoreKitController sharedInstance];
NSMutableSet<NSString *> *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<NSDictionary *> * _Nonnull products,
NSArray<NSString *> * _Nonnull invalidIdentifiers,
NSError * _Nullable error) {
QList<QVariantMap> 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<QVariantMap>(), QStringList(), "StoreKit 2 requires iOS 15.0 or later");
}
}
}
void IosController::requestInetAccess() {
NSURL *url = [NSURL URLWithString:@"http://captive.apple.com/generate_204"];
if (!url) {