Skip to content
Merged
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
Binary file removed .DS_Store
Binary file not shown.
26 changes: 21 additions & 5 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,39 @@ on:

jobs:
build-and-docs:
runs-on: macos-14
# The package deploys to the OS 26 platforms, so the job needs an Xcode 26 SDK.
# Xcode 16.x cannot build it.
runs-on: macos-latest

steps:
- name: Checkout
uses: actions/checkout@v3

- name: Select Xcode 16.2
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2'
xcode-version: latest-stable

- name: Print Swift version
run: swift --version
- name: Check the toolchain is new enough
run: |
# The package deploys to the OS 26 platforms and needs that SDK to build at all.
# Without this check a too-old image fails later, inside compilation, with errors
# that do not name the real cause.
required=26
major=$(xcodebuild -version | head -1 | sed -E 's/Xcode ([0-9]+).*/\1/')
if [ "$major" -lt "$required" ]; then
echo "::error::Xcode $major is too old. This package needs Xcode $required or newer for the OS 26 SDK. Pin a runner image that provides it, or lower the platforms in Package.swift."
exit 1
fi
xcodebuild -version
swift --version

- name: Build Package
run: swift build -v

- name: Run Tests
run: swift test -v

- name: Generate DocC
run: |
mkdir -p docs
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.DS_Store
Sources/.DS_Store
.build/
xcuserdata/
Binary file removed .swiftpm/.DS_Store
Binary file not shown.
Binary file removed .swiftpm/xcode/.DS_Store
Binary file not shown.
Binary file not shown.

This file was deleted.

