diff --git a/Configuration.storekit b/Configuration.storekit deleted file mode 100644 index 11ba2ca..0000000 --- a/Configuration.storekit +++ /dev/null @@ -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 - } -} diff --git a/Kanban/App/AboutBox.swift b/Kanban/App/AboutBox.swift index 2dbb1b6..87afb27 100644 --- a/Kanban/App/AboutBox.swift +++ b/Kanban/App/AboutBox.swift @@ -2,12 +2,9 @@ import IndieAbout /// **The About box's configuration.** /// -/// **Dormant since the 2026-08-07 pivot** (12-editions.md ▸ PIVOT 2026-08-07 — git leaves the -/// paywall): this box used to carry a second copyright line naming Lanework Pro, one of the three -/// places 12's pre-pivot "Tier naming" section named the subscription. Git left the paywall, the -/// 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. +/// **One box, one copyright line, shown to everyone** (12-editions.md ▸ PIVOT 2026-08-08 — one +/// version, everything free): this box once carried a second copyright line naming a paid edition, +/// back when the app had editions to name. It has none, so the line is gone and the box is plain. enum AboutBox { /// The About window's content: version/build/date from the stamped Info.plist diff --git a/Kanban/App/AppDelegate.swift b/Kanban/App/AppDelegate.swift index 96dfdef..fc2a51a 100644 --- a/Kanban/App/AppDelegate.swift +++ b/Kanban/App/AppDelegate.swift @@ -16,24 +16,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { 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* /// this app, so `KanbanApp.init()` and therefore `AppModel.init` run on every test launch - /// (`AppStateHome.isUnitTestHost`). Building the entitlement there costs one `UserDefaults` read; - /// acquiring a StoreKit listener there would give every test run a live `Transaction.updates` - /// subscription for no reason. Launch is the honest home for a listener, 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`). + /// (`AppStateHome.isUnitTestHost`). `AppearanceStore.init` only reads a default; this is the one + /// call that hands its answer to `NSApp` — the global side effect `KanbanApp.init` must not + /// carry (`AppearanceStore.applyCurrent`). Launch is the honest home for it, and this is the + /// app's. func applicationDidFinishLaunching(_ notification: Notification) { - appModel?.entitlement.start() appModel?.appearance.applyCurrent() } diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index eae9e15..6686035 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -146,16 +146,6 @@ public enum AppPreferences { public static var appearance: AppAppearance? { 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 @@ -323,36 +313,6 @@ public final class AppModel { /// reads the pasteboard once and collects every staged tree it no longer names. 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 /// **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** /// (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 - /// decides this any more: not its tier (12 ▸ PIVOT 2026-08-07 took the last row the tier decided), - /// not whether it sits inside somebody's repository, not what is on disk beside it. A board - /// nobody has done anything 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. + /// decides this any more: not what the user paid (12-editions.md ▸ PIVOT 2026-08-08 — one + /// version, everything free, no edition axis left to consult), not whether it sits inside + /// somebody's repository, not what is on disk beside it. A board nobody has done anything + /// 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 /// @@ -421,30 +382,12 @@ public final class AppModel { /// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which /// 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 - /// mid-session substrate swap was add-git's commanded mode flip, which went with the git - /// stack. It stays a `var` because a second provider is a live possibility - /// (`strategy/01-git-excision.md` ▸ Reversibility) and because nothing is bought by tightening - /// 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. + /// A `var` rather than a `let`, and now for no event at all: the one sanctioned mid-session + /// substrate swap was add-git's commanded mode flip, which went with the git stack. It stays + /// a `var` because a second provider is a live possibility (`strategy/01-git-excision.md` ▸ + /// Reversibility) and because nothing is bought by tightening it. 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 /// 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. @@ -670,14 +613,6 @@ public final class AppModel { zoom = BoardZoomStore(defaults: preferences) appearance = AppearanceStore(defaults: preferences) 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 // 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 @@ -836,16 +771,6 @@ public final class AppModel { /// drop 02 forbids — it forbids a failure that was never surfaced disappearing, not one the user /// has since fixed. 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` // 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". @@ -861,7 +786,6 @@ public final class AppModel { store: store, recordID: recordID, history: history, - tier: tier, // 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 // 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 } } - /// **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**. /// /// Sequential rather than concurrent so each board's ordering is the one 02 fixes rather than diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift index 8235b6a..46fb606 100644 --- a/Kanban/App/WelcomeView.swift +++ b/Kanban/App/WelcomeView.swift @@ -379,15 +379,15 @@ private struct RecentBoardRow: View { /// The app's preferences (⌘, — 11-command-nexus.md). /// -/// One section today: "Restore open boards at launch", whose preference gates only whether the -/// registry's open-now flags are *consulted* at launch — the flags themselves are maintained either -/// way, which is what keeps crash recovery working for a user who has restoration turned off and then -/// turns it back on. +/// One section, and one section is all there is: "Restore open boards at launch", whose preference +/// gates only whether the registry's open-now flags are *consulted* at launch — the flags themselves +/// are maintained either way, which is what keeps crash recovery working for a user who has +/// 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 -/// paywall): `ProSettingsSection` still exists and still compiles, but this scene no longer renders -/// it — the base/Pro split is being re-decided, and until it's ruled, no surface in the app names or -/// sells Lanework Pro. +/// **There is no purchase section, and nothing to build one out of** (12-editions.md ▸ PIVOT +/// 2026-08-08 — one version, everything free): the subscription machinery this pane once hosted is +/// excised, not dormant. A paid tier returns as a fresh design pass, with the iPhone companion and +/// sync, and will bring its own surface. struct SettingsView: View { @AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey) diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index f1f6c96..8549f1a 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -181,8 +181,8 @@ struct KanbanApp: App { // 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` // 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, - // every tier, identical. + // bundled changelog. `AboutBox` names no edition (12-editions.md ▸ PIVOT 2026-08-08) — + // there is one version of this app, so there is one box. IndieAboutCommand(configuration: AboutBox.configuration) // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…, diff --git a/Kanban/Tier/ProEntitlement.swift b/Kanban/Tier/ProEntitlement.swift deleted file mode 100644 index c1eeb30..0000000 --- a/Kanban/Tier/ProEntitlement.swift +++ /dev/null @@ -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? - - 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) 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) - } -} diff --git a/Kanban/Tier/ProProducts.swift b/Kanban/Tier/ProProducts.swift deleted file mode 100644 index 5b59cce..0000000 --- a/Kanban/Tier/ProProducts.swift +++ /dev/null @@ -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 = [monthly] -} diff --git a/Kanban/Tier/ProSettingsSection.swift b/Kanban/Tier/ProSettingsSection.swift deleted file mode 100644 index 481bba7..0000000 --- a/Kanban/Tier/ProSettingsSection.swift +++ /dev/null @@ -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 - } -} diff --git a/Kanban/Tier/ProStorefront.swift b/Kanban/Tier/ProStorefront.swift deleted file mode 100644 index 75c5110..0000000 --- a/Kanban/Tier/ProStorefront.swift +++ /dev/null @@ -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 - } - } -} diff --git a/Kanban/Tier/Tier.swift b/Kanban/Tier/Tier.swift deleted file mode 100644 index e25ef58..0000000 --- a/Kanban/Tier/Tier.swift +++ /dev/null @@ -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 - } -} diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 32877f4..c2832ab 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -284,9 +284,8 @@ struct NativeHistoryProviderTests { /// 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 -/// the adapter works for any implementation of the seam (Pro's git provider once bound the same -/// protocol, in pro-m1, before app-managed git was excised entirely, -/// `strategy/01-git-excision.md`). +/// the adapter works for any implementation of the seam (the git provider bound the same protocol, +/// in pro-m1, before app-managed git was excised entirely, `strategy/01-git-excision.md`). @MainActor private final class FakeHistoryProvider: HistoryProviding { @@ -597,79 +596,6 @@ struct BoardSessionHistoryTests { session.undoManager.undo() #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 diff --git a/KanbanTests/TierTests.swift b/KanbanTests/TierTests.swift deleted file mode 100644 index 0d574e6..0000000 --- a/KanbanTests/TierTests.swift +++ /dev/null @@ -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) - } -} diff --git a/KanbanUITests/AccessibilityAuditTests.swift b/KanbanUITests/AccessibilityAuditTests.swift index 2c90f86..f8f9bec 100644 --- a/KanbanUITests/AccessibilityAuditTests.swift +++ b/KanbanUITests/AccessibilityAuditTests.swift @@ -20,13 +20,13 @@ import XCTest /// /// ### 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, -/// and a `--ui-test-pro` launch argument was rejected as a subscription bypass anyone could type -/// into Terminal (`UITestLaunch` ships in the app binary on purpose). The 2026-08-07 pivot -/// (12-editions.md ▸ PIVOT 2026-08-07 — git left the paywall) made it reachable and it gained a test -/// here; the **reversal later the same day retired the sheet outright**, and the test with it. What -/// remains is the precedent — the no-bypass objection stands for whatever the next split gates — -/// and `testBoardInfoPopover` opens the popover onto its Info tab, which the audit covers. +/// The sheet was once gated, and this suite could not reach it: the gate had no test control, and a +/// launch argument that would have opened it was rejected as a bypass anyone could type into +/// Terminal (`UITestLaunch` ships in the app binary on purpose). The gate is gone — there is one +/// version of the app and nothing to unlock (12-editions.md ▸ PIVOT 2026-08-08) — and the sheet +/// itself retired on 2026-08-07, taking the test with it. What remains is the precedent: a launch +/// argument never exists to open something the shipped app would refuse. `testBoardInfoPopover` +/// 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, /// 2026-08-08) removed it; the popover is Info, Theme, Sync now, and there is no tab-specific audit diff --git a/project.yml b/project.yml index b0096d1..2621852 100644 --- a/project.yml +++ b/project.yml @@ -35,10 +35,11 @@ settings: ENABLE_USER_SCRIPT_SANDBOXING: NO # **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 -# second binary. The 2026-07-27 two-target split — a `KanbanPro` application target, its scheme, its -# bundle id, the `-module-alias Kanban=KanbanPro` test bundle and the twin source files that existed -# only because two bundles claimed different UTI ownership — retired wholesale with the collapse. +# Mac App Store app, `dev.rzen.indie.Kanban` — **one version, everything in it** (12-editions.md ▸ +# PIVOT 2026-08-08): there is no edition axis for this file to express. The 2026-07-27 two-target +# split — a `KanbanPro` application target, its scheme, its bundle id, the `-module-alias +# 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 # resources. There is one product, one Info.plist, one entitlements file, one Swift module, and one @@ -67,18 +68,6 @@ targets: - path: CHANGELOG.md type: file 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: - package: Yams - package: swift-markdown @@ -100,11 +89,12 @@ targets: MARKETING_VERSION: "2.0" INFOPLIST_FILE: Kanban/Info.plist # `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, and - # `com.apple.security.network.client`, which is **declared now and dormant until Pro - # ships**: the one binary carries the key, and nothing exercises it until the git - # provider's remotes do under an active subscription (12 ▸ The target). No App Group and - # no keychain access group — groups exist to share *between* apps, and there is one app, - # so app-side state homes in the ordinary sandbox container (`AppStateHome`). + # `com.apple.security.network.client`, **declared now and exercised by nothing**: the key + # stays because the sync capability to come needs it regardless (12-editions.md ▸ PIVOT + # 2026-08-08), and a key added later is a new provisioning profile and a new review + # surface. No App Group and no keychain access group — groups exist to share *between* + # apps, and there is one app, so app-side state homes in the ordinary sandbox container + # (`AppStateHome`). CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements GENERATE_INFOPLIST_FILE: false SWIFT_STRICT_CONCURRENCY: complete @@ -121,7 +111,7 @@ targets: # # 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 - # 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/`. # # Boards live in the iCloud Drive container `iCloud.dev.rzen.indie.Kanban` — named after the @@ -281,15 +271,6 @@ schemes: Kanban: all run: 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: config: Debug gatherCoverageData: false