diff --git a/atom/browser/api/atom_api_in_app_purchase.cc b/atom/browser/api/atom_api_in_app_purchase.cc index c30057fa0702..ecfb146bd287 100644 --- a/atom/browser/api/atom_api_in_app_purchase.cc +++ b/atom/browser/api/atom_api_in_app_purchase.cc @@ -45,6 +45,29 @@ struct Converter { } }; +template <> +struct Converter { + static v8::Local ToV8(v8::Isolate* isolate, + const in_app_purchase::Product& val) { + mate::Dictionary dict = mate::Dictionary::CreateEmpty(isolate); + dict.SetHidden("simple", true); + dict.Set("productIdentifier", val.productIdentifier); + dict.Set("localizedDescription", val.localizedDescription); + dict.Set("localizedTitle", val.localizedTitle); + dict.Set("contentVersion", val.localizedTitle); + dict.Set("contentLengths", val.contentLengths); + + // Pricing Information + dict.Set("price", val.price); + dict.Set("formattedPrice", val.formattedPrice); + + // Downloadable Content Information + dict.Set("isDownloadable", val.downloadable); + + return dict.GetHandle(); + } +}; + } // namespace mate namespace atom { @@ -64,7 +87,12 @@ void InAppPurchase::BuildPrototype(v8::Isolate* isolate, mate::ObjectTemplateBuilder(isolate, prototype->PrototypeTemplate()) .SetMethod("canMakePayments", &in_app_purchase::CanMakePayments) .SetMethod("getReceiptURL", &in_app_purchase::GetReceiptURL) - .SetMethod("purchaseProduct", &InAppPurchase::PurchaseProduct); + .SetMethod("purchaseProduct", &InAppPurchase::PurchaseProduct) + .SetMethod("finishAllTransactions", + &in_app_purchase::FinishAllTransactions) + .SetMethod("finishTransactionByDate", + &in_app_purchase::FinishTransactionByDate) + .SetMethod("getProducts", &in_app_purchase::GetProducts); } InAppPurchase::InAppPurchase(v8::Isolate* isolate) { diff --git a/atom/browser/api/atom_api_in_app_purchase.h b/atom/browser/api/atom_api_in_app_purchase.h index 3646c9b8e8f7..10f8f473e42a 100644 --- a/atom/browser/api/atom_api_in_app_purchase.h +++ b/atom/browser/api/atom_api_in_app_purchase.h @@ -11,14 +11,15 @@ #include "atom/browser/api/event_emitter.h" #include "atom/browser/mac/in_app_purchase.h" #include "atom/browser/mac/in_app_purchase_observer.h" +#include "atom/browser/mac/in_app_purchase_product.h" #include "native_mate/handle.h" namespace atom { namespace api { -class InAppPurchase: public mate::EventEmitter, - public in_app_purchase::TransactionObserver { +class InAppPurchase : public mate::EventEmitter, + public in_app_purchase::TransactionObserver { public: static mate::Handle Create(v8::Isolate* isolate); diff --git a/atom/browser/mac/in_app_purchase.h b/atom/browser/mac/in_app_purchase.h index d014744d55c4..f42f172d3acf 100644 --- a/atom/browser/mac/in_app_purchase.h +++ b/atom/browser/mac/in_app_purchase.h @@ -19,6 +19,10 @@ typedef base::Callback InAppPurchaseCallback; bool CanMakePayments(void); +void FinishAllTransactions(void); + +void FinishTransactionByDate(const std::string& date); + std::string GetReceiptURL(void); void PurchaseProduct(const std::string& productID, diff --git a/atom/browser/mac/in_app_purchase.mm b/atom/browser/mac/in_app_purchase.mm index f9f98332da19..98d890b75500 100644 --- a/atom/browser/mac/in_app_purchase.mm +++ b/atom/browser/mac/in_app_purchase.mm @@ -136,6 +136,34 @@ bool CanMakePayments() { return [SKPaymentQueue canMakePayments]; } +void FinishAllTransactions() { + for (SKPaymentTransaction* transaction in SKPaymentQueue.defaultQueue + .transactions) { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + } +} + +void FinishTransactionByDate(const std::string& date) { + // Create the date formatter. + NSDateFormatter* dateFormatter = [[NSDateFormatter alloc] init]; + NSLocale* enUSPOSIXLocale = + [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + [dateFormatter setLocale:enUSPOSIXLocale]; + [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"]; + + // Remove the transaction. + NSString* transactionDate = base::SysUTF8ToNSString(date); + + for (SKPaymentTransaction* transaction in SKPaymentQueue.defaultQueue + .transactions) { + if ([transactionDate + isEqualToString:[dateFormatter + stringFromDate:transaction.transactionDate]]) { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + } + } +} + std::string GetReceiptURL() { NSURL* receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; if (receiptURL != nil) { diff --git a/atom/browser/mac/in_app_purchase_observer.mm b/atom/browser/mac/in_app_purchase_observer.mm index ef21228ad6e1..dffaf7d23623 100644 --- a/atom/browser/mac/in_app_purchase_observer.mm +++ b/atom/browser/mac/in_app_purchase_observer.mm @@ -17,9 +17,8 @@ namespace { -using InAppTransactionCallback = - base::RepeatingCallback< - void(const std::vector&)>; +using InAppTransactionCallback = base::RepeatingCallback&)>; } // namespace @@ -72,8 +71,8 @@ using InAppTransactionCallback = } // Send the callback to the browser thread. - content::BrowserThread::PostTask( - content::BrowserThread::UI, FROM_HERE, base::Bind(callback_, converted)); + content::BrowserThread::PostTask(content::BrowserThread::UI, FROM_HERE, + base::Bind(callback_, converted)); } /** @@ -141,9 +140,9 @@ using InAppTransactionCallback = } if (transaction.transactionState < 5) { - transactionStruct.transactionState = [[@[ - @"purchasing", @"purchased", @"failed", @"restored", @"deferred" - ] objectAtIndex:transaction.transactionState] UTF8String]; + transactionStruct.transactionState = + [[@[ @"purchasing", @"purchased", @"failed", @"restored", @"deferred" ] + objectAtIndex:transaction.transactionState] UTF8String]; } if (transaction.payment != nil) { @@ -177,8 +176,8 @@ namespace in_app_purchase { TransactionObserver::TransactionObserver() : weak_ptr_factory_(this) { obeserver_ = [[InAppTransactionObserver alloc] - initWithCallback:base::Bind(&TransactionObserver::OnTransactionsUpdated, - weak_ptr_factory_.GetWeakPtr())]; + initWithCallback:base::Bind(&TransactionObserver::OnTransactionsUpdated, + weak_ptr_factory_.GetWeakPtr())]; } TransactionObserver::~TransactionObserver() { diff --git a/atom/browser/mac/in_app_purchase_product.h b/atom/browser/mac/in_app_purchase_product.h new file mode 100644 index 000000000000..49e05d42c1ae --- /dev/null +++ b/atom/browser/mac/in_app_purchase_product.h @@ -0,0 +1,47 @@ +// Copyright (c) 2018 Amaplex Software, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_ +#define ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_ + +#include +#include + +#include "base/callback.h" + +namespace in_app_purchase { + +// --------------------------- Structures --------------------------- + +struct Product { + // Product Identifier + std::string productIdentifier; + + // Product Attributes + std::string localizedDescription; + std::string localizedTitle; + std::string contentVersion; + std::vector contentLengths; + + // Pricing Information + double price = 0.0; + std::string formattedPrice; + + // Downloadable Content Information + bool downloadable = false; +}; + +// --------------------------- Typedefs --------------------------- + +typedef base::Callback&)> + InAppPurchaseProductsCallback; + +// --------------------------- Functions --------------------------- + +void GetProducts(const std::vector& productIDs, + const InAppPurchaseProductsCallback& callback); + +} // namespace in_app_purchase + +#endif // ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_ diff --git a/atom/browser/mac/in_app_purchase_product.mm b/atom/browser/mac/in_app_purchase_product.mm new file mode 100644 index 000000000000..9fbeb25961a4 --- /dev/null +++ b/atom/browser/mac/in_app_purchase_product.mm @@ -0,0 +1,179 @@ +// Copyright (c) 2017 Amaplex Software, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "atom/browser/mac/in_app_purchase_product.h" + +#include "base/bind.h" +#include "base/strings/sys_string_conversions.h" +#include "content/public/browser/browser_thread.h" + +#import + +// ============================================================================ +// InAppPurchaseProduct +// ============================================================================ + +// --------------------------------- Interface -------------------------------- + +@interface InAppPurchaseProduct : NSObject { + @private + in_app_purchase::InAppPurchaseProductsCallback callback_; +} + +- (id)initWithCallback: + (const in_app_purchase::InAppPurchaseProductsCallback&)callback; + +@end + +// ------------------------------- Implementation ----------------------------- + +@implementation InAppPurchaseProduct + +/** + * Init with a callback. + * + * @param callback - The callback that will be called to return the products. + */ +- (id)initWithCallback: + (const in_app_purchase::InAppPurchaseProductsCallback&)callback { + if ((self = [super init])) { + callback_ = callback; + } + + return self; +} + +/** + * Return products. + * + * @param productIDs - The products' id to fetch. + */ +- (void)getProducts:(NSSet*)productIDs { + SKProductsRequest* productsRequest; + productsRequest = + [[SKProductsRequest alloc] initWithProductIdentifiers:productIDs]; + + productsRequest.delegate = self; + [productsRequest start]; +} + +/** + * @see SKProductsRequestDelegate + */ +- (void)productsRequest:(SKProductsRequest*)request + didReceiveResponse:(SKProductsResponse*)response { + // Release request object. + [request release]; + + // Get the products. + NSArray* products = response.products; + + // Convert the products. + std::vector converted; + converted.reserve([products count]); + + for (SKProduct* product in products) { + converted.push_back([self skProductToStruct:product]); + } + + // Send the callback to the browser thread. + content::BrowserThread::PostTask(content::BrowserThread::UI, FROM_HERE, + base::Bind(callback_, converted)); + + [self release]; +} + +/** + * Format local price. + * + * @param price - The price to format. + * @param priceLocal - The local format. + */ +- (NSString*)formatPrice:(NSDecimalNumber*)price + withLocal:(NSLocale*)priceLocal { + NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init]; + + [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; + [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; + [numberFormatter setLocale:priceLocal]; + + return [numberFormatter stringFromNumber:price]; +} + +/** + * Convert a skProduct object to Product structure. + * + * @param product - The SKProduct object to convert. + */ +- (in_app_purchase::Product)skProductToStruct:(SKProduct*)product { + in_app_purchase::Product productStruct; + + // Product Identifier + if (product.productIdentifier != nil) { + productStruct.productIdentifier = [product.productIdentifier UTF8String]; + } + + // Product Attributes + if (product.localizedDescription != nil) { + productStruct.localizedDescription = + [product.localizedDescription UTF8String]; + } + if (product.localizedTitle != nil) { + productStruct.localizedTitle = [product.localizedTitle UTF8String]; + } + if (product.contentVersion != nil) { + productStruct.contentVersion = [product.contentVersion UTF8String]; + } + if (product.contentLengths != nil) { + productStruct.contentLengths.reserve([product.contentLengths count]); + + for (NSNumber* contentLength in product.contentLengths) { + productStruct.contentLengths.push_back([contentLength longLongValue]); + } + } + + // Pricing Information + if (product.price != nil) { + productStruct.price = [product.price doubleValue]; + + if (product.priceLocale != nil) { + productStruct.formattedPrice = + [[self formatPrice:product.price withLocal:product.priceLocale] + UTF8String]; + } + } + + // Downloadable Content Information + if (product.downloadable == true) { + productStruct.downloadable = true; + } + + return productStruct; +} + +@end + +// ============================================================================ +// C++ in_app_purchase +// ============================================================================ + +namespace in_app_purchase { + +void GetProducts(const std::vector& productIDs, + const InAppPurchaseProductsCallback& callback) { + auto* iapProduct = [[InAppPurchaseProduct alloc] initWithCallback:callback]; + + // Convert the products' id to NSSet. + NSMutableSet* productsIDSet = + [NSMutableSet setWithCapacity:productIDs.size()]; + + for (auto& productID : productIDs) { + [productsIDSet addObject:base::SysUTF8ToNSString(productID)]; + } + + // Fetch the products. + [iapProduct getProducts:productsIDSet]; +} + +} // namespace in_app_purchase diff --git a/docs/api/in-app-purchase.md b/docs/api/in-app-purchase.md index 0757bfd5cba7..a07d106a3ac6 100644 --- a/docs/api/in-app-purchase.md +++ b/docs/api/in-app-purchase.md @@ -21,15 +21,24 @@ Returns: The `inAppPurchase` module has the following methods: + ### `inAppPurchase.purchaseProduct(productID, quantity, callback)` -* `productID` String - The id of the product to purchase. (the id of `com.example.app.product1` is `product1`). +* `productID` String - The identifiers of the product to purchase. (The identifier of `com.example.app.product1` is `product1`). * `quantity` Integer (optional) - The number of items the user wants to purchase. * `callback` Function (optional) - The callback called when the payment is added to the PaymentQueue. -* `isProductValid` Boolean - Determine if the product is valid and added to the payment queue. + * `isProductValid` Boolean - Determine if the product is valid and added to the payment queue. You should listen for the `transactions-updated` event as soon as possible and certainly before you call `purchaseProduct`. +### `inAppPurchase.getProducts(productIDs, callback)` + +* `productIDs` String[] - The identifiers of the products to get. +* `callback` Function - The callback called with the products or an empty array if the products don't exist. + * `products` Product[] - Array of [`Product`](structures/product) objects + +Retrieves the product descriptions. + ### `inAppPurchase.canMakePayments()` Returns `true` if the user can make a payment and `false` otherwise. @@ -37,3 +46,15 @@ Returns `true` if the user can make a payment and `false` otherwise. ### `inAppPurchase.getReceiptURL()` Returns `String`, the path to the receipt. + + +### `inAppPurchase.finishAllTransactions()` + +Completes all pending transactions. + + +### `inAppPurchase.finishTransactionByDate(date)` + +* `date` String - The ISO formatted date of the transaction to finish. + +Completes the pending transactions corresponding to the date. diff --git a/docs/api/structures/product.md b/docs/api/structures/product.md new file mode 100644 index 000000000000..911ccc2b5e68 --- /dev/null +++ b/docs/api/structures/product.md @@ -0,0 +1,10 @@ +# Product Object + +* `productIdentifier` String - The string that identifies the product to the Apple App Store. +* `localizedDescription` String - A description of the product. +* `localizedTitle` String - The name of the product. +* `contentVersion` String - A string that identifies the version of the content. +* `contentLengths` Number[] - The total size of the content, in bytes. +* `price` Number - The cost of the product in the local currency. +* `formattedPrice` String - The locale formatted price of the product. +* `downloadable` Boolean - A Boolean value that indicates whether the App Store has downloadable content for this product. \ No newline at end of file diff --git a/docs/api/structures/transaction.md b/docs/api/structures/transaction.md index 78ee4e8ad0c0..0ecf8ed8d90e 100644 --- a/docs/api/structures/transaction.md +++ b/docs/api/structures/transaction.md @@ -1,11 +1,11 @@ # Transaction Object -* `transactionIdentifier` String -* `transactionDate` String -* `originalTransactionIdentifier` String +* `transactionIdentifier` String - A string that uniquely identifies a successful payment transaction. +* `transactionDate` String - The date the transaction was added to the App Store’s payment queue. +* `originalTransactionIdentifier` String - The identifier of the restored transaction by the App Store. * `transactionState` String - The transaction sate (`"purchasing"`, `"purchased"`, `"failed"`, `"restored"`, or `"deferred"`) -* `errorCode` Integer -* `errorMessage` String +* `errorCode` Integer - The error code if an error occurred while processing the transaction. +* `errorMessage` String - The error message if an error occurred while processing the transaction. * `payment` Object - * `productIdentifier` String - * `quantity` Integer + * `productIdentifier` String - The identifier of the purchased product. + * `quantity` Integer - The quantity purchased. diff --git a/docs/tutorial/in-app-purchases.md b/docs/tutorial/in-app-purchases.md new file mode 100644 index 000000000000..6e3c9afe039f --- /dev/null +++ b/docs/tutorial/in-app-purchases.md @@ -0,0 +1,125 @@ +# In-App Purchase (macOS) + +## Preparing + +### Paid Applications Agreement +If you haven't already, you’ll need to sign the Paid Applications Agreement and set up your banking and tax information in iTunes Connect. + +[iTunes Connect Developer Help: Agreements, tax, and banking overview](https://help.apple.com/itunes-connect/developer/#/devb6df5ee51) + +### Create Your In-App Purchases +Then, you'll need to configure your in-app purchases in iTunes Connect, and include details such as name, pricing, and description that highlights the features and functionality of your in-app purchase. + +[iTunes Connect Developer Help: Create an in-app purchase](https://help.apple.com/itunes-connect/developer/#/devae49fb316) + + +### Change the CFBundleIdentifier + +To test In-App Purchase in development with Electron you'll have to change the `CFBundleIdentifier` in `node_modules/electron/dist/Electron.app/Contents/Info.plist`. You have to replace `com.github.electron` by the bundle identifier of the application you created with iTunes Connect. + +```xml +CFBundleIdentifier +com.example.app +``` + + +## Code example + +Here is an example that shows how to use In-App Purchases in Electron. You'll have to replace the product ids by the identifiers of the products created with iTunes Connect (the identifier of `com.example.app.product1` is `product1`). Note that you have to listen to the `transactions-updated` event as soon as possible in your app. + + +```javascript +const { inAppPurchase } = require('electron').remote +const PRODUCT_IDS = ['id1', 'id2'] + +// Listen for transactions as soon as possible. +inAppPurchase.on('transactions-updated', (event, transactions) => { + if (!Array.isArray(transactions)) { + return + } + + // Check each transaction. + transactions.forEach(function (transaction) { + var payment = transaction.payment + + switch (transaction.transactionState) { + case 'purchasing': + console.log(`Purchasing ${payment.productIdentifier}...`) + break + case 'purchased': + + console.log(`${payment.productIdentifier} purchased.`) + + // Get the receipt url. + let receiptURL = inAppPurchase.getReceiptURL() + + console.log(`Receipt URL: ${receiptURL}`) + + // Submit the receipt file to the server and check if it is valid. + // @see https://developer.apple.com/library/content/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html + // ... + // If the receipt is valid, the product is purchased + // ... + + // Finish the transaction. + inAppPurchase.finishTransactionByDate(transaction.transactionDate) + + break + case 'failed': + + console.log(`Failed to purchase ${payment.productIdentifier}.`) + + // Finish the transaction. + inAppPurchase.finishTransactionByDate(transaction.transactionDate) + + break + case 'restored': + + console.log(`The purchase of ${payment.productIdentifier} has been restored.`) + + break + case 'deferred': + + console.log(`The purchase of ${payment.productIdentifier} has been deferred.`) + + break + default: + break + } + }) +}) + +// Check if the user is allowed to make in-app purchase. +if (!inAppPurchase.canMakePayments()) { + console.log('The user is not allowed to make in-app purchase.') +} + +// Retrieve and display the product descriptions. +inAppPurchase.getProducts(PRODUCT_IDS, (products) => { + // Check the parameters. + if (!Array.isArray(products) || products.length <= 0) { + console.log('Unable to retrieve the product informations.') + return + } + + // Display the name and price of each product. + products.forEach((product) => { + console.log(`The price of ${product.localizedTitle} is ${product.formattedPrice}.`) + }) + + // Ask the user which product he/she wants to purchase. + // ... + let selectedProduct = products[0] + let selectedQuantity = 1 + + // Purchase the selected product. + inAppPurchase.purchaseProduct(selectedProduct.productIdentifier, selectedQuantity, (isProductValid) => { + if (!isProductValid) { + console.log('The product is not valid.') + return + } + + console.log('The payment has been added to the payment queue.') + }) +}) +``` \ No newline at end of file diff --git a/filenames.gypi b/filenames.gypi index 025469c73e89..ebaeb395f3e9 100644 --- a/filenames.gypi +++ b/filenames.gypi @@ -240,6 +240,8 @@ 'atom/browser/mac/in_app_purchase.mm', 'atom/browser/mac/in_app_purchase_observer.h', 'atom/browser/mac/in_app_purchase_observer.mm', + 'atom/browser/mac/in_app_purchase_product.h', + 'atom/browser/mac/in_app_purchase_product.mm', 'atom/browser/native_browser_view.cc', 'atom/browser/native_browser_view.h', 'atom/browser/native_browser_view_mac.h', diff --git a/spec/api-in-app-purchase-spec.js b/spec/api-in-app-purchase-spec.js index 2c5c9c17da9f..4ebc02f2c797 100644 --- a/spec/api-in-app-purchase-spec.js +++ b/spec/api-in-app-purchase-spec.js @@ -15,6 +15,14 @@ describe('inAppPurchase module', function () { inAppPurchase.canMakePayments() }) + it('finishAllTransactions() does not throw', () => { + inAppPurchase.finishAllTransactions() + }) + + it('finishTransactionByDate() does not throw', () => { + inAppPurchase.finishTransactionByDate(new Date().toISOString()) + }) + it('getReceiptURL() returns receipt URL', () => { assert.ok(inAppPurchase.getReceiptURL().endsWith('_MASReceipt/receipt')) }) @@ -32,4 +40,11 @@ describe('inAppPurchase module', function () { done() }) }) + + it('getProducts() returns an empty list when getting invalid product', (done) => { + inAppPurchase.getProducts(['non-exist'], (products) => { + assert.ok(products.length === 0) + done() + }) + }) })