The subscription machinery leaves the code — Kanban/Tier excised, StoreKit wiring unwound

The 2026-08-08 one-version ruling (12-editions.md ▸ PIVOT 2026-08-08) carried out: Kanban/Tier/
deleted wholesale (Tier, ProEntitlement, ProProducts, ProStorefront, the never-rendered
ProSettingsSection) with TierTests and Configuration.storekit, whose project.yml resource entry
and scheme storeKitConfiguration go with it. AppModel loses the entitlement, the currentTier
seam, BoardSession.tier, and the purchase flow's reopenOpenBoards (its only caller was the
storefront); AppDelegate's launch keeps only the appearance application. The three tests
pinning the recorded tier and the reopen are deleted with their subject. The network-client
entitlement stays — the sync capability to come needs it regardless — and the HistoryProviding
seam stands untouched. 2,686 unit tests green (2,707 minus the 21 that tested what left).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 13:18:54 -04:00
parent 871083e5ca
commit e1f89d9cf9
15 changed files with 48 additions and 1465 deletions
-63
View File
@@ -1,63 +0,0 @@
{
"identifier" : "A9F1C4E2-7B30-4D6A-9E51-1C7D2B8F0A34",
"nonRenewingSubscriptions" : [
],
"products" : [
],
"settings" : {
"_askToBuyEnabled" : false,
"_billingIssuesEnabled" : false,
"_disableDialogs" : false,
"_failTransactionsEnabled" : false,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
]
},
"subscriptionGroups" : [
{
"id" : "20250730",
"localizations" : [
],
"name" : "Lanework Pro",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "2.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "2025073001",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Git-backed board history and sync.",
"displayName" : "Lanework Pro",
"locale" : "en_US"
}
],
"productID" : "dev.rzen.indie.kanban.pro.monthly",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Lanework Pro Monthly",
"subscriptionGroupID" : "20250730",
"type" : "RecurringSubscription",
"winbackOffers" : [
]
}
]
}
],
"version" : {
"major" : 4,
"minor" : 0
}
}
+3 -6
View File
@@ -2,12 +2,9 @@ import IndieAbout
/// **The About box's configuration.** /// **The About box's configuration.**
/// ///
/// **Dormant since the 2026-08-07 pivot** (12-editions.md PIVOT 2026-08-07 git leaves the /// **One box, one copyright line, shown to everyone** (12-editions.md PIVOT 2026-08-08 one
/// paywall): this box used to carry a second copyright line naming Lanework Pro, one of the three /// version, everything free): this box once carried a second copyright line naming a paid edition,
/// places 12's pre-pivot "Tier naming" section named the subscription. Git left the paywall, the /// back when the app had editions to name. It has none, so the line is gone and the box is plain.
/// base/Pro split is being re-decided, and until it's ruled, no surface in the app names or sells
/// Pro so the line comes out, and the box goes back to a plain, one-line copyright shown to
/// everyone.
enum AboutBox { enum AboutBox {
/// The About window's content: version/build/date from the stamped Info.plist /// The About window's content: version/build/date from the stamped Info.plist
+5 -14
View File
@@ -16,24 +16,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "app-delegate")
/// Starts the Pro entitlement's transaction listener (12-editions.md The entitlement). /// Applies the stored appearance override to `NSApp` (03-board-ui.md Toolbar).
/// ///
/// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is* /// **Here rather than in `AppModel.init`**, and the distinction matters: a unit-test host *is*
/// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch /// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch
/// (`AppStateHome.isUnitTestHost`). Building the entitlement there costs one `UserDefaults` read; /// (`AppStateHome.isUnitTestHost`). `AppearanceStore.init` only reads a default; this is the one
/// acquiring a StoreKit listener there would give every test run a live `Transaction.updates` /// call that hands its answer to `NSApp` the global side effect `KanbanApp.init` must not
/// subscription for no reason. Launch is the honest home for a listener, and this is the app's. /// carry (`AppearanceStore.applyCurrent`). Launch is the honest home for it, and this is the
/// /// app's.
/// It starts nothing the board-open path waits on: the listener writes cached facts that a
/// *later* composition may read, and never reaches into a session that is already open
/// (`ProEntitlement`).
///
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
/// only reads; this is the one call that hands its answer to `NSApp` the global side effect
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
/// (`AppearanceStore.applyCurrent`).
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
appModel?.entitlement.start()
appModel?.appearance.applyCurrent() appModel?.appearance.applyCurrent()
} }
+9 -138
View File
@@ -146,16 +146,6 @@ public enum AppPreferences {
public static var appearance: AppAppearance? { public static var appearance: AppAppearance? {
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:)) UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
} }
/// The cached subscription facts behind the tier decision (12-editions.md The entitlement)
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
///
/// A scalar default rather than a file in `AppStateHome` because it is two fields, which is the
/// line that type's own note draws. **Not a secret and not a receipt**: the signed transaction
/// store is StoreKit's and stays StoreKit's; this is a *cache of the last answer* whose worst
/// case if edited by hand is one wrong tier until the next refresh corrects it, which is the
/// same self-correction a fresh install already relies on.
public static let subscriptionFactsKey = "subscriptionFacts"
} }
// MARK: - Launch failures // MARK: - Launch failures
@@ -323,36 +313,6 @@ public final class AppModel {
/// reads the pasteboard once and collects every staged tree it no longer names. /// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard: ClipboardStore public let clipboard: ClipboardStore
// MARK: The entitlement
/// **Lanework Pro's entitlement** (12-editions.md The entitlement) the cached, local answer
/// to "is this a subscriber?", owned here for the registries' reason: it is app-scoped, and a
/// test holds its own over its own defaults rather than colliding with the app's.
///
/// Nothing on the board-open path awaits anything through this object. See `ProEntitlement` for
/// why that is a property of its shape rather than a rule somebody has to remember.
public let entitlement: ProEntitlement
/// **The tier a board session composes under**, as an injectable seam.
///
/// Defaulted to the real entitlement's local read and separated from it so a test binds a tier
/// without needing a StoreKit transaction, an App Store account, or a second `AppModel`
/// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor
/// state, and `@ObservationIgnored` because nothing renders from it the tier reaches the UI,
/// where it reaches it at all, through `entitlement`.
///
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
///
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall, and the 2026-08-08
/// ruling then excised git entirely): the answer is still read and still recorded on the session
/// (`BoardSession.tier`), and **nothing consults it any more** `makeHistoryProvider` lost the
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
/// what it does not do is decide undo.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
// MARK: The provider seam // MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a /// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
@@ -361,10 +321,11 @@ public final class AppModel {
/// **Every board gets the native stack, and the seam has one answer** /// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed /// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board /// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not its tier (12 PIVOT 2026-08-07 took the last row the tier decided), /// decides this any more: not what the user paid (12-editions.md PIVOT 2026-08-08 one
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board /// version, everything free, no edition axis left to consult), not whether it sits inside
/// nobody has done anything special to and a board living in a user's git repo bind the same /// somebody's repository, not what is on disk beside it. A board nobody has done anything
/// stack, which was already true before this ruling and is now true by construction. /// special to and a board living in a user's git repo bind the same stack, which was already
/// true before this ruling and is now true by construction.
/// ///
/// ### Why it is still a seam /// ### Why it is still a seam
/// ///
@@ -421,30 +382,12 @@ public final class AppModel {
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which /// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate. /// answers the empty way over an absent substrate.
/// ///
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned /// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git /// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays
/// stack. It stays a `var` because a second provider is a live possibility /// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md`
/// (`strategy/01-git-excision.md` Reversibility) and because nothing is bought by tightening /// Reversibility) and because nothing is bought by tightening it.
/// it; a tier lapse could never touch it `tier` has no setter, and since PIVOT 2026-08-07 it
/// has no say in this either.
public var history: (any HistoryProviding)? public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement) recorded,
/// and **dormant since PIVOT 2026-08-07**.
///
/// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind
/// "a lapse never interrupts an open session": there is no setter, no observation, and
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// mid-session is a fact about the *next* open and about nothing already on screen.
///
/// **Nothing reads it.** Git left the paywall (12 PIVOT 2026-08-07) and then left the app
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
/// this. It stays recorded because the entitlement's machinery stays built and correct for
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
/// a composition-time answer the way the provider binding is: whatever asks next must get what
/// this board opened with, never what the entitlement says at render time.
public let tier: Tier
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's /// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows /// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain. /// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -670,14 +613,6 @@ public final class AppModel {
zoom = BoardZoomStore(defaults: preferences) zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences) appearance = AppearanceStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does.
let entitlement = ProEntitlement(defaults: preferences)
self.entitlement = entitlement
// Bound after the stored properties are in place, so the closure captures the object rather
// than a half-built `self`. This is the app's default wiring; a test that wants a tier
// assigns over it.
currentTier = { entitlement.tier }
// Read once here rather than lazily, so File Open Recent is populated from the app's first // Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled // menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at // in only after the first close would look broken. It costs one bookmark-resolution sweep at
@@ -836,16 +771,6 @@ public final class AppModel {
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user /// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed. /// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// **The entitlement read** (12-editions.md The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition the open path
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
//
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the paywall;
// `strategy/01-git-excision.md`, ruled 2026-08-08 git left the app). This line used to sit
// on the same line as the git state it gated; both the gate and the state are gone, and what
// is left is a dormant fact kept for the base/Pro split still to be ruled.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown` // The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with // below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior". // the board session and dies at close/quit ... standard macOS behavior".
@@ -861,7 +786,6 @@ public final class AppModel {
store: store, store: store,
recordID: recordID, recordID: recordID,
history: history, history: history,
tier: tier,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the // The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to // other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store // resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -1131,59 +1055,6 @@ public final class AppModel {
return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true } return session.cardRefs.contains { cardSessions[$0]?.holdsUnsavedContent == true }
} }
/// **The purchase flow's reopen offer, carried out** (12-editions.md The entitlement:
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate").
///
/// ### Close and open, through the ordinary paths
///
/// There is no reopen-in-place mechanism here and deliberately so: the provider binding is a
/// composition-time fact, so "apply the new tier to this board" *means* end its session and
/// compose a new one. Doing that through `closeBoard` and `openBoard` the same two calls W and
/// welcome make is what keeps every guarantee those paths carry: the close flush runs in its
/// fixed order (card windows, pending work, registry stamp, teardown), the reopen resolves and
/// re-scopes the board's URL exactly as a fresh open does, and the registry records both.
///
/// ### Why the window is dismissed rather than reused
///
/// A board window's identity is its root path (`BoardWindowRef`), so reopening the same board
/// hands `openWindow(value:)` a ref it already has a window for which *focuses* that window
/// instead of building a new one, and the window it would focus is one whose host has already run
/// its one-shot load. Dismissing first is what makes the reopen an open. The dismissals are all
/// issued before any reopen, then given a run-loop turn to land: SwiftUI processes a window's
/// teardown asynchronously, and asking for a value's window in the same turn it was dismissed is
/// the one way this sequence can produce a focused corpse.
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
/// "subscribe takes effect at each board's next open" (12 The entitlement) needs one, not
/// because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
guard !refs.isEmpty else { return }
// The store's `rootURL` rather than the ref's path: a board renamed while open keeps the
// path it was opened with, and reopening it there would open nothing (`BoardStore.rootURL`).
var roots: [URL] = []
for ref in refs {
guard let session = sessions[ref] else { continue }
roots.append(session.store.rootURL)
await closeBoard(ref: ref, cause: .userClose)
windowDismisser?(value: ref)
}
// One run-loop turn for the dismissals see the note above. `Task.sleep` rather than
// `Task.yield` because the main run loop, not the cooperative pool, is what has to advance.
try? await Task.sleep(for: .milliseconds(150))
for root in roots {
openBoard(at: root)
}
}
/// Quit: the same sequence, once per open board, **sequentially**. /// Quit: the same sequence, once per open board, **sequentially**.
/// ///
/// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than /// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than
+8 -8
View File
@@ -379,15 +379,15 @@ private struct RecentBoardRow: View {
/// The app's preferences (, 11-command-nexus.md). /// The app's preferences (, 11-command-nexus.md).
/// ///
/// One section today: "Restore open boards at launch", whose preference gates only whether the /// One section, and one section is all there is: "Restore open boards at launch", whose preference
/// registry's open-now flags are *consulted* at launch the flags themselves are maintained either /// gates only whether the registry's open-now flags are *consulted* at launch the flags themselves
/// way, which is what keeps crash recovery working for a user who has restoration turned off and then /// are maintained either way, which is what keeps crash recovery working for a user who has
/// turns it back on. /// restoration turned off and then turns it back on.
/// ///
/// **The Pro section is dormant, not deleted** (12-editions.md PIVOT 2026-08-07 git leaves the /// **There is no purchase section, and nothing to build one out of** (12-editions.md PIVOT
/// paywall): `ProSettingsSection` still exists and still compiles, but this scene no longer renders /// 2026-08-08 one version, everything free): the subscription machinery this pane once hosted is
/// it the base/Pro split is being re-decided, and until it's ruled, no surface in the app names or /// excised, not dormant. A paid tier returns as a fresh design pass, with the iPhone companion and
/// sells Lanework Pro. /// sync, and will bring its own surface.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey) @AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
+2 -2
View File
@@ -181,8 +181,8 @@ struct KanbanApp: App {
// The About window (11-command-nexus.md files About under the app menu's standard // The About window (11-command-nexus.md files About under the app menu's standard
// furniture): icon, version/build/date from the Info.plist that `update_build_info.sh` // furniture): icon, version/build/date from the Info.plist that `update_build_info.sh`
// stamped at build time never a hardcoded string with the version line opening the // stamped at build time never a hardcoded string with the version line opening the
// bundled changelog. `AboutBox` names no tier (12-editions.md PIVOT 2026-08-07) one box, // bundled changelog. `AboutBox` names no edition (12-editions.md PIVOT 2026-08-08)
// every tier, identical. // there is one version of this app, so there is one box.
IndieAboutCommand(configuration: AboutBox.configuration) IndieAboutCommand(configuration: AboutBox.configuration)
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board, // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board,
-259
View File
@@ -1,259 +0,0 @@
import Foundation
import Observation
import StoreKit
import os
/// **Lanework Pro's entitlement** the thin StoreKit 2 adapter over the pure decision in
/// `Tier.resolve(from:now:)` (12-editions.md The entitlement).
///
/// ### The open path never waits on this, and never reaches the network through it
///
/// "Pro state is read from StoreKit's signed on-device transaction store at board-session
/// composition the open path gains no network dependency" (12). That is enforced by shape, not by
/// care: what a composing board session reads is `tier`, a synchronous computed property over
/// `facts`, which is an in-memory value restored from `UserDefaults` at init. No `await`, no
/// `AsyncSequence`, no `AppStore.sync()`, no product load. The two methods that *do* touch StoreKit
/// `start()` and `refreshFromLocalTransactions()` are called from the app's launch and from the
/// Settings Pro section, and both only ever *write* `facts` for a later composition to read.
///
/// Even those two are local reads. `Transaction.currentEntitlements` is StoreKit's own signed
/// on-device transaction store; it answers offline, which is the whole reason 12 could rule the open
/// path network-free while still gating on a subscription. The genuinely networked calls loading
/// products for their localized price, `AppStore.sync()` live in `ProStorefront`, which only the
/// Settings section ever builds.
///
/// ### A lapse never rebinds an open session
///
/// "A lapse never interrupts an open session: an open board finishes with the provider it composed;
/// the next open composes the native stack over inert `.git`" (12). This type is `@Observable`
/// because the Settings section renders from it and *nothing else* observes it. `AppModel` reads
/// it through `currentTier`, once, inside `beginSession`; the answer is then a `let` on
/// `AppModel.BoardSession`. There is deliberately no path from a `facts` change back into an open
/// session: no observer registration, no notification, no delegate. Adding one would be the bug, not
/// the feature.
///
/// ### Why the facts are cached rather than re-derived
///
/// See `SubscriptionFacts` in short, StoreKit ages an expired subscription out of
/// `currentEntitlements` locally, so an offline device and a real lapse are indistinguishable from
/// that property alone, and 12's offline grace has to tell them apart. The cache is what "the last
/// known state" means, and `adopt(_:)` is what "until StoreKit actually refreshes and answers" means.
@MainActor
@Observable
public final class ProEntitlement {
// MARK: The facts
/// What StoreKit last told the app, cached across launches.
///
/// `private(set)`: every write goes through `adopt(_:)`, which is also the write-through to
/// `UserDefaults`. A second way to set this would be a second way for the cache and the memory to
/// disagree.
public private(set) var facts: SubscriptionFacts
/// **The composition-time read.** Synchronous, local, and the only member the board-open path
/// ever touches see the type's note.
///
/// `Date()` rather than an injected clock: the *decision* takes its date as a parameter and is
/// tested that way (`Tier.resolve`), so the one place a real clock has to enter is here, at the
/// edge, where there is nothing left to get wrong.
public var tier: Tier {
Tier.resolve(from: facts, now: Date())
}
// MARK: Storage
@ObservationIgnored
private let defaults: UserDefaults
/// The `Transaction.updates` listener, held so it can be cancelled and so `start()` is idempotent.
@ObservationIgnored
private var updatesTask: Task<Void, Never>?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "entitlement")
/// The app builds one of these over `UserDefaults.standard`; a test passes its own, for the
/// reason every other injected store in this app takes its home as a parameter (`AppStateHome`)
/// a suite that wrote the real domain would be editing the developer's own subscription state.
///
/// **The initializer reads the cache and nothing else.** It starts no task and touches no
/// StoreKit API, so constructing an `AppModel` which a unit-test host does on every launch
/// costs one `UserDefaults` read.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
facts = Self.cachedFacts(in: defaults)
}
deinit {
updatesTask?.cancel()
}
// MARK: - Adopting an answer
/// Records what StoreKit said, in memory and in the cache.
///
/// **This is the "until StoreKit actually refreshes and answers" of 12's offline-grace rule**, in
/// both directions. Handed live facts it moves the expiry forward, so a renewal the device just
/// learned about ends any hold that was standing. Handed `.none` it ends the entitlement outright,
/// which is what a *definitive* withdrawal looks like: a refund, a revocation, or a subscription
/// group whose status reads expired (`ProStorefront.reconcile`). What it is never called with is
/// "`currentEntitlements` came back empty" see `refreshFromLocalTransactions()` for why that is
/// silence rather than an answer.
///
/// Public because the Settings section's storefront is the other half of this: the networked
/// reads live over there (12: the open path stays local), and this is where their conclusions
/// land.
public func adopt(_ newFacts: SubscriptionFacts) {
guard newFacts != facts else { return }
facts = newFacts
Self.cache(newFacts, in: defaults)
Self.logger.debug("entitlement facts adopted; tier is now \(self.tier.rawValue, privacy: .public)")
}
// MARK: - StoreKit
/// Begins listening for transaction changes, and takes one local reading.
///
/// Called once, from the app's launch (`AppDelegate.applicationDidFinishLaunching`) never from
/// `AppModel.init`, so that a unit-test host constructing a model does not acquire a StoreKit
/// listener as a side effect.
///
/// `Transaction.updates` is StoreKit's out-of-band channel: a renewal that landed while the app
/// was closed, a purchase made on another device, a refund the App Store processed. It is *not*
/// polling and it is not a network call this app makes it is a delivery. Handling it here is
/// what keeps the cached facts from needing anybody to visit Settings.
///
/// Idempotent: a second call is a no-op, because a second listener would finish every transaction
/// twice.
public func start() {
guard updatesTask == nil else { return }
updatesTask = Task { [weak self] in
for await result in Transaction.updates {
guard let self else { return }
await handle(result)
}
}
Task { [weak self] in
await self?.refreshFromLocalTransactions()
}
}
/// Re-reads StoreKit's **local** signed transaction store and adopts what it finds.
///
/// ### An empty result is silence, not an answer
///
/// The one subtle line in this file. `Transaction.currentEntitlements` yields only entitlements
/// StoreKit currently considers valid, and that validity is computed on-device from the last
/// signed transaction it holds so a subscription drops out of it the moment its cached expiry
/// passes, **whether or not the device has been able to ask the App Store whether it renewed**.
/// Treating an empty result as "the subscription is over" would therefore lapse every offline
/// user at their renewal date, which is precisely the outcome 12 The entitlement rules out:
/// "an on-disk expiry passing while offline, with the last known state active and auto-renew on,
/// holds the entitlement until StoreKit actually refreshes and answers."
///
/// So an empty result **leaves the cache alone** and the pure decision does the rest: an expiry
/// still in the future keeps the user Pro (rule 2), a passed expiry with auto-renew on holds
/// (rule 3), and a passed expiry with auto-renew off a cancellation, which StoreKit told us
/// about *before* it ran out lapses on its own (rule 4). The definitive endings arrive through
/// `adopt(.none)`: a revocation seen here, or the subscription-group status the Settings section
/// reads when the user goes looking.
///
/// The honest residual is 12's own accepted trade: a user who cancels while offline, quits, and
/// never opens Settings again holds Pro until StoreKit delivers an update. "A wrong hold gives
/// away days of local commits Apple's own billing grace makes the same trade."
public func refreshFromLocalTransactions() async {
var newest: SubscriptionFacts?
for await result in Transaction.currentEntitlements {
guard case let .verified(transaction) = result else {
// An unverified transaction is not evidence of anything. Skipped rather than treated
// as a lapse, for the empty-result reason above.
continue
}
guard ProProducts.all.contains(transaction.productID) else { continue }
if transaction.revocationDate != nil {
// A refund or a family-sharing withdrawal StoreKit answering, definitively.
adopt(.none)
return
}
let candidate = await Self.facts(of: transaction)
if newest == nil || Self.expiry(of: candidate) > Self.expiry(of: newest!) {
newest = candidate
}
}
guard let newest else {
Self.logger.debug("no current Pro entitlement in the local store; the cached facts stand")
return
}
adopt(newest)
}
/// One current entitlement, reduced to the two facts the decision needs.
///
/// `willAutoRenew` comes from the subscription group's renewal info, which is the only place
/// StoreKit publishes it. When it cannot be read an unverified renewal info, a status call that
/// throws the answer defaults to `true`, which is the paying user's direction and the same one
/// 12's grace rule already chose everywhere else it had to pick.
///
/// A current entitlement with **no** expiration date is given `.distantFuture` rather than `nil`,
/// because `nil` means "no cached transaction" to `SubscriptionFacts` and this is the opposite of
/// that: a live entitlement that does not expire. Not a state an auto-renewable subscription
/// reaches today; spelled out so it cannot become one silently.
private static func facts(of transaction: Transaction) async -> SubscriptionFacts {
var willAutoRenew = true
if let status = try? await transaction.subscriptionStatus,
case let .verified(renewalInfo) = status.renewalInfo {
willAutoRenew = renewalInfo.willAutoRenew
}
return SubscriptionFacts(
expiration: transaction.expirationDate ?? .distantFuture,
willAutoRenew: willAutoRenew
)
}
/// A transaction delivered out of band. Finished, then folded into the facts by a fresh local
/// read the same read every other path uses, so there is one reduction rule and not two.
///
/// Finishing is not optional bookkeeping: an unfinished transaction is redelivered forever, and
/// for an auto-renewable subscription there is no content to deliver first the entitlement
/// *is* the delivery.
private func handle(_ result: VerificationResult<Transaction>) async {
guard case let .verified(transaction) = result else { return }
guard ProProducts.all.contains(transaction.productID) else { return }
await transaction.finish()
await refreshFromLocalTransactions()
}
private static func expiry(of facts: SubscriptionFacts) -> Date {
facts.expiration ?? .distantPast
}
// MARK: - The cache
/// Read at init, written by `adopt(_:)`. JSON in a scalar default, beside the app's other
/// scalars (`AppPreferences`) rather than in `AppStateHome` this is two fields, not a file
/// store, and `AppStateHome`'s own note draws that line.
///
/// A cache that will not decode is treated as no cache at all: the tier reads free and the next
/// refresh rebuilds it. Losing it costs a user with an active subscription nothing (StoreKit's
/// own store still holds the transaction) and costs a user mid-grace their hold, which is the
/// safe direction to fail in.
private static func cachedFacts(in defaults: UserDefaults) -> SubscriptionFacts {
guard let data = defaults.data(forKey: AppPreferences.subscriptionFactsKey),
let decoded = try? JSONDecoder().decode(SubscriptionFacts.self, from: data) else {
return .none
}
return decoded
}
private static func cache(_ facts: SubscriptionFacts, in defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(facts) else {
logger.error("subscription facts could not be encoded; the cache is unchanged")
return
}
defaults.set(data, forKey: AppPreferences.subscriptionFactsKey)
}
}
-49
View File
@@ -1,49 +0,0 @@
import Foundation
/// **The App Store Connect configuration this app mirrors in code** the subscription's product
/// identifier and the group it belongs to.
///
/// ### These strings are configuration, not invention
///
/// Nothing here is created at runtime. A subscription group and its products are made **by hand** in
/// App Store Connect, against the record `dev.rzen.indie.Kanban` (12-editions.md Distribution;
/// RELEASE.md Lanework Pro), and StoreKit will only ever return products whose identifiers match
/// what that account actually declares. This file is the local half of that agreement: change a
/// string here without changing it there and the Settings Pro section quietly reports that it cannot
/// reach the App Store, which is exactly what a mismatched identifier looks like from inside the app.
/// `Configuration.storekit` at the repo root is the third copy the Xcode-local one and it exists
/// so the purchase flow can be exercised on a machine with no App Store Connect access at all.
///
/// ### The identifier's shape
///
/// `dev.rzen.indie.kanban.pro.monthly` follows the family's reverse-DNS style, the same one the three
/// pasteboard types already use (`dev.rzen.indie.kanban.cards`, `lanes`, `clipboard`
/// `PasteboardTypes.swift`): the lowercase `kanban` codename segment, then what the thing *is*. The
/// bundle id keeps its capital `K` because that is what the 1.x App Store record ships under; the
/// identifiers the app coins for itself do not, and consistency inside that set is what matters.
///
/// **One product to start.** A monthly subscription is the whole storefront until there is evidence
/// for a second an annual tier, a family plan and an introductory offer are all additions to the
/// same group and none of them changes a line of the entitlement, which reads *whatever* transaction
/// the group produced (`ProEntitlement`). `all` is the seam that keeps that true: everything that
/// matches a transaction against this app's products asks this set, never the single constant.
public enum ProProducts {
/// Lanework Pro, billed monthly the one auto-renewable subscription product.
public static let monthly = "dev.rzen.indie.kanban.pro.monthly"
/// The subscription group's **reference name** in App Store Connect: "Lanework Pro".
///
/// Deliberately *not* the group's numeric id. That number is minted by App Store Connect when the
/// group is created and cannot be known until then, so hardcoding one would be a value invented
/// at the wrong end. Where StoreKit needs the numeric id reading a group's subscription status
/// it is read off a loaded `Product` (`product.subscription?.subscriptionGroupID`), which is
/// the account's own answer rather than this file's guess.
public static let subscriptionGroupName = "Lanework Pro"
/// Every product identifier this app's entitlement recognises.
///
/// The set, rather than the constant, is what `ProEntitlement` matches transactions against and
/// what `ProStorefront` loads so adding an annual product is one line here and nothing else.
public static let all: Set<String> = [monthly]
}
-183
View File
@@ -1,183 +0,0 @@
import SwiftUI
/// **The Settings Pro section** subscribe, manage, restore (12-editions.md Distribution).
///
/// **Dormant since the 2026-08-07 pivot** (12 PIVOT 2026-08-07 git leaves the paywall): git
/// integration left the paywall and the base/Pro feature split is being re-decided, so this view is
/// no longer instantiated from `SettingsView` it is unrendered pending the new split. The type,
/// its logic, and its tests stay exactly as they were: the entitlement's mechanics (local read,
/// composition-time binding, offline grace, the recorded session tier) are unchanged and correct
/// for whatever the next split gates, and this section is the seam that will surface it again once
/// that split is ruled. Nothing below this comment was touched for the pivot.
///
/// ### Calm, because the rule says calm
///
/// "The free tier presents as a complete app, not a demo ... Nothing on the welcome screen, nothing
/// in banners." This section is where the subscription actually lives, so it is allowed to describe
/// itself and that is all it does: what Pro adds, in the same sentence the About box uses, then the
/// price and the buttons. No badge, no comparison table, no countdown, no second sentence selling the
/// first. A user who never subscribes should be able to read this pane and feel they are looking at a
/// feature they do not need, not an ad they have to dismiss.
///
/// ### Three states, all of them sentences
///
/// - **Subscribed** says so, with whatever renewal or expiry date StoreKit exposes, and offers
/// Manage. Restore is not shown: there is nothing to restore onto an account that is already
/// entitled here.
/// - **Not subscribed, product loaded** the localized price and Subscribe, with Restore Purchases
/// beside it for the account that owns a subscription this Mac has never seen.
/// - **Not subscribed, App Store unreachable** one quiet line and Try Again, never an indefinite
/// spinner (`ProStorefront`'s own note). Restore stays available: it is the button whose whole job
/// is to reach the App Store, so hiding it in the state where the App Store could not be reached
/// would remove the one thing worth pressing.
///
/// ### The reopen offer
///
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate" (12 The entitlement). The offer is raised **once**, from
/// here, on the one outcome that means an active subscription just landed and declining costs a
/// gitless board nothing at all: the provider follows the board, so its undo is the same native
/// stack before and after (`AppModel.makeHistoryProvider`). What a Not Now defers is the git trail
/// on the boards that have a repository.
struct ProSettingsSection: View {
@Environment(AppModel.self) private var appModel
/// Built on appearance rather than at init, because the entitlement it wraps comes from the
/// environment and dropped when the pane goes, which is what keeps the networked half of Pro
/// out of every other part of the app (`ProStorefront`'s note).
@State private var storefront: ProStorefront?
/// The reopen offer, raised at most once per activation.
@State private var isOfferingReopen = false
/// How many boards the offer was raised over. Captured when the offer is made, like the trash
/// confirmations capture their counts (`TrashConfirmations`): the user is being asked about the
/// boards that were open when they subscribed, and a count recomputed at render time could
/// disagree with the sentence they are reading.
@State private var openBoardCount = 0
var body: some View {
Section {
content
} header: {
Text("Lanework Pro")
} footer: {
Text("Lanework Pro adds git-backed board history and sync. It takes effect the next time each board opens.")
}
.task {
let storefront = storefront ?? ProStorefront(entitlement: appModel.entitlement)
self.storefront = storefront
await storefront.load()
}
.alert("Reopen your open boards?", isPresented: $isOfferingReopen) {
Button("Reopen") {
Task { await appModel.reopenOpenBoards() }
}
Button("Not Now", role: .cancel) {}
} message: {
Text(reopenMessage)
}
}
// MARK: Content
@ViewBuilder
private var content: some View {
if appModel.entitlement.tier == .pro {
subscribedRows
} else {
unsubscribedRows
}
}
/// The subscribed state. One status line and one button the App Store owns everything else
/// about a subscription (price changes, cancellation, billing), and duplicating any of it here
/// would be a second, staler answer.
@ViewBuilder
private var subscribedRows: some View {
// One `Text`, deliberately, rather than a status word beside a date: VoiceOver reads it as
// the one sentence it is, and nothing about the state is carried by layout
// (10-accessibility.md nothing is ever said by position or colour alone).
Text(subscriptionStatusLine)
Button("Manage Subscription…") {
storefront?.openManageSubscriptions()
}
.accessibilityHint("Opens your subscriptions in the App Store.")
}
@ViewBuilder
private var unsubscribedRows: some View {
switch storefront?.availability ?? .idle {
case .idle, .loading:
// A determinate, one-line placeholder rather than a progress spinner: the load is short,
// and a spinner that resolves into a price reads as a slower price.
Text("Checking the App Store…")
.foregroundStyle(.secondary)
case let .ready(product):
HStack {
Text(product.displayPrice)
Spacer()
Button("Subscribe") {
Task { await activate { await storefront?.subscribe() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityLabel("Subscribe to Lanework Pro, \(product.displayPrice)")
}
case .unreachable:
Text("Can't reach the App Store right now.")
.foregroundStyle(.secondary)
Button("Try Again") {
Task { await storefront?.load() }
}
.disabled(storefront?.isBusy ?? false)
}
Button("Restore Purchases") {
Task { await activate { await storefront?.restore() } }
}
.disabled(storefront?.isBusy ?? false)
.accessibilityHint("Checks this Apple Account for a Lanework Pro subscription.")
if let message = storefront?.message {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: Wording
/// What a subscriber's status line says, built from exactly what StoreKit cached and nothing
/// more. An unknown expiry says the plain fact and stops, rather than inventing a date.
private var subscriptionStatusLine: String {
let facts = appModel.entitlement.facts
guard let expiration = facts.expiration, expiration < .distantFuture else {
return "Subscribed."
}
let date = expiration.formatted(date: .abbreviated, time: .omitted)
return facts.willAutoRenew ? "Subscribed — renews \(date)." : "Subscribed — ends \(date)."
}
private var reopenMessage: String {
let boards = openBoardCount == 1 ? "the board you have open" : "the \(openBoardCount) boards you have open"
return "Pro takes effect at each board's next open. Lanework can close and reopen \(boards) now."
}
// MARK: Actions
/// Runs a purchase or a restore and raises the reopen offer on the one outcome that earns it.
///
/// The count is read here, before the alert exists, for the reason `TrashConfirmations` captures
/// its phrasing at request time. And the offer is skipped outright when nothing is open, because
/// an offer to reopen no boards is a dialog with nothing behind it.
private func activate(_ work: () async -> ProStorefront.Outcome?) async {
guard await work() == .activated else { return }
let count = appModel.storeRegistry.openBoardCount
guard count > 0 else { return }
openBoardCount = count
isOfferingReopen = true
}
}
-273
View File
@@ -1,273 +0,0 @@
import AppKit
import Foundation
import Observation
import StoreKit
import os
/// **The networked half of Lanework Pro** loading the product for its localized price, buying it,
/// restoring it, and pointing at the system's manage-subscription surface (12-editions.md
/// Distribution: "purchased and managed in a Pro section of Settings (,) subscribe, manage,
/// restore purchases").
///
/// ### Why it is a separate type from `ProEntitlement`
///
/// Because the network is. 12 The entitlement makes the *entitlement* a local read so the
/// board-open path gains no network dependency, and the cleanest way to keep a promise like that is
/// to put everything that could break it somewhere the open path cannot reach. Nothing constructs a
/// `ProStorefront` except the Settings Pro section, which builds one when the pane appears and drops
/// it when the pane goes; `AppModel` has no reference to it and no way to acquire one. The split is
/// the enforcement.
///
/// What crosses back the other way is narrow and one-directional: this type hands `ProEntitlement`
/// the conclusions StoreKit reached (`adopt(_:)`), which a *later* board composition may read. It
/// never reaches into an open session see `ProEntitlement`'s note on why a lapse cannot rebind one.
///
/// ### Offline is a sentence, not a spinner
///
/// A product load that fails leaves `availability` at `.unreachable`, which the section renders as
/// one quiet line with a Try Again button. There is deliberately no retry loop and no indefinite
/// progress view: the App Store being unreachable is an ordinary, temporary, user-legible condition,
/// and a subscriber's *entitlement* is unaffected by it the cached facts already answered that
/// question before this type existed.
@MainActor
@Observable
public final class ProStorefront {
// MARK: Availability
/// Whether the subscription product can be shown, and at what price.
public enum Availability: Equatable {
/// Nothing has been asked for yet the state the pane is built in.
case idle
/// A product load is in flight.
case loading
/// Loaded. The `Product` carries its own localized `displayPrice`, which is the only place
/// a price may come from: a price written into the app would be wrong in most of the world
/// and out of date in the rest.
case ready(Product)
/// The App Store could not be reached, or answered with no such product. **One case for
/// both**, because they are one sentence to the user and neither is actionable beyond
/// trying again a missing product id is a configuration mistake that shows up in
/// development, never in a shipped build.
case unreachable
}
/// What just happened, for the section to react to. Distinct from `availability`, which is about
/// the *product*; this is about the last thing the user asked for.
public enum Outcome: Equatable {
/// A purchase or restore left an active subscription in the cache the one outcome that
/// raises the reopen offer (12: "the purchase flow offers to reopen open boards").
case activated
/// Ask to Buy, or a payment the App Store has not settled. Nothing to do but wait; the
/// entitlement will arrive through `Transaction.updates` when it does.
case pending
/// The user backed out of the App Store's sheet. Not an error and not worth a word.
case cancelled
/// A restore that reached the App Store and found nothing to restore.
case nothingToRestore
/// Anything else, carrying the sentence to show.
case failed(String)
}
public private(set) var availability: Availability = .idle
/// Whether a purchase or a restore is in flight what the buttons disable on.
public private(set) var isBusy = false
/// The last outcome's sentence, or `nil`. Rendered as one quiet line under the buttons.
public private(set) var message: String?
@ObservationIgnored
private let entitlement: ProEntitlement
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "storefront")
public init(entitlement: ProEntitlement) {
self.entitlement = entitlement
}
// MARK: - Loading
/// Loads the subscription product and reconciles the cached facts against what the App Store
/// says.
///
/// **The reconciliation is the point, as much as the price is.** This is the moment
/// `ProEntitlement`'s offline-grace hold can end honestly: the app has demonstrably reached the
/// App Store (the product came back), so the subscription group's status is StoreKit *answering*
/// rather than StoreKit computing locally from a stale transaction which is exactly what 12
/// The entitlement makes the hold wait for.
///
/// Run from the section's `.task`, so opening Settings is what triggers it. That is also the one
/// place a user who has been offline for a while goes looking when they wonder about their
/// subscription, which makes it the right door for this to be behind.
public func load() async {
availability = .loading
do {
let products = try await Product.products(for: ProProducts.all)
guard let product = products.first(where: { $0.id == ProProducts.monthly }) else {
Self.logger.error("the App Store returned no product for \(ProProducts.monthly, privacy: .public)")
availability = .unreachable
return
}
availability = .ready(product)
await reconcile(with: product)
} catch {
Self.logger.error("product load failed: \(error.localizedDescription, privacy: .public)")
availability = .unreachable
}
}
/// Folds the subscription group's status into the cached facts.
///
/// Three outcomes, and the middle one is the whole reason this method exists:
///
/// - **A live status** (subscribed, in grace, in billing retry) re-read the local transactions,
/// which moves the cached expiry forward to whatever the renewal actually is.
/// - **Every status expired or revoked** `adopt(.none)`. StoreKit has answered, so any hold
/// standing on "we haven't heard" is over.
/// - **No statuses at all** also `adopt(.none)`, and for the same reason rather than a weaker
/// one: the account demonstrably reached the App Store, and the App Store knows of no
/// subscription in this group. For a user who never subscribed this is a no-op on facts that
/// are already empty.
///
/// A status read that throws changes nothing. That is silence again, not an answer the same
/// posture `ProEntitlement.refreshFromLocalTransactions()` takes toward an empty local store.
private func reconcile(with product: Product) async {
guard let subscription = product.subscription else { return }
guard let statuses = try? await subscription.status else {
Self.logger.debug("subscription status unavailable; the cached facts stand")
return
}
let isLive = statuses.contains { status in
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod: true
default: false
}
}
if isLive {
await entitlement.refreshFromLocalTransactions()
} else {
entitlement.adopt(.none)
}
}
// MARK: - Buying
/// The Subscribe button. Returns what happened, so the section can raise the reopen offer on
/// exactly one outcome.
///
/// The transaction is **finished** on the way through. An auto-renewable subscription has no
/// content to deliver the entitlement is the delivery so an unfinished one is simply a
/// transaction StoreKit redelivers forever.
@discardableResult
public func subscribe() async -> Outcome {
guard case let .ready(product) = availability, !isBusy else { return .cancelled }
isBusy = true
message = nil
defer { isBusy = false }
do {
switch try await product.purchase() {
case let .success(verification):
guard case let .verified(transaction) = verification else {
return report(.failed("This purchase couldn't be verified."))
}
await transaction.finish()
await entitlement.refreshFromLocalTransactions()
return report(entitlement.tier == .pro ? .activated : .pending)
case .pending:
return report(.pending)
case .userCancelled:
return report(.cancelled)
@unknown default:
return report(.failed("The App Store returned an unexpected answer."))
}
} catch {
Self.logger.error("purchase failed: \(error.localizedDescription, privacy: .public)")
return report(.failed(error.localizedDescription))
}
}
/// Restore Purchases `AppStore.sync()`, then the same reconciliation the load runs.
///
/// This is the app's **only** deliberate App Store refresh, and it is behind a button the user
/// pressed, which is where 12 puts the network. It exists for the account that owns a
/// subscription this device's transaction store has never seen: a new Mac, a reinstall, a signed
/// out-and-in Apple Account.
@discardableResult
public func restore() async -> Outcome {
guard !isBusy else { return .cancelled }
isBusy = true
message = nil
defer { isBusy = false }
do {
try await AppStore.sync()
} catch {
// A cancelled authentication sheet arrives here too, and is not a failure worth a
// sentence the user closed a dialog.
Self.logger.error("App Store sync failed: \(error.localizedDescription, privacy: .public)")
return report(.failed(error.localizedDescription))
}
await entitlement.refreshFromLocalTransactions()
if case let .ready(product) = availability {
await reconcile(with: product)
}
return report(entitlement.tier == .pro ? .activated : .nothingToRestore)
}
// MARK: - Managing
/// Opens the system's subscription-management surface.
///
/// **The Mac App Store's account page, not a StoreKit sheet.** StoreKit 2's
/// `AppStore.showManageSubscriptions(in:)` takes a `UIWindowScene` and has no macOS counterpart;
/// on the Mac the surface is the App Store app's Account Subscriptions, and the
/// `macappstore:` URL is how an app asks for it. The `https:` form is the fallback for a machine
/// where that scheme is unhandled, and lands on the same page in a browser.
public func openManageSubscriptions() {
let candidates = [
"macappstore://apps.apple.com/account/subscriptions",
"https://apps.apple.com/account/subscriptions"
]
for candidate in candidates {
guard let url = URL(string: candidate) else { continue }
if NSWorkspace.shared.open(url) { return }
}
Self.logger.error("no handler for the App Store subscriptions page")
}
// MARK: - Messages
/// Records an outcome's sentence and hands the outcome straight back, so every return site is
/// one line.
@discardableResult
private func report(_ outcome: Outcome) -> Outcome {
message = Self.sentence(for: outcome)
return outcome
}
/// The one place outcome wording lives. Calm and factual 12 Tier naming keeps this whole
/// section free of upsell, and that applies to its failure lines as much as to its heading.
static func sentence(for outcome: Outcome) -> String? {
switch outcome {
case .activated: nil
case .pending: "This subscription is waiting for approval."
case .cancelled: nil
case .nothingToRestore: "No subscription was found for this Apple Account."
case let .failed(message): message
}
}
}
-133
View File
@@ -1,133 +0,0 @@
import Foundation
// MARK: - Tier
/// Which tier a board session composes under (12-editions.md The tiers).
///
/// **Two cases, and there will never be a third here.** Lanework Teams is deferred and will "never
/// share an app group or any cross-app state with Lanework" (12 The tiers, ruled 2026-07-30)
/// whatever it becomes, it is a different app, not a third case of this enum.
///
/// Nothing about this type is a *feature flag*. It is the answer to one question free or Pro
/// asked once per board session at composition (`AppModel.beginSession`), recorded on the session,
/// and never asked again for that board. What consumes it is the provider seam
/// (12 The provider seam); see `AppModel.makeHistoryProvider`.
public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
/// Lanework. Boards are plain folders, mode `none` everywhere, undo is macOS-native
/// (13-native-undo.md), and any `.git` the app meets is inert (12 The free tier and `.git`).
///
/// **This is also the lapsed tier.** "Unsubscribed and lapsed are one state the inert posture,
/// nothing lost, histories frozen not forfeited" (12 The entitlement). There is deliberately no
/// `.lapsed` case: a case nothing may act on differently is a distinction the design forbids from
/// existing at all.
case free
/// Lanework Pro an active auto-renewable subscription. It is what puts git on the table; which
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
/// 2026-07-31 `AppModel.makeHistoryProvider`): the git provider on a git board
/// (06-history-undo.md, 07-sync-collab.md), and the same native stack the free tier uses on every
/// board without app-managed git, repo-nested ones included.
case pro
}
// MARK: - SubscriptionFacts
/// **What the app knows locally about the subscription** the whole input to the tier decision,
/// beside a date.
///
/// ### Why a cached fact struct rather than a live StoreKit read
///
/// 12-editions.md The entitlement makes two demands that pull in the same direction. Pro state is
/// "a local read, never a network call ... the open path gains no network dependency"; and offline
/// grace "resolves toward the paying user" "an on-disk expiry passing while offline, with the last
/// known state *active and auto-renew on*, holds the entitlement until StoreKit actually refreshes
/// and answers."
///
/// The second demand is the reason this type exists as *stored* state rather than as a view onto
/// `Transaction.currentEntitlements`. StoreKit computes entitlement validity locally, so a
/// subscription whose expiry has passed drops out of `currentEntitlements` **whether or not the
/// device has been able to ask the App Store about it** an offline device and a genuinely lapsed
/// subscription look identical from that property alone. Holding the last answer ourselves is what
/// lets the two be told apart in the only direction the design cares about: a *cancellation* (auto
/// renew off) lapses at its expiry with no network needed, while a *renewal we simply have not heard
/// about yet* keeps the user paid-up until StoreKit says otherwise (`ProEntitlement.adopt`).
///
/// ### Never-subscribed and never-online are one shape, on purpose
///
/// `expiration == nil` means "no cached transaction" and covers both the user who has never
/// subscribed and the fresh install that "has no cached transactions and reads as the free tier
/// until the first refresh honest and self-correcting" (12). Nothing distinguishes them because
/// nothing may: they are the same tier, reached by the same route, correcting themselves the same
/// way.
///
/// `Codable` because these facts are cached across launches in `UserDefaults`
/// (`AppPreferences.subscriptionFactsKey`) that cache *is* the "local read" the open path performs.
public struct SubscriptionFacts: Codable, Sendable, Equatable {
/// When the current subscription period ends, as StoreKit last reported it.
///
/// `nil` is the no-cached-transaction state see the type's note. A non-`nil` value is never
/// evidence on its own that the subscription is *live*: an expiry in the past is either a lapse
/// or an offline hold, and `willAutoRenew` is what decides which.
public var expiration: Date?
/// Whether the subscription was set to renew, at the last moment StoreKit told us anything.
///
/// This is the whole of the offline-grace rule. Auto-renew **on** with a passed expiry is a
/// renewal the device has not heard about hold. Auto-renew **off** with a passed expiry is a
/// cancellation that has run out lapse, "offline or not" (12 The entitlement).
public var willAutoRenew: Bool
public init(expiration: Date?, willAutoRenew: Bool) {
self.expiration = expiration
self.willAutoRenew = willAutoRenew
}
/// No cached transaction: never subscribed, never online, or an entitlement StoreKit has
/// definitively withdrawn (a refund, a revocation). All three read as the free tier, and that is
/// the point see the type's note.
public static let none = SubscriptionFacts(expiration: nil, willAutoRenew: false)
}
// MARK: - The decision
public extension Tier {
/// **The tier decision, as a pure function of cached facts and a date.**
///
/// Every semantic here is 12-editions.md The entitlement's, in its own order:
///
/// 1. **No cached transaction free.** "A fresh install that has never been online has no
/// cached transactions and reads as the free tier until the first refresh." The
/// never-subscribed user takes the identical branch, which is what makes unsubscribed and
/// lapsed one state.
/// 2. **Expiry in the future Pro.** "Offline with an active subscription is indistinguishable
/// from online" there is no reachability term in this function because there is no
/// reachability term in the rule.
/// 3. **Expiry passed, auto-renew on Pro.** The offline-grace hold: "an on-disk expiry passing
/// while offline, with the last known state active and auto-renew on, holds the entitlement
/// until StoreKit actually refreshes and answers." The *answering* is `ProEntitlement`'s job
/// this function's job is only to resolve toward the paying user until it happens.
/// 4. **Expiry passed, auto-renew off free.** "A cancellation (auto-renew off) lapses at
/// expiry, offline or not."
///
/// The design weighs both wrong-for-a-window directions and accepts them: "a wrong lapse pauses
/// auto-commits into one catch-up commit; a wrong hold gives away days of local commits Apple's
/// own billing grace makes the same trade."
///
/// `nonisolated` and `static` because it is exactly as pure as that reads: no stored state, no
/// clock of its own, no StoreKit. `now` is a parameter rather than a `Date()` inside for the
/// reason `AppModel.shouldRestoreAtLaunch` takes its two `Bool`s a decision worth this much
/// prose is worth being provable without a machine in a particular state.
///
/// The expiry comparison is strict (`>`), so an expiry falling exactly on `now` is *past*: a
/// StoreKit expiration date is the instant the period ends, not the last instant it covers, and
/// resolving the boundary the other way would extend every subscription by a tick for no reason.
/// At that boundary rule 3 is usually what answers anyway, which is the paying user's direction.
static func resolve(from facts: SubscriptionFacts, now: Date) -> Tier {
guard let expiration = facts.expiration else { return .free }
if expiration > now { return .pro }
return facts.willAutoRenew ? .pro : .free
}
}
+2 -76
View File
@@ -284,9 +284,8 @@ struct NativeHistoryProviderTests {
/// A provider with no `NSUndoManager` anywhere in it which is the point: `BoardUndoManager` is /// A provider with no `NSUndoManager` anywhere in it which is the point: `BoardUndoManager` is
/// tested against *this* rather than against the native stack, because what has to be true is that /// tested against *this* rather than against the native stack, because what has to be true is that
/// the adapter works for any implementation of the seam (Pro's git provider once bound the same /// the adapter works for any implementation of the seam (the git provider bound the same protocol,
/// protocol, in pro-m1, before app-managed git was excised entirely, /// in pro-m1, before app-managed git was excised entirely, `strategy/01-git-excision.md`).
/// `strategy/01-git-excision.md`).
@MainActor @MainActor
private final class FakeHistoryProvider: HistoryProviding { private final class FakeHistoryProvider: HistoryProviding {
@@ -597,79 +596,6 @@ struct BoardSessionHistoryTests {
session.undoManager.undo() session.undoManager.undo()
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound") #expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
} }
@Test("The session records the tier it composed under — and the provider root never sees it")
func theTierIsARecordedFact() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and recorded.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere, and the 2026-08-08 excision took
// the last argument beside it the seam takes a store and nothing else. What is pinned here
// is what replaced the old matrix: one call per board, and a session still carrying the tier
// for the base/Pro split yet to be ruled.
var seen = 0
model.currentTier = { .pro }
model.makeHistoryProvider = { _ in
seen += 1
return NativeHistoryProvider()
}
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(seen == 1, "called once per board, with no tier to consult")
#expect(session.tier == .pro, "the session carries the fact it composed under")
}
@Test("A tier change never reaches a session that is already open")
func aLapseNeverRebindsAnOpenSession() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
#expect(try #require(model.session(for: ref)).tier == .pro)
// The subscription lapses mid-session the one thing 12 The entitlement says must not
// disturb a board that is already on screen: "an open board finishes with the provider it
// composed". (Its second clause "the next open composes the native stack over inert
// `.git`" went with PIVOT 2026-08-07, when for one day the next open could compose a git
// stack instead; the 2026-08-08 excision (`strategy/01-git-excision.md`) then restored the
// original clause outright every open composes the native stack over inert `.git` again,
// permanently. What is pinned here is the recorded fact's immutability, which is what makes
// the first clause true whatever a future split gates.)
model.currentTier = { .free }
#expect(try #require(model.session(for: ref)).tier == .pro)
}
@Test("The purchase flow's reopen ends every session, because that is what recomposing means")
func reopeningEndsTheSessions() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
#expect(model.hasOpenBoards)
// 12 The entitlement: "Subscribe takes effect at each board's next open ... The purchase
// flow offers to reopen open boards." There is no rebinding-in-place to test for, and that
// is the finding: reopening *is* ending the session and composing a new one, so what this
// pins is the ending. The reopen half needs SwiftUI's window actions, which this host has
// none of the URLs simply buffer until an opener exists (`AppModel.openBoard`), which is
// the same path a cold-launch Finder open already takes.
await model.reopenOpenBoards()
#expect(model.session(for: ref) == nil)
#expect(model.hasOpenBoards == false)
#expect(model.storeRegistry.openBoardCount == 0, "the store and its watcher went with the session")
}
} }
// MARK: - The command surface // MARK: - The command surface
-223
View File
@@ -1,223 +0,0 @@
import Foundation
import Testing
@testable import Kanban
/// **The tier decision** (12-editions.md The entitlement).
///
/// Every test here goes through `Tier.resolve(from:now:)` with facts and a date the test wrote down
/// no StoreKit, no App Store account, no clock. That is the point of the seam: the semantics 12
/// rules are semantics about *cached facts and a date*, and everything else in the Pro stack
/// (`ProEntitlement`, `ProStorefront`) exists to produce those facts honestly. Pinning the decision
/// here is what makes the adapter thin enough to read.
///
/// The five states 12 names are the five suites' worth of cases below, in its own order:
/// active, lapsed, offline-grace, never-online, and free with free and never-online deliberately
/// arriving at the same input, because 12 says nothing may distinguish them.
@Suite("Tier ▸ the entitlement decision")
struct TierDecisionTests {
/// A fixed instant, so nothing here depends on when the suite runs.
static let now = Date(timeIntervalSinceReferenceDate: 800_000_000)
private static func resolve(_ facts: SubscriptionFacts) -> Tier {
Tier.resolve(from: facts, now: now)
}
// MARK: - Free
@Test("No cached transaction reads as the free tier")
func freeTier() {
#expect(Self.resolve(.none) == .free)
}
@Test("An auto-renew flag with no expiry behind it is still the free tier")
func autoRenewWithoutAnExpiryIsNotAnEntitlement() {
// The offline-grace hold is about an expiry that *passed*, never about the flag on its own.
// A fact set that carries a renew flag and no period is not a subscription in any state.
#expect(Self.resolve(SubscriptionFacts(expiration: nil, willAutoRenew: true)) == .free)
}
// MARK: - Active
@Test("An expiry in the future is Pro")
func activeSubscription() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 20), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("An active subscription the user has already cancelled is Pro until its expiry")
func cancelledButStillInsideThePaidPeriod() {
// "A cancellation (auto-renew off) lapses **at expiry**" not when it is made. The paid
// period is paid for.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 3), willAutoRenew: false)
#expect(Self.resolve(facts) == .pro)
}
@Test("Offline changes nothing about an active subscription")
func offlineWithAnActiveSubscription() {
// 12: "Offline with an active subscription is indistinguishable from online." Pinned by the
// absence of a reachability parameter as much as by this assertion there is nothing to
// pass in that could make this answer differently.
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 400), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
// MARK: - Lapsed
@Test("A cancellation whose expiry has passed is the free tier")
func lapsedSubscription() {
// 12: "A cancellation (auto-renew off) lapses at expiry, offline or not."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60), willAutoRenew: false)
#expect(Self.resolve(facts) == .free)
}
@Test("A long-lapsed cancellation is the same free tier as never having subscribed")
func lapsedAndUnsubscribedAreOneState() {
// "Unsubscribed and lapsed are **one state** the inert posture, nothing lost, histories
// frozen not forfeited." Nothing downstream may tell these two apart, and the decision is
// where that starts: one answer, from two histories.
let lapsed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 365), willAutoRenew: false)
#expect(Self.resolve(lapsed) == Self.resolve(.none))
}
// MARK: - Offline grace
@Test("An expiry that passed with auto-renew still on holds the entitlement")
func offlineGraceHoldsForThePayingUser() {
// 12: "an on-disk expiry passing while offline, with the last known state *active and
// auto-renew on*, holds the entitlement until StoreKit actually refreshes and answers."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("The offline-grace hold does not decay with time")
func theHoldHasNoTimeout() {
// Deliberately no expiry-of-the-expiry: the hold ends when StoreKit *answers*
// (`ProEntitlement.adopt`), not when a timer this function knows nothing about runs out.
// 12 weighs the cost and takes it: "a wrong hold gives away days of local commits Apple's
// own billing grace makes the same trade."
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-60 * 60 * 24 * 90), willAutoRenew: true)
#expect(Self.resolve(facts) == .pro)
}
@Test("Auto-renew is the only thing separating a hold from a lapse")
func theFlagIsTheWholeDifference() {
let expired = Self.now.addingTimeInterval(-60 * 60 * 24)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: true)) == .pro)
#expect(Self.resolve(SubscriptionFacts(expiration: expired, willAutoRenew: false)) == .free)
}
// MARK: - Never online
@Test("A fresh install that has never been online reads as the free tier")
func neverOnline() {
// 12: "A fresh install that has never been online has no cached transactions and reads as
// the free tier until the first refresh honest and self-correcting." The input is
// identical to `freeTier` above and that is the finding, not a duplicate: a device that has
// not asked yet and an account that never subscribed are the same state, and the app has no
// vocabulary for telling them apart.
#expect(Self.resolve(.none) == .free)
}
@Test("The first refresh is what corrects a never-online install")
func theFirstRefreshCorrectsIt() {
// The self-correction, as the decision sees it: nothing about the free answer is sticky
// the same function over the facts a refresh produces answers Pro immediately.
#expect(Self.resolve(.none) == .free)
let refreshed = SubscriptionFacts(expiration: Self.now.addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
#expect(Self.resolve(refreshed) == .pro)
}
// MARK: - The boundary
@Test("An expiry falling exactly on now is past")
func theExpiryBoundaryIsExclusive() {
// A StoreKit expiration date is the instant the period ends, not the last instant it covers.
// With auto-renew off that means the lapse takes effect at the boundary rather than a tick
// after it; with auto-renew on the grace hold catches it, which is the paying user's side.
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: false)) == .free)
#expect(Self.resolve(SubscriptionFacts(expiration: Self.now, willAutoRenew: true)) == .pro)
}
@Test("The decision is a pure function of its two arguments")
func theSameInputsAlwaysAnswerTheSame() {
let facts = SubscriptionFacts(expiration: Self.now.addingTimeInterval(-1), willAutoRenew: true)
let answers = (0..<5).map { _ in Tier.resolve(from: facts, now: Self.now) }
#expect(Set(answers).count == 1)
// And a *different* date genuinely moves it the clock is an argument, not decoration.
let before = Tier.resolve(from: facts, now: Self.now.addingTimeInterval(-60))
#expect(before == .pro, "still inside the paid period")
}
}
// MARK: - The cached facts
/// The entitlement's cache, exercised through its injected `UserDefaults` the same injection every
/// other app-side store in this project takes (`AppStateHome`), and here it also keeps a suite from
/// editing the developer's own subscription state.
///
/// StoreKit itself is deliberately absent: `ProEntitlement.start()` and
/// `refreshFromLocalTransactions()` are the only members that touch it, neither is called here, and
/// what is left is exactly the seam worth pinning that the facts round-trip, that the tier follows
/// them, and that adopting `.none` ends a hold.
@MainActor
@Suite("Tier ▸ the entitlement's cache")
struct ProEntitlementCacheTests {
/// A defaults domain of this suite's own. Removed at the end of each test so the cases cannot
/// see each other's writes.
private static func makeDefaults() -> (UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.tier-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
return (defaults, { UserDefaults.standard.removePersistentDomain(forName: name) })
}
@Test("A fresh domain has no facts, and the tier is free")
func emptyCache() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
#expect(entitlement.facts == .none)
#expect(entitlement.tier == .free)
}
@Test("Adopted facts survive into the next launch")
func factsRoundTrip() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let facts = SubscriptionFacts(expiration: Date().addingTimeInterval(60 * 60 * 24 * 30), willAutoRenew: true)
ProEntitlement(defaults: defaults).adopt(facts)
// A second object over the same domain is what a relaunch is.
let relaunched = ProEntitlement(defaults: defaults)
#expect(relaunched.facts == facts)
#expect(relaunched.tier == .pro)
}
@Test("Adopting nothing ends the entitlement — StoreKit's definitive answer")
func adoptingNoneEndsAHold() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
let entitlement = ProEntitlement(defaults: defaults)
// An offline-grace hold: the expiry passed, the last we heard it was renewing.
entitlement.adopt(SubscriptionFacts(expiration: Date().addingTimeInterval(-60), willAutoRenew: true))
#expect(entitlement.tier == .pro)
// and then StoreKit answers (a revocation, or a group status reading expired).
entitlement.adopt(.none)
#expect(entitlement.tier == .free)
#expect(ProEntitlement(defaults: defaults).tier == .free, "and the answer is cached, not just held")
}
@Test("A cache that will not decode is treated as no cache")
func aCorruptCacheIsEmpty() {
let (defaults, tearDown) = Self.makeDefaults()
defer { tearDown() }
defaults.set(Data("not json".utf8), forKey: AppPreferences.subscriptionFactsKey)
#expect(ProEntitlement(defaults: defaults).tier == .free)
}
}
+7 -7
View File
@@ -20,13 +20,13 @@ import XCTest
/// ///
/// ### The settings sheet's audit, and where it went /// ### The settings sheet's audit, and where it went
/// ///
/// The sheet was Pro-only and this suite could not reach it: the fixture launch had no tier control, /// The sheet was once gated, and this suite could not reach it: the gate had no test control, and a
/// and a `--ui-test-pro` launch argument was rejected as a subscription bypass anyone could type /// launch argument that would have opened it was rejected as a bypass anyone could type into
/// into Terminal (`UITestLaunch` ships in the app binary on purpose). The 2026-08-07 pivot /// Terminal (`UITestLaunch` ships in the app binary on purpose). The gate is gone there is one
/// (12-editions.md PIVOT 2026-08-07 git left the paywall) made it reachable and it gained a test /// version of the app and nothing to unlock (12-editions.md PIVOT 2026-08-08) and the sheet
/// here; the **reversal later the same day retired the sheet outright**, and the test with it. What /// itself retired on 2026-08-07, taking the test with it. What remains is the precedent: a launch
/// remains is the precedent the no-bypass objection stands for whatever the next split gates /// argument never exists to open something the shipped app would refuse. `testBoardInfoPopover`
/// and `testBoardInfoPopover` opens the popover onto its Info tab, which the audit covers. /// opens the popover onto its Info tab, which the audit covers.
/// ///
/// The popover carried a fourth tab, Git, until the git excision (strategy/01-git-excision.md, /// The popover carried a fourth tab, Git, until the git excision (strategy/01-git-excision.md,
/// 2026-08-08) removed it; the popover is Info, Theme, Sync now, and there is no tab-specific audit /// 2026-08-08) removed it; the popover is Info, Theme, Sync now, and there is no tab-specific audit
+12 -31
View File
@@ -35,10 +35,11 @@ settings:
ENABLE_USER_SCRIPT_SANDBOXING: NO ENABLE_USER_SCRIPT_SANDBOXING: NO
# **One app target** (12-editions.md ▸ The target, re-ruled 2026-07-30). Lanework ships as a single # **One app target** (12-editions.md ▸ The target, re-ruled 2026-07-30). Lanework ships as a single
# Mac App Store app, `dev.rzen.indie.Kanban`; **Lanework Pro is a subscription inside it**, not a # Mac App Store app, `dev.rzen.indie.Kanban` **one version, everything in it** (12-editions.md ▸
# second binary. The 2026-07-27 two-target split — a `KanbanPro` application target, its scheme, its # PIVOT 2026-08-08): there is no edition axis for this file to express. The 2026-07-27 two-target
# bundle id, the `-module-alias Kanban=KanbanPro` test bundle and the twin source files that existed # split — a `KanbanPro` application target, its scheme, its bundle id, the `-module-alias
# only because two bundles claimed different UTI ownership — retired wholesale with the collapse. # Kanban=KanbanPro` test bundle and the twin source files that existed only because two bundles
# claimed different UTI ownership — retired wholesale with the collapse.
# #
# What that buys this file: no anchors, no per-target exclusion lists, no second copy of the # What that buys this file: no anchors, no per-target exclusion lists, no second copy of the
# resources. There is one product, one Info.plist, one entitlements file, one Swift module, and one # resources. There is one product, one Info.plist, one entitlements file, one Swift module, and one
@@ -67,18 +68,6 @@ targets:
- path: CHANGELOG.md - path: CHANGELOG.md
type: file type: file
buildPhase: resources buildPhase: resources
# The local StoreKit configuration (12-editions.md ▸ Distribution) — the subscription group
# "Lanework Pro" and its monthly product, mirroring what App Store Connect declares, so the
# Settings Pro section's subscribe/manage/restore flow is exercisable in Xcode without an ASC
# product existing yet. Attached to the scheme's run action below.
#
# **`buildPhase: none`, deliberately.** It is development configuration, not app content: the
# scheme reads it, the shipped bundle must never carry it. Listed in the project anyway, for
# the same reason `KanbanUITests`' verification documents are — visible where it belongs,
# built into nothing.
- path: Configuration.storekit
type: file
buildPhase: none
dependencies: dependencies:
- package: Yams - package: Yams
- package: swift-markdown - package: swift-markdown
@@ -100,11 +89,12 @@ targets:
MARKETING_VERSION: "2.0" MARKETING_VERSION: "2.0"
INFOPLIST_FILE: Kanban/Info.plist INFOPLIST_FILE: Kanban/Info.plist
# `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, and # `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, and
# `com.apple.security.network.client`, which is **declared now and dormant until Pro # `com.apple.security.network.client`, **declared now and exercised by nothing**: the key
# ships**: the one binary carries the key, and nothing exercises it until the git # stays because the sync capability to come needs it regardless (12-editions.md ▸ PIVOT
# provider's remotes do under an active subscription (12 ▸ The target). No App Group and # 2026-08-08), and a key added later is a new provisioning profile and a new review
# no keychain access group — groups exist to share *between* apps, and there is one app, # surface. No App Group and no keychain access group — groups exist to share *between*
# so app-side state homes in the ordinary sandbox container (`AppStateHome`). # apps, and there is one app, so app-side state homes in the ordinary sandbox container
# (`AppStateHome`).
CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements
GENERATE_INFOPLIST_FILE: false GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete SWIFT_STRICT_CONCURRENCY: complete
@@ -121,7 +111,7 @@ targets:
# #
# What it deliberately does NOT compile: LiveStore (FSEvents — macOS-only machinery; the phone's # What it deliberately does NOT compile: LiveStore (FSEvents — macOS-only machinery; the phone's
# change signal is NSMetadataQuery over the ubiquity container), Git/History (no git support in # change signal is NSMetadataQuery over the ubiquity container), Git/History (no git support in
# the mobile MVP), Tier, and all of `Kanban/UI`. The mobile app's own sources live under # the mobile MVP), and all of `Kanban/UI`. The mobile app's own sources live under
# `KanbanMobile/`. # `KanbanMobile/`.
# #
# Boards live in the iCloud Drive container `iCloud.dev.rzen.indie.Kanban` — named after the # Boards live in the iCloud Drive container `iCloud.dev.rzen.indie.Kanban` — named after the
@@ -281,15 +271,6 @@ schemes:
Kanban: all Kanban: all
run: run:
config: Debug config: Debug
# StoreKit Testing in Xcode (12-editions.md ▸ Distribution): a ⌘R run resolves
# `dev.rzen.indie.kanban.pro.monthly` against the local `Configuration.storekit` instead of
# against the App Store, so the Settings Pro section shows a price and its purchase flow
# completes on a machine with no App Store Connect product and no sandbox account.
#
# It changes nothing about a build or an archive — it is a *run action* setting, so `xcodebuild
# build`, `xcodebuild test` and `scripts/release.sh` are untouched, and a shipped app resolves
# the real product or reports that it cannot reach the App Store.
storeKitConfiguration: Configuration.storekit
test: test:
config: Debug config: Debug
gatherCoverageData: false gatherCoverageData: false