Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ docs:
--disable-indexing \
--transform-for-static-hosting \
--target AmoreLicensing \
--output-path ./docs
# --enable-experimental-combined-documentation \
--target AmoreStore \
--output-path ./docs \
--enable-experimental-combined-documentation \

docs-preview:
swift package --disable-sandbox preview-documentation --target AmoreLicensing
Expand Down
9 changes: 9 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ let package = Package(
name: "AmoreLicensing",
targets: ["AmoreLicensing"]
),
.library(
name: "AmoreStore",
targets: ["AmoreStore"]
),
],
dependencies: [
.package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"),
Expand All @@ -24,13 +28,18 @@ let package = Package(
.product(name: "JWTKit", package: "jwt-kit"),
],
),
.target(name: "AmoreStore"),
.testTarget(
name: "AmoreLicensingTests",
dependencies: [
"AmoreLicensing",
.product(name: "JWTKit", package: "jwt-kit"),
]
),
.testTarget(
name: "AmoreStoreTests",
dependencies: ["AmoreStore"]
),
],
swiftLanguageModes: [.v6]
)
51 changes: 51 additions & 0 deletions Sources/AmoreStore/AmoreStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

extension URL {
/// Default Amore licensing server. Internal to `AmoreStore` so it does not
/// collide with the public `URL.amoreServer` declared in `AmoreLicensing`.
static let amoreServer = URL(string: "https://api.amore.computer")!
}

/// Lists the products configured for an Amore-licensed app.
///
/// Fetch products with ``products()``, then read ``Product/checkoutURL`` on a
/// returned product to send a customer to Stripe checkout. Use this to build
/// paywalls, pickers, or any view that displays the products and prices
/// configured in the licensing dashboard.
public struct AmoreStore: Sendable {
private let bundleIdentifier: String
private let productsClient: ProductsClient

/// Creates a store client for the given bundle identifier, targeting the Amore server.
/// - Parameter bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`.
public init(bundleIdentifier: String? = nil) {
self.init(bundleIdentifier: bundleIdentifier, baseURL: .amoreServer)
}

/// Creates a store client for the given bundle identifier and server URL.
/// - Parameters:
/// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`.
/// - baseURL: The licensing server base URL.
public init(bundleIdentifier: String? = nil, baseURL: URL) {
self.bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? ""
self.productsClient = HTTPProductsClient(baseURL: baseURL)
}

init(bundleIdentifier: String, productsClient: ProductsClient) {
self.bundleIdentifier = bundleIdentifier
self.productsClient = productsClient
}

/// Returns the products configured for this app. Purchasable products carry a
/// non-`nil` ``Product/checkoutURL``.
/// - Throws: ``StoreError`` if the request fails.
public func products() async throws(StoreError) -> [Product] {
do {
return try await productsClient.fetchProducts(bundleIdentifier: bundleIdentifier)
} catch let error as StoreError {
throw error
} catch {
throw .network(error.localizedDescription)
}
}
}
61 changes: 61 additions & 0 deletions Sources/AmoreStore/Documentation.docc/Getting Started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Getting Started

This article describes how to get started with AmoreStore.

## Installation

In Xcode, go to **File → Add Package Dependencies…** and enter:

```
https://github.com/AmoreComputer/AmoreKit
```

Or add it to your `Package.swift`:

```swift
.package(url: "https://github.com/AmoreComputer/AmoreKit", from: "0.1")
```

## AmoreStore

To get started with AmoreStore, create an instance of ``AmoreStore``. By default it uses your app's `Bundle.main.bundleIdentifier`.

```swift
let store = AmoreStore()
```

## Fetching Products

Call ``AmoreStore/products()`` to fetch the products configured for your app.

```swift
let products = try await store.products()
```

> Note: ``AmoreStore/products()`` throws ``StoreError`` with detailed information about what went wrong.

## Displaying Prices

Each ``Product`` carries an optional ``Product/price`` with a localized, display-ready string.

```swift
ForEach(products) { product in
HStack {
Text(product.name)
Spacer()
if let displayPrice = product.displayPrice {
Text(displayPrice)
}
}
}
```

Use ``Price/recurringInterval`` to tell one-time purchases from subscriptions.

## Checkout

Every purchasable ``Product`` carries a ``Product/checkoutURL``. Open it to send the customer to Stripe checkout.

```swift
NSWorkspace.shared.open(product.checkoutURL)
```
26 changes: 26 additions & 0 deletions Sources/AmoreStore/Documentation.docc/Index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ``AmoreStore``

A macOS SDK for listing an app's products and sending customers to checkout.

## Overview