17 changes: 11 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import PackageDescription
let package = Package(
name: "PurchaseKit",
platforms: [
.iOS(.v15),
.watchOS(.v8),
.tvOS(.v15),
.macOS(.v12),
.macCatalyst(.v15),
.visionOS(.v1)
.iOS(.v26),
.watchOS(.v26),
.tvOS(.v26),
.macOS(.v26),
.macCatalyst(.v26),
.visionOS(.v26)
],
products: [
.library(
Expand All @@ -27,6 +27,11 @@ let package = Package(
swiftSettings: [
.define("SWIFT_PACKAGE")
]
),
.testTarget(
name: "RKPurchaseKitTests",
dependencies: ["RKPurchaseKit"],
path: "Tests/RKPurchaseKitTests"
)
]
)
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# RKPurchaseKit

[![Swift](https://img.shields.io/badge/swift-6.2-orange.svg)](https://swift.org)
[![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-blue)]()
[![Platforms](https://img.shields.io/badge/platforms-iOS%2026%20%7C%20macOS%2026%20%7C%20watchOS%2026%20%7C%20tvOS%2026%20%7C%20visionOS%2026-blue)]()

A lightweight Swift framework for in-app purchases using StoreKit 2
with full support for Swift Concurrency, async/await, and SPM.
Expand All @@ -17,15 +17,26 @@ https://github.com/Ramiz69/PurchaseKit.git
```
In Package.swift:
```text
.package(url: "https://github.com/Ramiz69/PurchaseKit.git", from: "1.0.5")
.package(url: "https://github.com/Ramiz69/PurchaseKit.git", from: "2.0.0")
```

### ✅ Features
- async/await API
- actor-based PurchasesManager
- DocC documentation
- Static linking support (type: .static)
- Support for iOS, macOS, tvOS, watchOS, visionOS
- Support for iOS, macOS, tvOS, watchOS, visionOS (26 and later)

### ⚠️ Migrating to 2.0

2.0 raises the deployment target and changes several public types. If you are on 1.x:

- **Platforms** now start at iOS/macOS/tvOS/watchOS/visionOS **26**. Earlier releases are no longer supported; stay on 1.0.6 if you need them.
- **`StoreProduct.product` is now optional.** It is `nil` on values you build yourself, because `StoreKit.Product` cannot be constructed outside StoreKit. There is now a public initializer taking the fields directly, so `PurchasesProtocol` can finally be mocked in tests and previews.
- **`ProductType` gained `unknown`** and `PurchasesError` gained `unhandledPurchaseResult`. Exhaustive `switch` statements over either need a new branch. A StoreKit product type this SDK does not recognise now reports `unknown` instead of silently passing as `nonConsumable`.
- **`purchasedProducts` no longer replays past events.** Every access returns a fresh stream, so several observers can watch at once — previously two `for await` loops split the events between them and each missed the rest. Read current state with `hasEntitlement(for:)` or `requestProducts(includingCache:)` when a subscriber starts.
- **`requestProducts()` with no arguments** is now a distinct overload on `PurchasesProtocol`. Calls are unchanged; only a conformer that relied on the default implementation is affected, and that case no longer compiles rather than recursing forever at runtime.
- `PurchasesError` now conforms to `Equatable` and `LocalizedError`, and `PurchasesManager.resolved()` returns the singleton by throwing `notConfigured` instead of trapping like `shared`.

### 📚 Documentation

Expand Down
Binary file removed Sources/.DS_Store
Binary file not shown.
Binary file removed Sources/PurchaseKit/.DS_Store
Binary file not shown.
112 changes: 112 additions & 0 deletions Sources/PurchaseKit/Manager/RKEventBroadcaster.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// RKEventBroadcaster.swift
// RKPurchaseKit
//
// Created by Ramiz Kichibekov on 11.05.2025.
//

import Foundation
import Synchronization

/// Delivers every event to every active subscriber.
///
/// `AsyncStream` has exactly one consumer. Two `for await` loops over the *same* stream do
/// not each receive the full sequence — they split it, so one screen sees an event and the
/// other silently misses it. Handing out one shared stream therefore loses purchase events
/// as soon as a second observer appears. Instead each subscriber gets its own stream, and
/// this type keeps the live continuations so a single yield reaches all of them.
///
/// State lives behind a `Mutex`, which makes the type checked-`Sendable`: the compiler
/// verifies the isolation rather than taking an `@unchecked` assertion on trust.
final class EventBroadcaster<Event: Sendable>: Sendable {

// MARK: Nested types

private struct State {
var continuations: [UUID: AsyncStream<Event>.Continuation] = [:]
var isFinished = false
}

// MARK: Properties

/// Per-subscriber buffer depth.
///
/// Bounded on purpose: the stream is optional to consume, and an unbounded buffer grows
/// for the whole lifetime of the process when nobody drains it. Events describe
/// entitlement changes, so when a slow subscriber overflows, the newest ones are the
/// ones worth keeping.
static var bufferSize: Int { 32 }

private let state = Mutex(State())

// MARK: Internal methods

/// Creates a stream that receives every event yielded from now on.
///
/// Events yielded before this call are not replayed; query the current state directly
/// if you need it. Returns an already-finished stream once ``finish()`` has been called.
func makeStream() -> AsyncStream<Event> {
let id = UUID()
let (stream, continuation) = AsyncStream.makeStream(
of: Event.self,
bufferingPolicy: .bufferingNewest(Self.bufferSize)
)

let hasFinished = state.withLock { state -> Bool in
guard !state.isFinished else { return true }

state.continuations[id] = continuation

return false
}

guard !hasFinished else {
continuation.finish()

return stream
}

// Drop the continuation when the subscriber stops iterating or is cancelled,
// otherwise the registry grows for every screen that ever observed the stream.
// Assigning to an already-terminated continuation runs the handler immediately,
// which covers a subscriber that went away during registration.
continuation.onTermination = { [weak self] _ in
self?.remove(id)
}

return stream
}
/// Sends one event to every active subscriber.
func yield(_ event: Event) {
// Read the targets under the lock, then deliver outside it: delivery can terminate a
// stream, and the resulting `onTermination` calls back into `remove(_:)`, which takes
// the same lock.
let targets = state.withLock { Array($0.continuations.values) }
for continuation in targets {
continuation.yield(event)
}
}
/// Ends every active stream and refuses new ones.
func finish() {
let targets = state.withLock { state -> [AsyncStream<Event>.Continuation] in
state.isFinished = true
let continuations = Array(state.continuations.values)
state.continuations.removeAll()

return continuations
}
for continuation in targets {
continuation.finish()
}
}
/// Number of registered subscribers. Exists so tests can observe cleanup.
var subscriberCount: Int {
state.withLock { $0.continuations.count }
}

// MARK: Private methods

private func remove(_ id: UUID) {
state.withLock { _ = $0.continuations.removeValue(forKey: id) }
}
}
Loading
Loading