Lanework Pro is a subscription — the tier seam, StoreKit 2, and Settings
Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution, ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro — deliberately no .lapsed case; unsubscribed and lapsed are one state) and the pure decision Tier.resolve(from:now:) over SubscriptionFacts (expiration + willAutoRenew), unit-tested through all five named states: free, active, lapsed, offline-grace, never-online. The facts are a persisted cache (standard defaults), not a live view: StoreKit ages an expired subscription out of currentEntitlements locally, so an offline device and a real lapse are indistinguishable from that property alone — the cache holds the last answer, empty entitlements read as silence, and holds end only on a definitive answer (revocation, or the subscription-group status read Settings performs). That is 12's offline-grace trade, resolved toward the paying user. ProEntitlement is the local adapter (currentEntitlements + Transaction.updates, started from launch, never from a test host); ProStorefront holds everything networked (product load, purchase, AppStore.sync) and only the Settings section ever constructs one — the split is the enforcement of "never network on the open path". beginSession reads the tier once at composition; BoardSession.tier is a let with no path back in, so a lapse never rebinds an open session. makeHistoryProvider now takes the tier; both tiers bind the native stack until pro-m1 builds the git provider — the seam's consumer is named, not invented early. Settings gains the Pro section (subscribe with localized price, manage, restore; a quiet unreachable line, no indefinite spinner) — the third of the exactly-three Pro mentions; the About line gains its "…in Settings" pointer now that there is a Settings to point at. A successful purchase or restore offers once to reopen open boards (close + reopen through the ordinary paths). Configuration.storekit wired into the scheme's run action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side steps and the rule that the product must not be configured before then. 1901 tests in 319 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,13 @@ import IndieAbout
|
|||||||
/// one-app collapse (12, re-ruled 2026-07-30) makes Pro a subscription, so the line is a pointer
|
/// one-app collapse (12, re-ruled 2026-07-30) makes Pro a subscription, so the line is a pointer
|
||||||
/// to something a subscriber has already taken up rather than to another download — true either
|
/// to something a subscriber has already taken up rather than to another download — true either
|
||||||
/// way, and shown to everyone. It stays deliberately flat: what Pro adds, in one sentence, with
|
/// way, and shown to everyone. It stays deliberately flat: what Pro adds, in one sentence, with
|
||||||
/// no call to action. It gains its "…in Settings" when the Settings Pro section it would point at
|
/// no call to action.
|
||||||
/// actually exists (the StoreKit phase); until then a direction would be a dead end.
|
///
|
||||||
|
/// **The direction arrived with the section.** The line was written without its "…in Settings"
|
||||||
|
/// while there was no Settings Pro section to point at — a signpost to a dead end is worse than no
|
||||||
|
/// signpost. `ProSettingsSection` exists now, so the sentence ends where the subscription actually
|
||||||
|
/// lives. Still one sentence, still flat, still no verb aimed at the reader: it names the place, it
|
||||||
|
/// does not ask them to go there.
|
||||||
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
|
||||||
@@ -26,7 +31,7 @@ enum AboutBox {
|
|||||||
/// own single-line paragraphs.
|
/// own single-line paragraphs.
|
||||||
static var configuration: AppInfoConfiguration {
|
static var configuration: AppInfoConfiguration {
|
||||||
AppInfoConfiguration(
|
AppInfoConfiguration(
|
||||||
copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync.",
|
copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync — see Settings.",
|
||||||
documents: [.license(extension: "md")],
|
documents: [.license(extension: "md")],
|
||||||
changelogDocument: .changelog()
|
changelogDocument: .changelog()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ 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).
|
||||||
|
///
|
||||||
|
/// **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`).
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
appModel?.entitlement.start()
|
||||||
|
}
|
||||||
|
|
||||||
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
|
||||||
/// alive)" — a document-shaped app whose windows are boards has no business quitting because the
|
/// alive)" — a document-shaped app whose windows are boards has no business quitting because the
|
||||||
/// user tidied one away, and welcome is one Dock click or one menu item back.
|
/// user tidied one away, and welcome is one Dock click or one menu item back.
|
||||||
|
|||||||
+134
-2
@@ -66,6 +66,16 @@ public enum AppPreferences {
|
|||||||
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
|
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
|
||||||
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
||||||
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
||||||
|
|
||||||
|
/// 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
|
||||||
@@ -193,6 +203,29 @@ 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 for `makeHistoryProvider`'s
|
||||||
|
/// reason exactly: 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.
|
||||||
|
@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
|
||||||
@@ -209,8 +242,21 @@ public final class AppModel {
|
|||||||
/// store's snapshots. A property rather than an initializer argument so a test
|
/// store's snapshots. A property rather than an initializer argument so a test
|
||||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||||
/// nothing renders from it.
|
/// nothing renders from it.
|
||||||
|
///
|
||||||
|
/// ### The `Tier` argument, and the one consumer it is still short
|
||||||
|
///
|
||||||
|
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
|
||||||
|
/// today and binds the native stack for both — not because the seam is decorative, but because
|
||||||
|
/// the git provider it would bind does not exist yet (12 ▸ Tier matrix: git init/adoption,
|
||||||
|
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
|
||||||
|
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
|
||||||
|
/// than a change to the composition root, and so that the tier a board actually composed under is
|
||||||
|
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
|
||||||
|
/// alongside its provider.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var makeHistoryProvider: (BoardStore) -> any HistoryProviding = { _ in NativeHistoryProvider() }
|
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
|
||||||
|
NativeHistoryProvider()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Sessions
|
// MARK: Sessions
|
||||||
|
|
||||||
@@ -232,6 +278,22 @@ public final class AppModel {
|
|||||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||||
public let history: any HistoryProviding
|
public let history: any HistoryProviding
|
||||||
|
|
||||||
|
/// **The tier this board composed under** (12-editions.md ▸ The entitlement).
|
||||||
|
///
|
||||||
|
/// A `let`, on a value type, set once by `beginSession` — which is the entire mechanism
|
||||||
|
/// behind "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`". There is no
|
||||||
|
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a
|
||||||
|
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
|
||||||
|
/// already on screen.
|
||||||
|
///
|
||||||
|
/// It is recorded rather than merely used-and-discarded because the provider it selects is
|
||||||
|
/// not the only thing that will ever ask. pro-m1's surfaces — the card window's History
|
||||||
|
/// section, View ▸ History (12 ▸ Tier matrix) — are per-board questions asked long after
|
||||||
|
/// composition, and they must get the answer this board actually opened with rather than
|
||||||
|
/// whatever the entitlement happens to say when the sidebar renders.
|
||||||
|
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.
|
||||||
@@ -439,6 +501,14 @@ public final class AppModel {
|
|||||||
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
|
||||||
styleRecents = StyleRecents(defaults: preferences)
|
styleRecents = StyleRecents(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
|
||||||
@@ -569,10 +639,18 @@ 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, on the same line
|
||||||
|
// as the provider it selects — which is the shape that makes "the open path never waits on
|
||||||
|
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is
|
||||||
|
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
|
||||||
|
// re-derives it.
|
||||||
|
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".
|
||||||
let history = makeHistoryProvider(store)
|
let history = makeHistoryProvider(store, tier)
|
||||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||||
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
||||||
@@ -584,6 +662,7 @@ 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
|
||||||
@@ -753,6 +832,59 @@ 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
|
||||||
|
///
|
||||||
|
/// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who
|
||||||
|
/// says Not Now loses exactly nothing that exists yet. The offer is built now because the
|
||||||
|
/// *mechanism* is what the design specifies and because a milestone that shipped the subscription
|
||||||
|
/// without it would leave a visible gap the moment the git provider lands.
|
||||||
|
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
|
||||||
|
|||||||
@@ -379,10 +379,14 @@ private struct RecentBoardRow: View {
|
|||||||
|
|
||||||
/// The app's preferences (⌘, — 11-command-nexus.md).
|
/// The app's preferences (⌘, — 11-command-nexus.md).
|
||||||
///
|
///
|
||||||
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only
|
/// Two sections. The first is the one control v1 shipped with: "Restore open boards at launch",
|
||||||
/// whether the registry's open-now flags are *consulted* at launch — the flags themselves are
|
/// whose preference gates only whether the registry's open-now flags are *consulted* at launch — the
|
||||||
/// maintained either way, which is what keeps crash recovery working for a user who has restoration
|
/// flags themselves are maintained either way, which is what keeps crash recovery working for a user
|
||||||
/// turned off and then turns it back on.
|
/// who has restoration turned off and then turns it back on.
|
||||||
|
///
|
||||||
|
/// The second is **where Lanework Pro lives** (12-editions.md ▸ Distribution: "purchased and managed
|
||||||
|
/// in a Pro section of Settings (⌘,) — subscribe, manage, restore purchases"), and the third of the
|
||||||
|
/// exactly three places the app names Pro at all — see `ProSettingsSection` for its own posture.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
|
||||||
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
|
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
|
||||||
@@ -395,11 +399,14 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.")
|
Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProSettingsSection()
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one
|
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip a footer
|
||||||
// toggle's footer sentence at a large system text size with no way to resize out of it
|
// sentence at a large system text size with no way to resize out of it
|
||||||
// (10-accessibility.md ▸ Text scaling).
|
// (10-accessibility.md ▸ Text scaling). The height stays free — the Pro section's own height
|
||||||
|
// depends on which of its three states it is in, and pinning it would clip the longest.
|
||||||
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
|
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// **The Settings Pro section** — subscribe, manage, restore (12-editions.md ▸ Distribution), and
|
||||||
|
/// the third and last place the app names Lanework Pro (12 ▸ Tier naming; the other two are
|
||||||
|
/// `AboutBox`'s line and `BoardGitNote`'s contextual one).
|
||||||
|
///
|
||||||
|
/// ### 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
|
||||||
|
/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git
|
||||||
|
/// provider (`AppModel.makeHistoryProvider`).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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. Binds the git history provider when
|
||||||
|
/// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
|
|||||||
defer { tearDown() }
|
defer { tearDown() }
|
||||||
|
|
||||||
let bound = FakeHistoryProvider()
|
let bound = FakeHistoryProvider()
|
||||||
model.makeHistoryProvider = { _ in bound }
|
model.makeHistoryProvider = { _, _ in bound }
|
||||||
|
|
||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
@@ -596,6 +596,72 @@ 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 tier reaches the composition root, and the session records what it composed under")
|
||||||
|
func theTierIsAComposedFact() 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 handed to the
|
||||||
|
// root that binds the provider. Both tiers bind the native stack until pro-m1 — what this
|
||||||
|
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
|
||||||
|
var seen: [Tier] = []
|
||||||
|
model.currentTier = { .pro }
|
||||||
|
model.makeHistoryProvider = { _, tier in
|
||||||
|
seen.append(tier)
|
||||||
|
return NativeHistoryProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
|
let session = try #require(model.session(for: ref))
|
||||||
|
|
||||||
|
#expect(seen == [.pro])
|
||||||
|
#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; the next open composes the native stack over inert `.git`".
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
- **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12).
|
||||||
|
|
||||||
|
- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription will unlock is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **None of that is built yet** — pro-m1 and pro-m2 are the milestones that build it, and until they ship both tiers run the same native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
||||||
@@ -73,6 +75,8 @@ macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `
|
|||||||
|
|
||||||
**One app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares exactly three: the app `Kanban`, the unit bundle `KanbanTests` hosted by it, and the UI bundle `KanbanUITests`; one scheme, `Kanban`, builds and tests all of them. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.
|
**One app target** (DESIGN/12-editions.md ▸ The target). `project.yml` declares exactly three: the app `Kanban`, the unit bundle `KanbanTests` hosted by it, and the UI bundle `KanbanUITests`; one scheme, `Kanban`, builds and tests all of them. The 2026-07-27 two-target edition split — a second `KanbanPro` application, its `LaneworkPro` scheme, a `KanbanProTests` bundle bound to it by `-module-alias`, and `scripts/verify-editions.sh` — was retired on 2026-07-30 in favour of one app with Lanework Pro as a subscription inside it. The `dev.rzen.indie.kanban-board` UTI and the three pasteboard types are exported once, by the one app that owns them.
|
||||||
|
|
||||||
|
**StoreKit runs locally in development.** `Configuration.storekit` at the repo root declares the "Lanework Pro" subscription group and its monthly product (`dev.rzen.indie.kanban.pro.monthly`), and `project.yml` attaches it to the scheme's *run* action, so a ⌘R build shows a real price and completes a real purchase with no App Store Connect product and no sandbox account. It is a run-action setting only — builds, tests and `scripts/release.sh` are untouched, and the file is `buildPhase: none`, so it never reaches the shipped bundle. The same three strings live in App Store Connect (created by hand — RELEASE.md) and in `Kanban/Tier/ProProducts.swift`; a mismatch shows up in the app as "Can't reach the App Store right now."
|
||||||
|
|
||||||
**Accessibility is verified, not assumed** (DESIGN/10-accessibility.md § Verification). `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names — the board with the trash shown and hidden, the card window in Preview, Edit and raw source, welcome, the template chooser, the board popover — and every violation is a test failure with nothing waived. The manual half — the per-release VoiceOver smoke script and the consolidated accessibility checklist — is `KanbanUITests/AccessibilityVerification.md`.
|
**Accessibility is verified, not assumed** (DESIGN/10-accessibility.md § Verification). `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names — the board with the trash shown and hidden, the card window in Preview, Edit and raw source, welcome, the template chooser, the board popover — and every violation is a test failure with nothing waived. The manual half — the per-release VoiceOver smoke script and the consolidated accessibility checklist — is `KanbanUITests/AccessibilityVerification.md`.
|
||||||
|
|
||||||
**The golden paths are verified end to end too.** `KanbanUITests/EndToEndFlowTests.swift` drives create card, create lane, inline rename, a pointer drag across lanes, cut/paste across lanes, undo and redo of a move, and the whole current trash grammar — delete into `.trash/` with no confirmation, View ▸ Show Trash, restore by ⌘X/⌘V back out, and Empty Trash… with its confirmation. `FailFastLaunchTests.swift` launches onto a board with one unparseable `index.md` and asserts the loud failure: no board window, welcome carrying the loader's own sentence naming the offending file, and nothing on disk repaired. `LargeBoardPerformanceTests.swift` measures launch and one interaction against an 8 × 40 board under explicit wall-clock budgets (XCTest baselines do not travel between machines, so the gate is an assertion rather than a baseline). The three flows that stay manual — instantiating a template, Duplicate's save-panel fallback, and File ▸ Open… — are the sandbox's Powerbox panels, which live in another process; they are written down as manual steps in `KanbanUITests/EndToEndVerification.md` rather than automated flakily.
|
**The golden paths are verified end to end too.** `KanbanUITests/EndToEndFlowTests.swift` drives create card, create lane, inline rename, a pointer drag across lanes, cut/paste across lanes, undo and redo of a move, and the whole current trash grammar — delete into `.trash/` with no confirmation, View ▸ Show Trash, restore by ⌘X/⌘V back out, and Empty Trash… with its confirmation. `FailFastLaunchTests.swift` launches onto a board with one unparseable `index.md` and asserts the loud failure: no board window, welcome carrying the loader's own sentence naming the offending file, and nothing on disk repaired. `LargeBoardPerformanceTests.swift` measures launch and one interaction against an 8 × 40 board under explicit wall-clock budgets (XCTest baselines do not travel between machines, so the gate is an assertion rather than a baseline). The three flows that stay manual — instantiating a template, Duplicate's save-panel fallback, and File ▸ Open… — are the sandbox's Powerbox panels, which live in another process; they are written down as manual steps in `KanbanUITests/EndToEndVerification.md` rather than automated flakily.
|
||||||
|
|||||||
+23
-2
@@ -42,6 +42,27 @@ Run top to bottom; nothing below "PUSH POINT" may run without a deliberate decis
|
|||||||
12. [ ] Review the staged listing at appstoreconnect.apple.com (age rating, privacy labels, and content rights carry over from 1.x — the stage step's errors will name anything missing).
|
12. [ ] Review the staged listing at appstoreconnect.apple.com (age rating, privacy labels, and content rights carry over from 1.x — the stage step's errors will name anything missing).
|
||||||
13. [ ] Submit: the same command with `--submit`.
|
13. [ ] Submit: the same command with `--submit`.
|
||||||
|
|
||||||
## Lanework Pro (later)
|
## Lanework Pro (the subscription)
|
||||||
|
|
||||||
Pro is **a subscription inside this app, not a second record** (DESIGN/12-editions.md ▸ Distribution, re-ruled 2026-07-30): the two-app split that once wanted its own bundle id, metadata folder and scheme here is retired. It ships after pro-m1 (git history) and pro-m2 (remote sync) as an auto-renewable subscription configured against `dev.rzen.indie.Kanban` in App Store Connect. Nothing in this pipeline changes for it — same scheme, same archive, same upload; the subscription products and their review materials are the added work, and this section gets its steps when that phase is designed.
|
Pro is **a subscription inside this app, not a second record** (DESIGN/12-editions.md ▸ Distribution, re-ruled 2026-07-30): the two-app split that once wanted its own bundle id, metadata folder and scheme here is retired. Nothing in the pipeline above changes for it — same scheme, same archive, same upload. The added work is App Store Connect configuration and review materials, and it is all below.
|
||||||
|
|
||||||
|
The **app side is built** (the entitlement, the tier seam, the Settings Pro section, the local `Configuration.storekit`). The **store side is not** and must not be until pro-m1 ships something to sell: a live subscription product against a build whose Pro tier binds the same native undo stack the free tier does would be selling nothing. So every step here is future work for the **pro-m1 release**, not for 2.0.
|
||||||
|
|
||||||
|
### What exists in the repo today
|
||||||
|
|
||||||
|
- `Kanban/Tier/ProProducts.swift` — the identifiers, mirrored from App Store Connect: product `dev.rzen.indie.kanban.pro.monthly`, group reference name `Lanework Pro`. The group's numeric id is read off the loaded product at runtime, never hardcoded.
|
||||||
|
- `Configuration.storekit` (repo root) — the same group and product as an Xcode-local StoreKit configuration, attached to the `Kanban` scheme's **run** action (`project.yml` ▸ `schemes.Kanban.run.storeKitConfiguration`). It makes the subscribe/manage/restore flow exercisable with no ASC product and no sandbox account. `buildPhase: none` — it never ships.
|
||||||
|
- Entitlements: no in-app-purchase key is needed. In-app purchase is an App Store Connect fact about the record, not a sandbox capability; the app calls StoreKit with the entitlements it already has.
|
||||||
|
|
||||||
|
### Checklist — pro-m1 release (not 2.0)
|
||||||
|
|
||||||
|
Steps 1–4 are App Store Connect work done by hand at appstoreconnect.apple.com; there is no script for them and no API step in this repo's tooling. They are all **below the PUSH POINT in spirit** — each one touches the live record of a shipping app.
|
||||||
|
|
||||||
|
1. [ ] Create the subscription group on `dev.rzen.indie.Kanban`: reference name **Lanework Pro**. One group — a second would let a user hold two Pro subscriptions at once.
|
||||||
|
2. [ ] Create the auto-renewable subscription in it: product id **`dev.rzen.indie.kanban.pro.monthly`**, reference name "Lanework Pro Monthly", duration **1 month**. The product id must match `ProProducts.monthly` exactly and can never be changed or reused once created.
|
||||||
|
3. [ ] Set the price (choose the tier; all territories, matching the app's ALL-territories availability) and the localized display name and description. The app never spells a price — it renders `product.displayPrice`, so the store is the only source.
|
||||||
|
4. [ ] Subscription review materials, which are separate from the app's: a **review screenshot of the Settings Pro section** showing the price, Subscribe and Restore Purchases; review notes naming Settings (⌘,) as the path to the purchase surface, that the subscription unlocks git-backed history and sync, and that there is no account or login of any kind.
|
||||||
|
5. [ ] Confirm the app's own listing carries the required subscription furniture: a link to the privacy policy and to the terms of use (EULA), both reachable from the listing, plus the auto-renewing terms in the description. Apple rejects auto-renewable subscriptions without them.
|
||||||
|
6. [ ] Update `Configuration.storekit`'s `displayPrice` to whatever step 3 set, so a development run and the store agree.
|
||||||
|
7. [ ] Verify in a **sandbox** account before submitting: subscribe, confirm the Settings section flips to "Subscribed — renews …", confirm the reopen offer appears with boards open, then Manage Subscription and Restore Purchases.
|
||||||
|
8. [ ] Then run the ordinary release checklist above for the pro-m1 version. The subscription is attached to that version's review submission.
|
||||||
|
|||||||
+21
@@ -66,6 +66,18 @@ 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
|
||||||
@@ -160,6 +172,15 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user