AmoreStore is the store SDK for [Amore](https://amore.computer).

AmoreStore fetches the products configured for your app in the licensing dashboard and exposes their prices and checkout URLs. Use it to build paywalls, pickers, or any view that displays what a customer can buy.

## Topics

### Articles

- <doc:Getting-Started>

### Essentials

- ``AmoreStore``
- ``Product``
- ``Price``
- ``RecurringInterval``

### Errors

- ``StoreError``
6 changes: 6 additions & 0 deletions Sources/AmoreStore/Documentation.docc/images/heart.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions Sources/AmoreStore/Documentation.docc/theme-settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"theme": {
"aside": {
"border-radius": "6px",
"border-style": "solid",
"border-width": "2px"
},
"border-radius": "0",
"button": {
"border-radius": "16px",
"border-width": "1px",
"border-style": "solid"
},
"code": {
"border-radius": "16px",
"border-width": "1px",
"border-style": "solid"
},
"color": {
"amore": "#f09000",
"documentation-intro-fill": { "dark": "#000", "light": "#fff" },
"documentation-intro-accent": "var(--color-jwtkit)",
"logo-base": { "dark": "#fff", "light": "#000" },
"logo-shape": { "dark": "#000", "light": "#fff" },
"link": { "dark": "var(--color-amore)", "light": "var(--color-amore)" },
"fill": { "dark": "#222", "light": "#eee" },
"button-background": "var(--color-amore)",
"fill-blue": "var(--color-amore)",
"figure-blue": "var(--color-amore)"
},
"icons": {
"technology": "/images/AmoreStore/heart.svg"
}
}
}
46 changes: 46 additions & 0 deletions Sources/AmoreStore/HTTPProductsClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

struct HTTPProductsClient: ProductsClient {
private let baseURL: URL

init(baseURL: URL) {
self.baseURL = baseURL
}

func fetchProducts(bundleIdentifier: String) async throws -> [Product] {
let url = baseURL
.appendingPathComponent("v1/public/apps")
.appendingPathComponent(bundleIdentifier)
.appendingPathComponent("products")

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")

let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw StoreError.network(error.localizedDescription)
}

guard let http = response as? HTTPURLResponse else {
throw StoreError.network("Invalid response from server")
}
switch http.statusCode {
case 200...299:
do {
return try JSONDecoder().decode([Product].self, from: data)
} catch {
throw StoreError.network("Could not decode product list: \(error.localizedDescription)")
}
case 404:
throw StoreError.appNotFound
case 429:
throw StoreError.rateLimited
default:
throw StoreError.serverError(statusCode: http.statusCode)
}
}
}
47 changes: 47 additions & 0 deletions Sources/AmoreStore/Models/Price.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// Pricing information for a product as configured in Stripe.
public struct Price: Hashable, Codable, Sendable {
/// Amount in the smallest unit of `currency` (e.g. cents for USD, yen for JPY).
public var unitAmount: Int
/// ISO 4217 currency code (e.g. `"USD"`).
public var currency: String
/// Billing interval for recurring prices, or `nil` for one-time purchases.
public var recurringInterval: RecurringInterval?

public init(unitAmount: Int, currency: String, recurringInterval: RecurringInterval?) {
self.unitAmount = unitAmount
self.currency = currency
self.recurringInterval = recurringInterval
}
}

extension Price {

/// The decimal representation of the cost of the product in ``currency``.
public var decimalAmount: Decimal {
Decimal(unitAmount) / pow(Decimal(10), Self.fractionDigits(for: currency))
}

/// The localized string representation of the product price, suitable for display.
public var displayPrice: String {
decimalAmount.formatted(.currency(code: currency))
}

/// Minor-unit exponent for `currency` (2 for USD, 0 for JPY, 3 for BHD),
private static func fractionDigits(for currency: String) -> Int {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currency
return formatter.maximumFractionDigits
}

}

extension Price: CustomStringConvertible {

public var description: String {
displayPrice
}

}
60 changes: 60 additions & 0 deletions Sources/AmoreStore/Models/Product.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

/// A product offered by an Amore-licensed application.
public struct Product: Identifiable, Hashable, Codable, Sendable {
/// Server identifier for this product.
public var id: UUID
/// Display name configured by the app owner.
public var name: String
/// License duration in seconds, or `nil` for non-expiring licenses.
public var durationInSeconds: Int?
/// Maximum number of devices that can activate a single license for this product.
public var deviceLimit: Int
/// Pricing information, or `nil` when no price is configured.
public var price: Price?

/// The checkout URL for this product
///
/// Open it to send the customer to checkout, for example:
/// ```swift
/// NSWorkspace.shared.open(product.checkoutURL)
/// ```
public var checkoutURL: URL

public init(
id: UUID,
name: String,
durationInSeconds: Int?,
deviceLimit: Int,
price: Price?,
checkoutURL: URL
) {
self.id = id
self.name = name
self.durationInSeconds = durationInSeconds
self.deviceLimit = deviceLimit
self.price = price
self.checkoutURL = checkoutURL
}
}

public extension Product {

/// The localized string representation of the product price, suitable for display.
var displayPrice: String? {
price?.displayPrice
}

}

extension Product: CustomStringConvertible {

public var description: String {
if let displayPrice {
"\(name) (\(id)): \(displayPrice)"
} else {
"\(name) (\(id))"
}
}

}
7 changes: 7 additions & 0 deletions Sources/AmoreStore/Models/RecurringInterval.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// Billing intervals supported by Stripe recurring prices.
public enum RecurringInterval: String, Hashable, Codable, Sendable {
case day
case week
case month
case year
}
5 changes: 5 additions & 0 deletions Sources/AmoreStore/ProductsClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

protocol ProductsClient: Sendable {
func fetchProducts(bundleIdentifier: String) async throws -> [Product]
}
Loading
Loading