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
+133
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) {