Improve in-app purchase for MacOS (#12464)

* Add methods to finish transactions

* Add a method to get the product descriptions from the App Store

* Improve the documentation of a transaction structure

* Add a tutorial for In App Purchase

* Fix typo in In-App Purchase tutorial

* Fix style of In-App Purchase files

* Fix In-App-Purchase product structure conversion in amr64

* Fix code style in In-App Purchase tutorial documentation

* Fix typos in In-App Purchase documentation

* Fix typo in In-App Purchase spec

* Slight style fixes
This commit is contained in:
Adrien Fery 2018-04-05 08:33:13 +02:00 committed by Cheng Zhao
parent 52b1065b3b
commit 5486a65702
13 changed files with 481 additions and 22 deletions

View file

@ -45,6 +45,29 @@ struct Converter<in_app_purchase::Transaction> {
}
};
template <>
struct Converter<in_app_purchase::Product> {
static v8::Local<v8::Value> 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) {

View file

@ -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<InAppPurchase>,
public in_app_purchase::TransactionObserver {
class InAppPurchase : public mate::EventEmitter<InAppPurchase>,
public in_app_purchase::TransactionObserver {
public:
static mate::Handle<InAppPurchase> Create(v8::Isolate* isolate);

View file

@ -19,6 +19,10 @@ typedef base::Callback<void(bool isProductValid)> InAppPurchaseCallback;
bool CanMakePayments(void);
void FinishAllTransactions(void);
void FinishTransactionByDate(const std::string& date);
std::string GetReceiptURL(void);
void PurchaseProduct(const std::string& productID,

View file

@ -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) {

View file

@ -17,9 +17,8 @@
namespace {
using InAppTransactionCallback =
base::RepeatingCallback<
void(const std::vector<in_app_purchase::Transaction>&)>;
using InAppTransactionCallback = base::RepeatingCallback<void(
const std::vector<in_app_purchase::Transaction>&)>;
} // 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() {

View file

@ -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 <string>
#include <vector>
#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<uint32_t> contentLengths;
// Pricing Information
double price = 0.0;
std::string formattedPrice;
// Downloadable Content Information
bool downloadable = false;
};
// --------------------------- Typedefs ---------------------------
typedef base::Callback<void(const std::vector<in_app_purchase::Product>&)>
InAppPurchaseProductsCallback;
// --------------------------- Functions ---------------------------
void GetProducts(const std::vector<std::string>& productIDs,
const InAppPurchaseProductsCallback& callback);
} // namespace in_app_purchase
#endif // ATOM_BROWSER_MAC_IN_APP_PURCHASE_PRODUCT_H_

View file

@ -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 <StoreKit/StoreKit.h>
// ============================================================================
// InAppPurchaseProduct
// ============================================================================
// --------------------------------- Interface --------------------------------
@interface InAppPurchaseProduct : NSObject<SKProductsRequestDelegate> {
@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<in_app_purchase::Product> 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<std::string>& 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

View file

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

View file

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

View file

@ -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 Stores 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.

View file

@ -0,0 +1,125 @@
# In-App Purchase (macOS)
## Preparing
### Paid Applications Agreement
If you haven't already, youll 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
<key>CFBundleIdentifier</key>
<string>com.example.app</string>
```
## 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.')
})
})
```

View file

@ -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',

View file

@ -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()
})
})
})