ShopKit — Implementation Guide
Total estimated time: 40–60 hours, plus 1–2 weeks of App Store Review iteration.
Day 1 — Project + Sign in with Apple
Step 1. Create project
Standard SwiftUI app, iOS 17 minimum.
Step 2. Capabilities
- Sign in with Apple
- In-App Purchase
- Keychain Sharing (with your team identifier)
Step 3. Sign in with Apple integration
import AuthenticationServices
struct SignInView: View {
var body: some View {
SignInWithAppleButton(.signIn) { request in
request.requestedScopes = [.email]
} onCompletion: { result in
switch result {
case .success(let auth):
handle(auth)
case .failure(let error):
// log
break
}
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)
}
func handle(_ auth: ASAuthorization) {
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential,
let tokenData = cred.identityToken,
let tokenString = String(data: tokenData, encoding: .utf8) else { return }
// Send tokenString to your backend; backend verifies and returns your own session token
}
}
Checkpoint: tap Sign In, complete the Apple flow, receive an identity token.
Day 2 — Keychain auth storage
Step 4. KeychainStore
public actor KeychainStore {
private let service: String
public init(service: String) { self.service = service }
public func save(_ data: Data, for account: String, biometric: Bool = false) throws {
let access = biometric
? SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .userPresence, nil)!
: SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, [], nil)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
public func read(account: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
return result as? Data
}
public func delete(account: String) throws { /* ... */ }
}
Checkpoint: save a token, read it back, delete it, verify deletion.
Day 3–4 — Networking layer
Step 5. Implement APIClient per architecture.md
Step 6. Define request types
struct ArticleListRequest: APIRequest {
typealias Response = [Article]
let path = "/articles"
let method = HTTPMethod.get
let query: [URLQueryItem] = []
let body: Data? = nil
let requiresAuth = true
}
Step 7. URLProtocol stub for tests
final class StubProtocol: URLProtocol {
static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
let (response, data) = handler(self.request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
Use it in tests by configuring a URLSession with URLSessionConfiguration.ephemeral and protocolClasses = [StubProtocol.self].
Checkpoint: write tests for APIClient covering 200, 401-with-refresh, 404, 500, and URLError.notConnectedToInternet. All pass.
Day 5 — Article browsing
Step 8. Article model + HomeView
struct Article: Codable, Identifiable {
let id: String
let title: String
let author: String
let isPro: Bool
let publishedAt: Date
let readTimeMinutes: Int
}
HomeView calls APIClient.send(ArticleListRequest()) in .task, shows a List of cards. Pro articles show a “Pro” badge.
Step 9. Article detail
Tapping a free article opens the detail view. Tapping a Pro article checks SubscriptionStatus.allowsProAccess; if no, push the paywall.
Checkpoint: list renders; Pro gating works (paywall pushes on Pro tap when not subscribed).
Day 6–7 — StoreKit 2 + paywall
Step 10. Create products in App Store Connect
com.yourorg.shopkit.pro.monthly— Auto-Renewing Subscription, $4.99/mocom.yourorg.shopkit.pro.yearly— Auto-Renewing Subscription, $39.99/yr- Subscription group: “ShopKit Pro”
- Introductory offer (7-day free trial) on both, eligibility: New Subscribers
Step 11. StoreKit Configuration file for local testing
Xcode → File → New → StoreKit Configuration → “Sync with App Store Connect.” Use as the StoreKit Configuration in your scheme.
Step 12. SubscriptionWatcher actor
@MainActor
@Observable
public final class SubscriptionStatusStore {
public private(set) var status: SubscriptionStatus = .notSubscribed
private var updateTask: Task<Void, Never>?
public func start() {
updateTask = Task { [weak self] in
for await update in Transaction.updates {
if case .verified(let transaction) = update {
await self?.refreshFromCurrentEntitlements()
await transaction.finish()
}
}
}
Task { await refreshFromCurrentEntitlements() }
}
public func refreshFromCurrentEntitlements() async {
var newStatus: SubscriptionStatus = .notSubscribed
for await result in Transaction.currentEntitlements {
guard case .verified(let txn) = result,
txn.productType == .autoRenewable else { continue }
// Map txn state to SubscriptionStatus
if let expires = txn.expirationDate {
newStatus = txn.offerType == .introductory
? .inFreeTrial(expires: expires)
: .active(expires: expires)
}
}
self.status = newStatus
}
}
Step 13. Paywall view
Product.products(for:) fetches the two products. Display them with Product.displayPrice. Purchase via Product.purchase().
struct PaywallView: View {
@State private var products: [Product] = []
@State private var selected: Product?
var body: some View {
VStack {
// tier cards
Button("Start 7-day free trial") {
Task { await purchase(selected!) }
}
Button("Restore Purchases") {
Task { try? await AppStore.sync() }
}
}
.task {
products = try await Product.products(for: [
"com.yourorg.shopkit.pro.monthly",
"com.yourorg.shopkit.pro.yearly"
])
selected = products.first { $0.id.hasSuffix("yearly") }
}
}
func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let txn) = verification {
await txn.finish()
}
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
// log
}
}
}
Checkpoint: launch with StoreKit Configuration, complete a sandbox purchase, see SubscriptionStatus flip to .inFreeTrial(...).
Day 8 — Cert pinning
Step 14. Add PinnedSessionDelegate from Phase 9 lab 9.2
Configure the URLSession used by APIClient with this delegate. Hardcode the SPKI hashes of your backend’s leaf + backup intermediate.
Checkpoint: verify against your real backend that requests succeed. Then with mitmproxy interposing, verify they fail.
Day 9–10 — GitHub Actions CI/CD
Step 15. .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Test
run: |
xcodebuild test \
-scheme ShopKit \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
-enableCodeCoverage YES \
| xcbeautify
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.0'
- name: Install Fastlane
run: bundle install
- name: Beta lane
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
run: bundle exec fastlane beta
Step 16. Fastlane setup per Phase 10 chapter 5
Step 17. Secrets
MATCH_PASSWORD— passphrase for the encrypted certs repoASC_KEY_ID,ASC_ISSUER_ID,ASC_KEY_CONTENT— App Store Connect API key (base64 of the.p8file)
Checkpoint: open a PR with a test change. CI runs, build + tests pass. Merge it. The deploy job runs and a TestFlight build appears about 25 minutes later.
Day 11–14 — App Store Review
Step 18. Submit for Review
- Compelling screenshots (use Fastlane
snapshot). - Honest Privacy Nutrition Label.
- A demo account for App Review if your backend requires login.
- Review notes explaining how to use the subscription with sandbox testing.
Step 19. Expect 1–3 rejection rounds
Common rejections:
- Unclear paywall fine print → add “Auto-renews unless cancelled 24h before” copy.
- Missing Restore Purchases button → add it.
- Vague Sign in with Apple usage → clarify in App Review Notes.
Iterate, resubmit, ship.
Next: Hardening checklist