diff --git a/Kanban/App/AboutBox.swift b/Kanban/App/AboutBox.swift new file mode 100644 index 0000000..e366516 --- /dev/null +++ b/Kanban/App/AboutBox.swift @@ -0,0 +1,34 @@ +import IndieAbout + +/// **The About box's configuration — one of the three places the app names Lanework Pro.** +/// +/// 12-editions.md ▸ Tier naming: "The free tier presents as a complete app, not a demo: Pro is +/// named in exactly three places — one line in the About box, the contextual popover line on +/// `.git` boards, and the Settings Pro section where the subscription actually lives. Nothing on +/// the welcome screen, nothing in banners." The popover line is `BoardGitNote`'s; this is the +/// About line, riding as the second line of the copyright text — `IndieAbout` renders +/// `copyrightText` as one multi-line `Text`, so the signpost is literally a line in the box, no +/// custom layout. +/// +/// **One box for every tier.** The 2026-07-27 split gave Pro a twin of this file without the +/// signpost, on the reasoning that a separate Pro app pointing at itself would be noise; the +/// 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 +/// 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 +/// actually exists (the StoreKit phase); until then a direction would be a dead end. +enum AboutBox { + + /// The About window's content: version/build/date from the stamped Info.plist + /// (`update_build_info.sh` — CFBundleVersion, BuildDate, BuildHash), the version line + /// opening the bundled CHANGELOG.md, and the bundled ISC LICENSE.md as the one document + /// link. Both documents are authored to the Apple inline-Markdown subset — see the files' + /// own single-line paragraphs. + static var configuration: AppInfoConfiguration { + AppInfoConfiguration( + copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync.", + documents: [.license(extension: "md")], + changelogDocument: .changelog() + ) + } +} diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index bb5f63b..d1da834 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -209,11 +209,11 @@ public final class AppModel { /// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a /// board session's undo stack is built by, called once per board as its session begins. /// - /// Base binds the native stack — a pair of step stacks over the inverses registered at the - /// Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default here - /// because it is the *shared* code's implementation: both targets compile it, and Pro runs it - /// too until pro-m1 replaces this closure with the git provider (06-history-undo.md). Nothing in - /// this file is edition-conditional; the edition difference is which closure the root installs. + /// The free tier binds the native stack — a pair of step stacks over the inverses registered at + /// the Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default + /// here because it is the substrate every tier can always fall back to; pro-m1 installs the git + /// provider over this closure when the subscription is active (06-history-undo.md). Nothing in + /// this file is tier-conditional; the tier difference is which closure the root installs. /// /// It takes the store because that is what a provider is a history *of*: the git provider needs /// the board root it is a repository at, and the native one's steps are computed from the same @@ -239,7 +239,7 @@ public final class AppModel { /// (13-native-undo.md ▸ Rules). It lives here for the store's reason exactly: the session is /// what every window over this board shares, and "undo is board-local". /// - /// Which implementation it is, is the edition's answer and nobody else's + /// Which implementation it is, is the tier's answer and nobody else's /// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`. public let history: any HistoryProviding diff --git a/Kanban/App/ClipboardManifest.swift b/Kanban/App/ClipboardManifest.swift index 26a732f..390d9f7 100644 --- a/Kanban/App/ClipboardManifest.swift +++ b/Kanban/App/ClipboardManifest.swift @@ -8,8 +8,8 @@ import UniformTypeIdentifiers // JSON `ClipboardManifest` below (04-interactions.md ▸ Clipboard: "the pasteboard carries a JSON // manifest + plain text"). Declared in `Info.plist` beside the two drag types, for the same reason // those are: a payload nobody has declared is a payload the system will not carry. The constant -// itself lives in `EditionTypes.swift` — base exports the type, Pro imports it, and each edition's -// twin uses the initializer its own plist can honor. +// itself lives with its two siblings in `PasteboardTypes.swift`, exported once by the app that +// owns it. // MARK: - The manifest diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 32d0a98..f805e38 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -19,6 +19,11 @@ import os /// missing or unreadable **refuses whole and writes nothing** (04-interactions.md ▸ Clipboard, /// re-ruled 2026-07-29 — Finder's invariant: an item arrives whole or not at all). /// +/// **⚠ one-app collapse phase 2**: the paragraph below describes a sharing arrangement that stops +/// existing when the App Group does (12-editions.md ▸ App-side state, re-ruled 2026-07-30) — the +/// staging store moves to the ordinary sandbox container and the sibling it tolerates is only ever +/// the developer's own second copy. The tolerance itself is worth keeping either way. +/// /// **The store is shared by every installed edition** (12-editions.md ▸ Both editions installed, ruled /// 2026-07-29): the group container is one container, so ⌘C in base pastes full-fidelity in Pro. The /// lifecycle below is unchanged by that — both editions read the same machine-wide pasteboard, so both diff --git a/Kanban/App/EditionAbout.swift b/Kanban/App/EditionAbout.swift deleted file mode 100644 index 9fa2cdc..0000000 --- a/Kanban/App/EditionAbout.swift +++ /dev/null @@ -1,31 +0,0 @@ -import IndieAbout - -/// **Base Lanework's About-box configuration — one of the two places base names Lanework Pro.** -/// -/// 12-editions.md ▸ Quiet signposts: "Base presents as a complete app, not a demo: Pro is named -/// in exactly two places — one line in the About box, and the contextual popover line on `.git` -/// boards. Nothing on the welcome screen, nothing in banners." The popover line is -/// `BoardGitNote`'s; this is the About line, riding as the second line of the copyright text — -/// `IndieAbout` renders `copyrightText` as one multi-line `Text`, so the signpost is literally a -/// line in the box, no custom layout. -/// -/// **The Pro target does not compile this file** (project.yml excludes it and compiles -/// `KanbanPro/Edition/EditionAbout.swift` instead): Pro's About has no signpost — the app it -/// would point to is the one showing the box — and falls back to the Info.plist copyright. The -/// difference is a file one target builds and the other does not, per the edition split's -/// no-`#if` doctrine (12-editions.md ▸ Targets). -enum EditionAbout { - - /// The About window's content: version/build/date from the stamped Info.plist - /// (`update_build_info.sh` — CFBundleVersion, BuildDate, BuildHash), the version line - /// opening the bundled CHANGELOG.md, and the bundled ISC LICENSE.md as the one document - /// link. Both documents are authored to the Apple inline-Markdown subset — see the files' - /// own single-line paragraphs. - static var configuration: AppInfoConfiguration { - AppInfoConfiguration( - copyrightText: "© 2026 rzen\nLanework Pro adds git-backed board history and sync.", - documents: [.license(extension: "md")], - changelogDocument: .changelog() - ) - } -} diff --git a/Kanban/App/EditionTypes.swift b/Kanban/App/EditionTypes.swift deleted file mode 100644 index e5eaea8..0000000 --- a/Kanban/App/EditionTypes.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -// MARK: - The family pasteboard types, as base declares them - -/// **Base's half of the edition UTI posture** (12-editions.md ▸ Targets: base *exports* the family -/// types, Pro *imports* them, declarations verbatim identical). `UTType(exportedAs:)` is a claim of -/// ownership the system checks against the running app's `Info.plist` — in Pro, whose plist imports -/// these, that claim is a runtime fault. So the three constants live in an edition twin rather than -/// beside their payloads: Pro compiles `KanbanPro/Edition/EditionTypes.swift`, which declares the -/// same names with `UTType(importedAs:)` — the edition split's file-level doctrine, applied to an -/// initializer. -/// -/// What each type *is* stays documented at its payload — `DragPayload` for cards and lanes, -/// `ClipboardManifest` for the clipboard type. -extension UTType { - - /// A drag carrying board **cards** — see `DragPayload`. - static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards") - - /// A drag carrying board **lanes** — see `DragPayload`. - static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes") - - /// A Lanework copy's pasteboard manifest — see `ClipboardManifest`. - static let laneworkClipboard = UTType(exportedAs: "dev.rzen.indie.kanban.clipboard") -} diff --git a/Kanban/App/PasteboardTypes.swift b/Kanban/App/PasteboardTypes.swift new file mode 100644 index 0000000..5844276 --- /dev/null +++ b/Kanban/App/PasteboardTypes.swift @@ -0,0 +1,24 @@ +import Foundation +import UniformTypeIdentifiers + +// MARK: - The family pasteboard types + +/// **The three drag and clipboard types the app owns**, declared once with `UTType(exportedAs:)` to +/// match the `UTExportedTypeDeclarations` in `Kanban/Info.plist`. `exportedAs` is a claim of +/// ownership the system checks against the running app's plist, and one app making it is the whole +/// story — there is no importing sibling to keep in step (12-editions.md ▸ Distribution, re-ruled +/// 2026-07-30: the types are "declared and exported once, by the one app — no ownership twins"). +/// +/// What each type *is* stays documented at its payload — `DragPayload` for cards and lanes, +/// `ClipboardManifest` for the clipboard type. +extension UTType { + + /// A drag carrying board **cards** — see `DragPayload`. + static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards") + + /// A drag carrying board **lanes** — see `DragPayload`. + static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes") + + /// A Lanework copy's pasteboard manifest — see `ClipboardManifest`. + static let laneworkClipboard = UTType(exportedAs: "dev.rzen.indie.kanban.clipboard") +} diff --git a/Kanban/App/TemplateEngine.swift b/Kanban/App/TemplateEngine.swift index c1e8562..3bd2217 100644 --- a/Kanban/App/TemplateEngine.swift +++ b/Kanban/App/TemplateEngine.swift @@ -110,6 +110,11 @@ enum TemplateEngine { /// and the clipboard's staging store (09-templates.md ▸ Save as Template ▸ Storage, re-homed /// 2026-07-29; 02-architecture.md § Per-board app state, "App-wide state has the same home"). /// + /// **⚠ one-app collapse phase 2**: the cross-edition half of this rationale retires with the App + /// Group (12-editions.md ▸ App-side state, re-ruled 2026-07-30); the store simply moves to the + /// ordinary sandbox container and keeps every property below, since it never had bookmarks or + /// grants to lose. + /// /// > **templates cross editions**: a template saved in base appears in Pro's chooser, honoring 12's /// > never-an-empty-home-screen promise (templates are plain board folders — no per-edition /// > semantics, no bookmark grant ceremony; the group container is directly writable by every diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index a12a74f..66f4f46 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -13,12 +13,12 @@ import AppKit /// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light /// up, disable and retitle with no code of the app's own. /// -/// The seam, though, must not be an `NSUndoManager`: base's stack is one, Pro's is git +/// The seam, though, must not be an `NSUndoManager`: the free tier's stack is one, Pro's is git /// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a /// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the /// translation — one per board session, over whichever provider that session was composed with. Pro /// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding -/// its provider and changing nothing here, which is what "a user moving between editions relearns +/// its provider and changing nothing here, which is what "a user subscribing (or lapsing) relearns /// nothing" (12) has to mean in code. /// /// ### It deliberately keeps its inherited stack empty @@ -110,7 +110,7 @@ public final class BoardUndoManager: UndoManager { // MARK: - BoardUndoRouting /// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md -/// ▸ Undo routing, which 12 records as **edition-independent**: "focus decides text-undo vs +/// ▸ Undo routing, which 12 records as **tier-independent**: "focus decides text-undo vs /// board-undo; only the substrate behind board-undo differs". /// /// ### Most of the rule is AppKit's, and is not here diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index 56990c6..d957d53 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -8,7 +8,7 @@ import Foundation /// /// 13-native-undo.md ▸ Rules hands the naming straight over: "The 06 vocabulary supplies menu titles /// ('Undo Move 3 Cards'), via NSUndoManager's dynamic retitling — the same naming machinery both -/// editions use." So the verbs here are exactly 06-history-undo.md ▸ Commit messages' list — *Add / +/// tiers use." So the verbs here are exactly 06-history-undo.md ▸ Commit messages' list — *Add / /// Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board*, plus /// the trash pair's **Restore** — and the plural rule is that section's own plural folding ("Delete /// 12 cards"), which is also 13's coalescing sentence read out loud: "a multi-card move is one step diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index 769fc21..5e9d4c0 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -80,14 +80,14 @@ public enum HistoryStepOutcome: Equatable, Sendable { /// holds: move → move back ...; rename → restore title" — and both halves of that write are already /// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write /// set (which is also what the staleness predicate compares). A step is therefore that pair, in the -/// design's own terms, and deliberately says nothing about how a stack stores it: base's stack is -/// `NSUndoManager`-backed and Pro's is git (12-editions.md ▸ The provider seam), and neither +/// design's own terms, and deliberately says nothing about how a stack stores it: the free tier's +/// stack is `NSUndoManager`-backed and Pro's is git (12-editions.md ▸ The provider seam), and neither /// substrate appears here. /// /// ### `name` is the 06 vocabulary, unprefixed /// /// "The 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's dynamic -/// retitling — the same naming machinery both editions use" (13). What the step carries is the bare +/// retitling — the same naming machinery both tiers use" (13). What the step carries is the bare /// phrase — `"Move Card"`, `"Move 3 Cards"`, `"Rename Lane"` — in the vocabulary of /// 06-history-undo.md ▸ Commit messages, plural-folded by the same rule ("one gesture, one undo /// step — a multi-card move is one step with a plural title"). The **"Undo "/"Redo " prefix is @@ -143,22 +143,22 @@ public struct HistoryStep { /// "One stack per board, owned by the board session. Not per-window: every window over a board /// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md /// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds -/// which implementation it gets: base binds `NativeHistoryProvider` (two step stacks over the -/// inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as +/// which implementation it gets: the free tier binds `NativeHistoryProvider` (two step stacks over +/// the inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as /// forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits /// Pro's. /// /// ### What this protocol deliberately does not say /// -/// - **No `NSUndoManager`, anywhere in the signature.** It is base's implementation detail, and a -/// seam that vended one would be a seam only one provider could ever satisfy — the opposite of -/// the reason the split exists at all ("base's native undo is the first proof the seam is real", -/// 12). AppKit still needs an `UndoManager` to hand the responder chain; that adapter is -/// `BoardUndoManager`, which sits *over* this protocol rather than inside it. -/// - **No persistence promise.** Base's stack dies with the session (13); Pro's survives relaunch -/// because git does (06). Both are honest implementations of these seven members. +/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation +/// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the +/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof +/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that +/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it. +/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives +/// relaunch because git does (06). Both are honest implementations of these seven members. /// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's -/// (06 ▸ Undo routing, edition-independent) — `BoardUndoRouting`. +/// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`. @MainActor public protocol HistoryProviding: AnyObject { diff --git a/Kanban/History/NativeHistoryProvider.swift b/Kanban/History/NativeHistoryProvider.swift index ad41432..6a2f702 100644 --- a/Kanban/History/NativeHistoryProvider.swift +++ b/Kanban/History/NativeHistoryProvider.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - NativeHistoryProvider -/// The base edition's undo substrate: one stack per board session (13-native-undo.md). +/// The free tier's undo substrate: one stack per board session (13-native-undo.md). /// /// ### Two arrays, and why not `NSUndoManager` /// diff --git a/Kanban/Kanban.entitlements b/Kanban/Kanban.entitlements index e6eea50..6dd15d4 100644 --- a/Kanban/Kanban.entitlements +++ b/Kanban/Kanban.entitlements @@ -8,14 +8,22 @@ com.apple.security.files.bookmarks.app-scope - + + com.apple.security.network.client + + com.apple.security.application-groups group.dev.rzen.indie.Kanban diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index b338fe0..f5dda64 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -182,9 +182,9 @@ struct KanbanApp: App { // The About window (11-command-nexus.md files About under the app menu's standard // furniture): icon, version/build/date from the Info.plist that `update_build_info.sh` // stamped at build time — never a hardcoded string — with the version line opening the - // bundled changelog. `EditionAbout` is the edition seam: base's carries the one-line Pro - // signpost (12-editions.md ▸ Quiet signposts), Pro's twin does not. - IndieAboutCommand(configuration: EditionAbout.configuration) + // bundled changelog. `AboutBox` carries the one-line Pro signpost with it (12-editions.md + // ▸ Tier naming) — one box, every tier. + IndieAboutCommand(configuration: AboutBox.configuration) // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…, // Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Reveal in Finder, Add diff --git a/Kanban/LiveStore/AppGroup.swift b/Kanban/LiveStore/AppGroup.swift index 82f53cc..c85e7da 100644 --- a/Kanban/LiveStore/AppGroup.swift +++ b/Kanban/LiveStore/AppGroup.swift @@ -1,6 +1,14 @@ import Foundation import os +/// **⚠ Retired by the one-app collapse — one-app collapse phase 2.** 12-editions.md ▸ App-side +/// state (re-ruled 2026-07-30) removes the App Group wholesale: with one app there is no sibling +/// to share a container *with*, so the registry and its peers home in the ordinary sandbox +/// container, records carry one grant and one open-now flag, and this type goes away with the +/// entitlement. Everything below still describes the shipping code, and the code still works — +/// it is simply describing a world that is being dismantled in a later phase, so the two-app +/// reasoning is left standing rather than half-rewritten into a fiction. +/// /// **The family App Group** — where every edition's app-side state lives (12-editions.md /// § Distribution, ruled 2026-07-29; 02-architecture.md § Per-board app state). /// @@ -44,10 +52,11 @@ public enum AppGroup { /// case and never a shipped app's. public static let baseEditionID = "dev.rzen.indie.Kanban" - /// Pro's bundle id (12 ▸ Targets, ruled 2026-07-27). Named here as well as in - /// `ProEdition.bundleIdentifier` because *base* has to know it: the popover's awareness line and - /// the grant-slot keying are shared-tree code that must be able to name the other edition - /// without compiling any of it. + /// The bundle id the retired Pro *app* would have carried (12 ▸ Targets, ruled 2026-07-27) — + /// **one-app collapse phase 2**: no app has ever shipped under it, and nothing will now that Pro + /// is a subscription rather than a second binary (12 ▸ Distribution, re-ruled 2026-07-30). It + /// stays only because the awareness line and the grant-slot keying still read it; both go in the + /// same phase, and this constant with them. public static let proEditionID = "dev.rzen.indie.KanbanPro" /// Which edition is running — the key every per-edition slot on a shared record is stored under. @@ -128,12 +137,12 @@ public enum AppGroup { /// /// ### Why the app has to know /// - /// The unit-test host **is the app** (`KanbanTests` and `KanbanProTests` are hosted bundles), so + /// The unit-test host **is the app** (`KanbanTests` is a hosted bundle), so /// `KanbanApp.init()` runs for real on every test launch and builds an `AppModel` over whatever /// the defaults resolve to. Every *object* a test constructs takes its storage by injection — that /// is the seam, and it is untouched — but the host's own launch has no injection point, and after - /// the 2026-07-29 ruling the thing it would reach for is a container shared with the sibling - /// edition and with the developer's running copy. Its launch sweep would collect real staged + /// the 2026-07-29 ruling the thing it would reach for is a container shared with the developer's + /// own running copy. Its launch sweep would collect real staged /// clipboard trees; its `refreshRecents()` would resolve, refresh and rewrite real records. /// /// So the *default* moves for a test host, which is the one place a default can be wrong in a way diff --git a/Kanban/LiveStore/BoardAnnouncer.swift b/Kanban/LiveStore/BoardAnnouncer.swift index 8eb9b10..8b07cc5 100644 --- a/Kanban/LiveStore/BoardAnnouncer.swift +++ b/Kanban/LiveStore/BoardAnnouncer.swift @@ -219,7 +219,7 @@ public enum BoardAnnouncer { /// What that operation wants said when it lands — "Pulled 3 commits", "Switched to branch /// 'redesign'". `nil` for a bracket whose completion is not worth speech, which is every - /// base-edition bracket today (see `BoardStore.performWholesale(announcing:_:)`). + /// free-tier bracket today (see `BoardStore.performWholesale(announcing:_:)`). public var completion: String? /// The snapshot comparison **already narrowed to the foreign-classified changes** diff --git a/Kanban/LiveStore/BoardDiff.swift b/Kanban/LiveStore/BoardDiff.swift index bb07cd8..718b107 100644 --- a/Kanban/LiveStore/BoardDiff.swift +++ b/Kanban/LiveStore/BoardDiff.swift @@ -19,11 +19,11 @@ import Foundation /// ### Why it lives in `LiveStore/` and not beside the announcement /// /// 10 says announcements "reuse the auto-committer's summarizer" and 06-history-undo.md says the -/// committer synthesizes its commit messages from the same comparison. The committer is Pro-edition +/// committer synthesizes its commit messages from the same comparison. The committer is Pro-tier /// work (pro-m1) and does not exist yet, so the summarizer is built here first, in the layer both -/// consumers can reach: base compiles `Kanban/`, Pro compiles `Kanban/` *plus* `KanbanPro/` -/// (12-editions.md ▸ Targets), so a type in the store layer is available to the committer by -/// construction while a type in `KanbanPro/` would not be available to the announcer. It imports +/// consumers can reach: the store layer is below the history providers, so it is available to the +/// committer by construction, where a type living inside the git provider would not be reachable +/// from the announcer (12-editions.md ▸ The provider seam). It imports /// `Foundation` alone and touches no view, no window, and no `NSAccessibility` — the phrasing is /// `AccessibilityPhrases`' job, and pro-m1's message composer will phrase the very same counts /// differently without either of them knowing about the other. diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index 9c265a4..a0778ab 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -65,6 +65,11 @@ public struct WindowFrame: Codable, Sendable, Equatable { /// /// ### One record, shared by every edition — with two per-edition fields /// +/// **⚠ one-app collapse phase 2**: the two per-edition fields collapse to one grant and one flag +/// when the App Group goes (12-editions.md ▸ App-side state, re-ruled 2026-07-30) — there is no +/// second sandbox to hold a slot for. The section below describes the shipping code, which still +/// works; it is retired, not wrong. +/// /// The record lives in the family App Group container (`AppGroup`), so base, Pro and later Teams all /// read and write the same one: an upgrader's recents, frames and settings are simply *there* /// (12-editions.md ▸ Distribution, ruled 2026-07-29). Two fields cannot be common, and both are diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index c347b1e..4f9adf2 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -344,7 +344,7 @@ public final class BoardStore: HealHost { /// Its consumers today are the announcer's digest and the vanishing-focus sentence, both /// through `land`. Pro's auto-committer (06-history-undo.md) becomes the second one without /// this line changing — which is why the type lives in `LiveStore/` beside `BoardDiff` rather - /// than in `KanbanPro/`. + /// than inside the git provider. @ObservationIgnored public let echoes = EchoLedger() @@ -477,7 +477,7 @@ public final class BoardStore: HealHost { /// raised lock above the completion), and the phrase must not survive to be spoken by some /// later, unrelated reload. /// - /// **`nil` on every base-edition bracket today.** Base has no git operations, and the design's + /// **`nil` on every free-tier bracket today.** The free tier has no git operations, and the design's /// examples ("Pulled 3 commits", "Switched to branch 'redesign'") are pro-m1's; the parameter /// exists so that milestone supplies phrasing rather than re-plumbing the seam. @ObservationIgnored @@ -1111,8 +1111,8 @@ public final class BoardStore: HealHost { /// - Parameter completion: what to announce when the closing reload lands /// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at /// completion" — "Pulled 3 commits", "Switched to branch 'redesign'"). `nil`, the default, is - /// an operation whose completion is not worth speech, which is **every base-edition bracket - /// today**: base has no git operations, and the two app-initiated writes that do reach disk on + /// an operation whose completion is not worth speech, which is **every free-tier bracket + /// today**: no git operations run there, and the two app-initiated writes that do reach disk on /// their own — the loose-file relocation and the legacy-tombstone migration — are ordinary /// `performWrite` calls that already say what they did on the banner strip. The parameter is /// the seam pro-m1 fills; see `wholesaleCompletion`. @@ -3881,7 +3881,7 @@ public final class BoardStore: HealHost { on: self ) { () throws(BoardWriteError) -> Void in // One bracket over the displacement *and* the write: two files change, one app-mediated - // reload lands, and (in the Pro edition) one honestly-attributed commit records it. + // reload lands, and (under Pro) one honestly-attributed commit records it. guard let moved = try AgentGuide.install(atBoardRoot: root) else { return } // The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's // displacement is announced. diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index 8146c8d..61e24db 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -215,7 +215,7 @@ enum AgentGuide { /// when the decision called for it. /// /// **Called inside `BoardStore.performWrite`**, so both halves ride one watcher bracket: the - /// rescue and the guide land as a single app-mediated reload, and (in the Pro edition) as a + /// rescue and the guide land as a single app-mediated reload, and (under Pro) as a /// single honestly-attributed commit rather than a foreign-looking rename followed by an /// app write (06-history-undo.md ▸ Commit messages, "Update agent guide (vN)"). /// diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 695214d..2a577f6 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -603,15 +603,14 @@ public enum BoardLoader: Sendable { /// § Fractal layout ▸ Rules: "on git boards, the path history already tracks outranks the /// newcomer (both tracked: the path that entered history first)"). /// - /// A seam rather than an implementation because the first rung of that ladder is unbuildable in - /// base: base Lanework links no git machinery at all (12-editions.md; `scripts/verify-editions.sh` - /// scans the base binary to prove it), so the loader consults an injected ranker and falls through - /// to birth date and traversal order when there is none — which is every base board, and every Pro - /// board without a repo. + /// A seam rather than an implementation because the first rung of that ladder is unreachable + /// without git: the free tier runs no git machinery at all (12-editions.md ▸ The inert posture), + /// so the loader consults an injected ranker and falls through to birth date and traversal order + /// when there is none — which is every free-tier board, and every Pro board without a repo. /// /// Deliberately one closure and no protocol: the loader asks one question — "how early did this /// path enter history" — and pro-m1's implementation answers it from `git log --diff-filter=A - /// --follow`-shaped plumbing behind the edition seam. `nil` means "untracked, or no history + /// --follow`-shaped plumbing behind the provider seam. `nil` means "untracked, or no history /// here", which the rule reads as *outranked by anything tracked*. /// /// - Parameter rank: keyed by the occurrence's **board-root-relative path**, which is what a diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index a26fb0a..b40ec8e 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -305,17 +305,20 @@ private struct BoardRenameField: View { // MARK: - Git -/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md § Base and `.git`, -/// settled 2026-07-27). Base has no git integration and never will (that is Pro's), so this is not a -/// grow-in-place slot the way the old `BoardGitSlot` placeholder was: there is nothing here to grow. -/// Base's whole git story is one line, shown only when it is true. +/// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and +/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git +/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old +/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is +/// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second +/// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the +/// Settings Pro section). /// /// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is /// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only -/// a board that carries an inert `.git` (12-editions.md § The inert posture: "any `.git` is inert" — -/// base never reads or writes it, whether the board's own or a Pro user's) earns this note, worded -/// exactly as 12 rules: an honest explanation of what the folder is, named exactly where the -/// question arises, never a standing ad for Pro. +/// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free +/// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind) +/// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named +/// exactly where the question arises, never a standing ad for Pro. /// /// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly /// (a fixture board with `.git` → true, without → false), which needs it visible past this file even @@ -329,7 +332,7 @@ struct BoardGitNote: View { .fixedSize(horizontal: false, vertical: true) } - /// Whether `boardRoot` carries a `.git` entry — base's entire detection story, and a deliberately + /// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately /// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact /// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder /// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload @@ -344,6 +347,13 @@ struct BoardGitNote: View { // MARK: - The other edition +/// **⚠ Retired by the one-app collapse — one-app collapse phase 2.** There is no other edition to be +/// aware of: 12-editions.md ▸ App-side state (re-ruled 2026-07-30) removes the App Group and with it +/// the cross-edition flags this line reads, and 12 ▸ Distribution retires the second app outright. +/// This type, `BoardInfoView.otherEditionNote` and `BoardRecord.openNow`'s keying go together in that +/// phase; until then the line is simply never non-`nil` in practice, since no sibling exists to set a +/// flag. Kept functioning, and documented as it was built, rather than half-unwound here. +/// /// **The cross-edition awareness line** — "Also open in Lanework Pro" (12-editions.md ▸ Both editions /// installed, ruled 2026-07-29). /// diff --git a/Kanban/UI/Board/BoardToolbar.swift b/Kanban/UI/Board/BoardToolbar.swift index 78b83ee..b280089 100644 --- a/Kanban/UI/Board/BoardToolbar.swift +++ b/Kanban/UI/Board/BoardToolbar.swift @@ -38,9 +38,9 @@ extension NSToolbarItem.Identifier { /// predicate written here — it is literally the same validation. Both reach the board window, whose /// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore /// enable exactly when that board has a step to cross and no read-only lock stands -/// (13-native-undo.md ▸ Rules). **Every base board has undo**, so there is no edition-shaped +/// (13-native-undo.md ▸ Rules). **Every board has undo in every tier**, so there is no tier-shaped /// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which -/// only Pro binds. +/// only a Pro subscription binds. /// /// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the /// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so diff --git a/Kanban/UI/Board/DragPayload.swift b/Kanban/UI/Board/DragPayload.swift index 6c400b7..34c7f54 100644 --- a/Kanban/UI/Board/DragPayload.swift +++ b/Kanban/UI/Board/DragPayload.swift @@ -9,8 +9,7 @@ import UniformTypeIdentifiers // and the cross-board lane transfer. Declared in `Info.plist` beside the clipboard type, because a // system drag session is what crosses window boundaries, shows the copy badge, and gives the // full-size replica preview (DRAG-REORDER.md § Cross-board sessions). The constants themselves live -// in `EditionTypes.swift` — base exports the types, Pro imports them, and each edition's twin uses -// the initializer its own plist can honor. +// with the clipboard type in `PasteboardTypes.swift`, exported once by the app that owns them. // MARK: - What a drag carries diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index 8ab7fe9..4bd94ec 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -46,7 +46,7 @@ import Observation /// closing — and it is deliberately a named call rather than a side effect of `flush()`, because /// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its /// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save -/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). On the base edition there is no committer, so +/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). In the free tier there is no committer, so /// the two calls do the same work today; the seam is what keeps them from having to be pulled apart /// later. @MainActor diff --git a/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json b/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 7a9d8ed..0000000 --- a/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"colors":[{"idiom":"universal"}],"info":{"version":1,"author":"xcode"}} diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json deleted file mode 100644 index 57d0737..0000000 --- a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images": [ - { - "filename": "icon-mac-16.png", - "idiom": "mac", - "size": "16x16", - "scale": "1x" - }, - { - "filename": "icon-mac-16@2x.png", - "idiom": "mac", - "size": "16x16", - "scale": "2x" - }, - { - "filename": "icon-mac-32.png", - "idiom": "mac", - "size": "32x32", - "scale": "1x" - }, - { - "filename": "icon-mac-32@2x.png", - "idiom": "mac", - "size": "32x32", - "scale": "2x" - }, - { - "filename": "icon-mac-128.png", - "idiom": "mac", - "size": "128x128", - "scale": "1x" - }, - { - "filename": "icon-mac-128@2x.png", - "idiom": "mac", - "size": "128x128", - "scale": "2x" - }, - { - "filename": "icon-mac-256.png", - "idiom": "mac", - "size": "256x256", - "scale": "1x" - }, - { - "filename": "icon-mac-256@2x.png", - "idiom": "mac", - "size": "256x256", - "scale": "2x" - }, - { - "filename": "icon-mac-512.png", - "idiom": "mac", - "size": "512x512", - "scale": "1x" - }, - { - "filename": "icon-mac-512@2x.png", - "idiom": "mac", - "size": "512x512", - "scale": "2x" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} \ No newline at end of file diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png deleted file mode 100644 index d96463e..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png deleted file mode 100644 index d3d9542..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png deleted file mode 100644 index 879c91f..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png deleted file mode 100644 index 94c1b8d..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png deleted file mode 100644 index d3d9542..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png deleted file mode 100644 index 3fdf2ef..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png deleted file mode 100644 index 94c1b8d..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png deleted file mode 100644 index 63a9e07..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png deleted file mode 100644 index 3fdf2ef..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png deleted file mode 100644 index df83a92..0000000 Binary files a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png and /dev/null differ diff --git a/KanbanPro/Assets.xcassets/Contents.json b/KanbanPro/Assets.xcassets/Contents.json deleted file mode 100644 index 2673658..0000000 --- a/KanbanPro/Assets.xcassets/Contents.json +++ /dev/null @@ -1 +0,0 @@ -{"info":{"version":1,"author":"xcode"}} diff --git a/KanbanPro/Edition/EditionAbout.swift b/KanbanPro/Edition/EditionAbout.swift deleted file mode 100644 index 60574ca..0000000 --- a/KanbanPro/Edition/EditionAbout.swift +++ /dev/null @@ -1,18 +0,0 @@ -import IndieAbout - -/// **Pro's About-box configuration** — the twin of base's `Kanban/App/EditionAbout.swift`, which -/// the Pro target excludes (project.yml): same documents, no signpost line. Base's About carries -/// "Lanework Pro adds…" because base is where a user discovers Pro exists (12-editions.md ▸ -/// Quiet signposts); Pro pointing at itself would be noise, so its copyright falls back to the -/// Info.plist string and the box is one line shorter. -enum EditionAbout { - - /// See base's twin for the full story; the two must keep the same shape so `KanbanApp` can - /// read `EditionAbout.configuration` without knowing which edition it is in. - static var configuration: AppInfoConfiguration { - AppInfoConfiguration( - documents: [.license(extension: "md")], - changelogDocument: .changelog() - ) - } -} diff --git a/KanbanPro/Edition/EditionTypes.swift b/KanbanPro/Edition/EditionTypes.swift deleted file mode 100644 index 71ef309..0000000 --- a/KanbanPro/Edition/EditionTypes.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import UniformTypeIdentifiers - -// MARK: - The family pasteboard types, as Pro declares them - -/// **Pro's half of the edition UTI posture** — the twin of base's `Kanban/App/EditionTypes.swift`, -/// which owns the full story (12-editions.md ▸ Targets: base *exports* the family types, Pro -/// *imports* them). `UTType(importedAs:)` is the initializer that matches Pro's -/// `UTImportedTypeDeclarations`; the identifiers and their `public.data` conformance are verbatim -/// base's, so a payload written by one edition reads in the other by type identity alone. -extension UTType { - - /// A drag carrying board **cards** — see `DragPayload`. - static let laneworkCards = UTType(importedAs: "dev.rzen.indie.kanban.cards") - - /// A drag carrying board **lanes** — see `DragPayload`. - static let laneworkLanes = UTType(importedAs: "dev.rzen.indie.kanban.lanes") - - /// A Lanework copy's pasteboard manifest — see `ClipboardManifest`. - static let laneworkClipboard = UTType(importedAs: "dev.rzen.indie.kanban.clipboard") -} diff --git a/KanbanPro/Edition/ProEdition.swift b/KanbanPro/Edition/ProEdition.swift deleted file mode 100644 index 9dbb0c2..0000000 --- a/KanbanPro/Edition/ProEdition.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -/// **Pro's source root.** This directory — and this file's presence in it — *is* the edition -/// split (12-editions.md ▸ Targets): the `Kanban` target compiles `Kanban/` alone, the -/// `KanbanPro` target compiles `Kanban/` **plus** `KanbanPro/`. Nothing in the shared tree is -/// `#if`-ed on an edition, because "the editions differ at the binary level" (12) is only true if -/// the difference is a file one target builds and the other does not. -/// -/// **What lands here, and when.** Nothing yet — this is a reserved root, split out ahead of the -/// code so pro-m1 is a set of new files rather than a second pass over the project structure: -/// -/// - `KanbanPro/Git/` — the git `HistoryProviding` implementation (06-history-undo.md: undo as -/// forward restore commits over HEAD's first-parent ancestry), init/adoption, branches, commit -/// identity, repository hygiene. Brings the libgit2 dependency with it; the base target must -/// never gain one, which `scripts/verify-editions.sh` checks against the signed binary. -/// - `KanbanPro/Remote/` — pull/push, push-on-commit (07-sync-collab.md). The -/// `com.apple.security.network.client` entitlement Pro already carries is for this, and base's -/// lack of it is the other half of what the verification script checks. -/// - `KanbanPro/Auth/` — remote credentials over the declared keychain access group -/// (07 ▸ Auth: Keychain, SSH, TOFU). -/// -/// **What does not land here**: anything the edition matrix marks ✓ for all three editions. The -/// board experience, agent attribution and accessibility are shared code and stay in `Kanban/`. -/// -/// The type itself is a namespace, not state — the composition root that picks a -/// `HistoryProviding` per edition is `AppModel.makeHistoryProvider`, and it lives in the shared -/// tree because base's `NativeHistoryProvider` is the shared default. Pro's override is one closure -/// installed from here once `KanbanPro/Git/` exists (pro-m1); until then Pro runs the native stack, -/// which is the seam working rather than a gap. -enum ProEdition { - /// The edition's own bundle identifier (12 ▸ Targets, ruled 2026-07-27: base keeps - /// `dev.rzen.indie.Kanban`, Pro mints its own). Stated as a constant so the value has one - /// home in Pro-only code; the running app reads its identity from `Bundle.main`, which is - /// already how the App Support directory is per-edition without a line of edition code - /// (`BoardRegistry`, `ClipboardStore`). - static let bundleIdentifier = "dev.rzen.indie.KanbanPro" -} diff --git a/KanbanPro/Info.plist b/KanbanPro/Info.plist deleted file mode 100644 index 3867a92..0000000 --- a/KanbanPro/Info.plist +++ /dev/null @@ -1,107 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Lanework Pro - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Lanework Pro - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - 1 - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - © 2026 rzen - NSPrincipalClass - NSApplication - - UTImportedTypeDeclarations - - - UTTypeIdentifier - dev.rzen.indie.kanban-board - UTTypeConformsTo - - com.apple.package - public.directory - - UTTypeDescription - Lanework Board - UTTypeTagSpecification - - public.filename-extension - kanban - - - - - UTTypeIdentifier - dev.rzen.indie.kanban.cards - UTTypeConformsTo - - public.data - - UTTypeDescription - Lanework Cards - - - UTTypeIdentifier - dev.rzen.indie.kanban.lanes - UTTypeConformsTo - - public.data - - UTTypeDescription - Lanework Lanes - - - - UTTypeIdentifier - dev.rzen.indie.kanban.clipboard - UTTypeConformsTo - - public.data - - UTTypeDescription - Lanework Clipboard - - - CFBundleDocumentTypes - - - CFBundleTypeName - Lanework Board - CFBundleTypeRole - Editor - LSItemContentTypes - - dev.rzen.indie.kanban-board - - LSTypeIsPackage - - - - - diff --git a/KanbanPro/KanbanPro.entitlements b/KanbanPro/KanbanPro.entitlements deleted file mode 100644 index 663f0c8..0000000 --- a/KanbanPro/KanbanPro.entitlements +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.bookmarks.app-scope - - - com.apple.security.application-groups - - group.dev.rzen.indie.Kanban - - - com.apple.security.network.client - - - keychain-access-groups - - $(AppIdentifierPrefix)dev.rzen.indie.KanbanPro - - - diff --git a/KanbanTests/AppGroupStateTests.swift b/KanbanTests/AppGroupStateTests.swift index 4ef6422..f7cc273 100644 --- a/KanbanTests/AppGroupStateTests.swift +++ b/KanbanTests/AppGroupStateTests.swift @@ -2,6 +2,12 @@ import Foundation import Testing @testable import Kanban +/// **⚠ Retired by the one-app collapse — one-app collapse phase 2.** 12-editions.md ▸ App-side +/// state (re-ruled 2026-07-30) removes the App Group wholesale: one app, one sandbox, one grant, one +/// open-now flag. This whole file is the two-app arrangement's proof, and it retires with the code +/// it pins — it is kept green in the meantime rather than deleted ahead of the machinery, because +/// the machinery is what still ships. +/// /// **App-side state in the shared App Group container** (12-editions.md ▸ Distribution and ▸ Both /// editions installed, ruled 2026-07-29; 02-architecture.md § Per-board app state). /// diff --git a/KanbanTests/BoardAnnouncerTests.swift b/KanbanTests/BoardAnnouncerTests.swift index 5235294..9f58a72 100644 --- a/KanbanTests/BoardAnnouncerTests.swift +++ b/KanbanTests/BoardAnnouncerTests.swift @@ -371,7 +371,7 @@ struct BoardAnnouncerSpeechTests { #expect(BoardAnnouncer.speech(for: facts) == "Pulled 3 commits") } - /// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases. + /// Every free-tier bracket today. The seam exists; pro-m1 supplies the phrases. @Test("A bracket with no phrase to say stays silent rather than falling back to the digest") func bracketWithoutAPhrase() { var facts = BoardAnnouncer.ReloadFacts() @@ -733,7 +733,7 @@ struct BoardAnnouncerStoreTests { #expect(log.lines == ["Card 'Fix login' was deleted externally"]) } - /// The seam pro-m1 fills. No base-edition operation passes a phrase today, so this is the one + /// The seam pro-m1 fills. No free-tier operation passes a phrase today, so this is the one /// place the completion path is exercised end to end — including that the phrase is *consumed* /// rather than left armed for whatever reload comes next. @Test("A bracketed operation announces at completion, once, and never again") @@ -755,7 +755,7 @@ struct BoardAnnouncerStoreTests { #expect(log.lines == ["Board changed: 1 card edited"], "the phrase was consumed, not carried forward") } - @Test("A bracket with no phrase to say stays quiet — every base-edition bracket today") + @Test("A bracket with no phrase to say stays quiet — every free-tier bracket today") func silentBracket() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 3a0a0f1..5b6bbbb 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -2,8 +2,9 @@ import Foundation import Testing @testable import Kanban -/// **The board popover's git-note detection** (12-editions.md § Base and `.git`, ruled 2026-07-27): -/// base's whole git story is one line, shown only on a board that carries an inert `.git`. The seam +/// **The board popover's git-note detection** (12-editions.md ▸ The free tier and `.git`, ruled +/// 2026-07-27): the free tier's whole git story is one line, shown only on a board that carries an +/// inert `.git`. The seam /// that decides *whether* to show it — `BoardGitNote.hasGitDirectory(at:)` — is a pure, read-only /// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its /// wording, its placement in `BoardInfoView`) is SwiftUI rendering and deliberately untested. diff --git a/KanbanTests/InertGitTests.swift b/KanbanTests/InertGitTests.swift index ecb3526..99b5a5f 100644 --- a/KanbanTests/InertGitTests.swift +++ b/KanbanTests/InertGitTests.swift @@ -2,9 +2,15 @@ import Foundation import Testing @testable import Kanban -/// **Base's inert-`.git` posture, stated against real bytes on disk** (12-editions.md ▸ Base and -/// `.git`): "any `.git` is inert — opening a board that has one works normally, but the app never -/// reads history, never commits, never touches `.git` in any way." +/// **The inert-`.git` posture, stated against real bytes on disk** (12-editions.md ▸ The free tier +/// and `.git`): "any `.git` is inert — opening a board that has one works normally, but the app +/// never reads history, never commits, never touches `.git` in any way." +/// +/// It is unconditional app behaviour, not a build-time posture: the app that ships is the only app +/// there is (12 ▸ The target, re-ruled 2026-07-30), and it runs this way for everyone without a +/// subscription and for everyone whose subscription has lapsed — "unsubscribed and lapsed are one +/// state". So these tests assert what `Kanban` does, full stop; nothing here is conditioned on a +/// tier, and the git provider that will one day touch `.git` composes over its own boards. /// /// Two halves of that posture are already pinned elsewhere and are referenced, not repeated: /// `FolderWatcherTests` ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any @@ -13,8 +19,8 @@ import Testing /// what the app does with events and entries it is handed. /// /// This file states the **output** claim, which no existing test covers, and which is the one the -/// edition split has to be able to demonstrate: a full session of ordinary base editing leaves -/// every byte and every mtime under `.git` exactly as it found them. It is asserted the only way +/// posture has to be able to demonstrate: a full session of ordinary editing leaves every byte and +/// every mtime under `.git` exactly as it found them. It is asserted the only way /// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before /// the edits and re-reading it afterwards, never through the app's own read path /// (`WriterTestSupport.swift`'s standing rule). @@ -140,7 +146,7 @@ private struct InertGitBoard { // MARK: - The posture -struct BaseInertGitTests { +struct InertGitTests { @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched") func anEditingSessionNeverTouchesGit() throws { let board = try InertGitBoard() @@ -149,7 +155,7 @@ struct BaseInertGitTests { let before = try snapshotSubtree(board.root, ".git") #expect(before.count == 12, "the fixture's own shape — files, directories and the root") - // A session's worth of every write the base app can make, in one go. `.git` is at the + // A session's worth of every write the app can make, in one go. `.git` is at the // board root, so anything that walks or renumbers the root's children walks past it. try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed board")) diff --git a/KanbanUITests/EndToEndVerification.md b/KanbanUITests/EndToEndVerification.md index 318048d..3780132 100644 --- a/KanbanUITests/EndToEndVerification.md +++ b/KanbanUITests/EndToEndVerification.md @@ -85,7 +85,7 @@ xcodebuild build-for-testing -project Kanban.xcodeproj -scheme Kanban \ -destination 'platform=macOS,arch=arm64' ``` -The UI bundle is base-only by design (`project.yml`): these tests launch the real app and drive its menu bar, and a second copy would double the slowest part of the suite to re-assert something edition-blind. The Pro app's own launch is already exercised on every `LaneworkPro` unit run, which uses it as the test host. +There is one UI bundle because there is one app (`project.yml`; DESIGN/12-editions.md ▸ The target, re-ruled 2026-07-30) — these tests launch the real app and drive its menu bar, which is the slowest, most environment-dependent part of the suite, and the 2026-07-27 split's second copy would have re-asserted the same tier-blind surfaces at double the cost. ## The flow inventory diff --git a/README.md b/README.md index e27bf80..b4d0ff7 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ Lanework is in early development. This list tracks what has actually shipped and - **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode. -- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Both editions ship it: base runs the native stack, and Lanework Pro binds git behind the same seam without changing a keystroke. +- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Every tier ships it: the free tier runs the native stack, and a Lanework Pro subscription binds git behind the same seam without changing a keystroke. - **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption. - **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled. -- **App identity — icon, versioning, About** — each edition carries its own icon (the three-lane glyph: teal for Lanework, graphite for Lanework Pro) 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. Base's About carries the one quiet line naming Lanework Pro; Pro's carries none, 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). ## Development @@ -59,11 +59,8 @@ The Xcode project is generated — `project.yml` is the source of truth, not the ```sh xcodegen generate -xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS' -xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64' -only-testing:KanbanTests -xcodebuild build -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS' -xcodebuild test -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS,arch=arm64' -scripts/verify-editions.sh +xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS' +xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64' -only-testing:KanbanTests ``` `-only-testing:KanbanTests` is the everyday run: the unit suite needs nothing but a compiler. Dropping it also runs `KanbanUITests`, which launches the real app and drives its menu bar — that needs a real, unlocked display and Accessibility automation permission, and on a machine without them every test in it fails on its first click for a reason that is not the app's. On such a machine, check the UI suites compile instead: @@ -74,7 +71,7 @@ xcodebuild build-for-testing -project Kanban.xcodeproj -scheme Kanban -destinati macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**. -Two app targets are built from one source tree (DESIGN/12-editions.md): **Lanework** compiles `Kanban/` alone, **Lanework Pro** (target `KanbanPro`, scheme `LaneworkPro`, bundle id `dev.rzen.indie.KanbanPro`) compiles `Kanban/` plus the Pro-only source root `KanbanPro/`. There is no edition flag and no `#if` in shared code — an edition difference is a file one target builds and the other does not — and the difference is checkable on the signed products: base carries no libgit2 and no network-client entitlement, which `scripts/verify-editions.sh` asserts against the built bundles. The unit suite is edition-agnostic and runs twice, once hosted by each app (`KanbanTests`, `KanbanProTests` — the same sources, bound to the Pro module by `-module-alias`). Both editions declare the same `dev.rzen.indie.kanban-board` UTI, so any board opens in either app. +**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. **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`. diff --git a/RELEASE.md b/RELEASE.md index db79d12..e267fb2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,11 +4,11 @@ **Lanework ships on the Mac App Store**, as version 2.0 of the existing `dev.rzen.indie.Kanban` App Store record — the 1.x app already listed there under the name Lanework. This was not an open question by the time this document was written; three prior rulings settle it: -- The 2026-07-27 edition split (DESIGN/12-editions.md) defines the editions as **separate App Store targets** and gave base Lanework the existing `dev.rzen.indie.Kanban` bundle id precisely so it ships as the update to the existing record; Lanework Pro (`dev.rzen.indie.KanbanPro`) becomes its own record when it ships after pro-m1/pro-m2. +- The one-app collapse (DESIGN/12-editions.md, re-ruled 2026-07-30, superseding the 2026-07-27 two-app split) puts the whole family on **one record**: `dev.rzen.indie.Kanban`, the id 1.x already ships under, so 2.0 is simply its update. Lanework Pro arrives inside it as a subscription after pro-m1/pro-m2, never as a second record. - The marketing version has been 2.0 since m3 — the successor numbering of the shipped 1.x. - The portfolio's whole release tooling (appstore-publish skill, `asc-*.swift` scripts, the account's API key) is App-Store-shaped, and the 1.x pipeline for this very record already exists and works. -Notarized direct distribution is deliberately **not** pursued for 2.0: a second channel means a second update path and a second set of listing assets for no current audience, and nothing in the app requires escaping the App Store's sandbox rules (the app is already sandboxed with user-selected file access; base has no network entitlement at all). Revisit only if App Review rejects something architectural. +Notarized direct distribution is deliberately **not** pursued for 2.0: a second channel means a second update path and a second set of listing assets for no current audience, and nothing in the app requires escaping the App Store's sandbox rules (the app is already sandboxed with user-selected file access, and its network-client entitlement stays dormant until Pro's remotes use it). Revisit only if App Review rejects something architectural. ## The pipeline @@ -29,20 +29,19 @@ set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publi Run top to bottom; nothing below "PUSH POINT" may run without a deliberate decision — every step after it writes to the live App Store record of a shipping 1.x app. 1. [ ] All milestone work committed and pushed (build number = commit count). -2. [ ] Both unit suites green: `xcodebuild test … -scheme Kanban` and `… -scheme LaneworkPro` (`-only-testing:KanbanTests` / `KanbanProTests`). -3. [ ] `scripts/verify-editions.sh` — 26/26. -4. [ ] UI test pass on a real display (KanbanUITests: accessibility audits + end-to-end golden flows), plus the manual checklists (`KanbanUITests/AccessibilityVerification.md`, toolbar/undo/trash/drag-preview lists). -5. [ ] CHANGELOG.md current (it is the in-app release notes) — What's New (`scripts/metadata/version/en-US/whats_new.txt`) tells the same story. -6. [ ] Screenshots: capture the 2.0 board on a real display (APP_DESKTOP sizes come straight from screenshots of the running app; numeric filename prefixes set the store order). -7. [ ] Archive dry run: `scripts/release.sh` at least through the archive step completes locally. -8. **— PUSH POINT (everything below touches the live record) —** -9. [ ] `scripts/release.sh` — archive + upload the build. -10. [ ] Metadata dry run, then push (`asc-metadata.swift --platform macos`, commands above). -11. [ ] Verify pricing (currently free, carried from 1.x) and availability (ALL territories) still match intent — Pro will be the paid tier later. -12. [ ] Stage: `asc-submit.swift --bundle dev.rzen.indie.Kanban --version 2.0 --platform macos` (waits for build processing, attaches, stages the review submission — does not submit). -13. [ ] 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). -14. [ ] Submit: the same command with `--submit`. +2. [ ] Unit suite green: `xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64' -only-testing:KanbanTests`. +3. [ ] UI test pass on a real display (KanbanUITests: accessibility audits + end-to-end golden flows), plus the manual checklists (`KanbanUITests/AccessibilityVerification.md`, toolbar/undo/trash/drag-preview lists). +4. [ ] CHANGELOG.md current (it is the in-app release notes) — What's New (`scripts/metadata/version/en-US/whats_new.txt`) tells the same story. +5. [ ] Screenshots: capture the 2.0 board on a real display (APP_DESKTOP sizes come straight from screenshots of the running app; numeric filename prefixes set the store order). +6. [ ] Archive dry run: `scripts/release.sh` at least through the archive step completes locally. +7. **— PUSH POINT (everything below touches the live record) —** +8. [ ] `scripts/release.sh` — archive + upload the build. +9. [ ] Metadata dry run, then push (`asc-metadata.swift --platform macos`, commands above). +10. [ ] Verify pricing (currently free, carried from 1.x) and availability (ALL territories) still match intent — Pro will be the paid tier later. +11. [ ] Stage: `asc-submit.swift --bundle dev.rzen.indie.Kanban --version 2.0 --platform macos` (waits for build processing, attaches, stages the review submission — does not submit). +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`. ## Lanework Pro (later) -Pro ships after pro-m1 (git history) and pro-m2 (remote sync) as a **new** App Store record: bundle id `dev.rzen.indie.KanbanPro`, its own metadata folder and pricing (paid), created by hand in App Store Connect first (the API cannot create app records). The `scripts/release.sh` config block parameterizes to the `LaneworkPro` scheme at that point; everything else in the pipeline is shared. +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. diff --git a/project.yml b/project.yml index d4a32b2..87f4ab3 100644 --- a/project.yml +++ b/project.yml @@ -17,8 +17,8 @@ packages: swift-markdown: url: https://github.com/apple/swift-markdown.git minorVersion: 0.8.0 - # The About window (both editions): app icon, version/build from the stamped Info.plist, and - # the bundled LICENSE/CHANGELOG documents. Pre-1.0 like swift-markdown, same pinning logic. + # The About window: app icon, version/build from the stamped Info.plist, and the bundled + # LICENSE/CHANGELOG documents. Pre-1.0 like swift-markdown, same pinning logic. IndieAbout: url: https://git.rzen.dev/rzen/indie-about.git minorVersion: 0.2.2 @@ -30,18 +30,20 @@ settings: DEVELOPMENT_TEAM: ${APPLE_TEAM_ID} ENABLE_USER_SCRIPT_SANDBOXING: NO -# The edition split (12-editions.md ▸ Targets). Two app targets compiled from one source tree: -# base builds `Kanban/` alone, Pro builds `Kanban/` *plus* `KanbanPro/`. There is no build flag -# and no `#if` in shared code — "why a real split and not feature flags" (12) is answered by the -# file list, so an edition difference is always a file one target compiles and the other does not. -# libgit2 arrives on the Pro target with pro-m1; the target, its entitlements and its source root -# exist now so that milestone lands without re-splitting anything. +# **One app target** (12-editions.md ▸ The target, re-ruled 2026-07-30). Lanework ships as a single +# Mac App Store app, `dev.rzen.indie.Kanban`; **Lanework Pro is a subscription inside it**, not a +# second binary. The 2026-07-27 two-target split — a `KanbanPro` application target, its scheme, its +# bundle id, the `-module-alias Kanban=KanbanPro` test bundle and the twin source files that existed +# only because two bundles claimed different UTI ownership — retired wholesale with the collapse. # -# YAML anchors (`&name` / `*name`) carry the genuinely identical parts across the two apps and the -# two unit-test bundles. They are resolved by the YAML parser before XcodeGen sees the file, so -# they cost nothing at generate time and cannot drift the way copy-paste does. +# What that buys this file: no anchors, no per-target exclusion lists, no second copy of the +# resources. There is one product, one Info.plist, one entitlements file, one Swift module, and one +# `.kanban` UTI **exported** (never imported) by the app that owns it. +# +# libgit2 arrives here with pro-m1 — a dependency of the one target, dormant behind the subscription +# gate rather than the contents of a second download. targets: - # MARK: - Lanework (base) + # MARK: - Lanework Kanban: type: application @@ -64,12 +66,12 @@ targets: - path: CHANGELOG.md type: file buildPhase: resources - dependencies: &appDependencies + dependencies: - package: Yams - package: swift-markdown product: Markdown - package: IndieAbout - postBuildScripts: &updateBuildInfo + postBuildScripts: - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' name: Update Build Info shell: /bin/sh @@ -79,87 +81,33 @@ targets: - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist settings: base: - # Base keeps the current identifier — it is the app that ships first, so nothing - # re-wires (12 ▸ Targets, ruled 2026-07-27). + # The identifier 1.x shipped under, unchanged: 2.0 updates that App Store record rather + # than opening a second one (12-editions.md ▸ Distribution, ruled 2026-07-30). PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban MARKETING_VERSION: "2.0" INFOPLIST_FILE: Kanban/Info.plist + # `Kanban/Kanban.entitlements` — sandbox, user-selected files, app-scope bookmarks, the + # App Group (removed with the app-side-state collapse, a later phase), and + # `com.apple.security.network.client`, which is **declared now and dormant until Pro + # ships**: the one binary carries the key, and nothing exercises it until the git + # provider's remotes do under an active subscription (12 ▸ The target). No keychain + # access group — groups exist to share items *between* apps, and there is one app. CODE_SIGN_ENTITLEMENTS: Kanban/Kanban.entitlements GENERATE_INFOPLIST_FILE: false SWIFT_STRICT_CONCURRENCY: complete ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - # MARK: - Lanework Pro - - KanbanPro: - type: application - platform: macOS - sources: - # The shared tree, minus the three files that are base's *identity* rather than its code. - # (XcodeGen already keeps `Info.plist` and `.entitlements` out of build phases; excluding - # them here keeps them out of the Pro target's file list too, so there is exactly one - # plist and one entitlements file visible per app.) - - path: Kanban - excludes: - - Info.plist - - Kanban.entitlements - - Assets.xcassets - - Assets.xcassets/** - # Base's About-box configuration carries the one-line Pro signpost (12-editions.md ▸ - # Quiet signposts); Pro compiles its own `KanbanPro/Edition/EditionAbout.swift` twin - # without it — the edition split's file-level doctrine, applied to a string. - - App/EditionAbout.swift - # Base declares the family pasteboard types `UTType(exportedAs:)` to match its - # UTExportedTypeDeclarations; Pro's plist *imports* them (12-editions.md ▸ Targets), so it - # compiles its own `KanbanPro/Edition/EditionTypes.swift` twin using `importedAs` — the - # same doctrine, applied to an initializer. - - App/EditionTypes.swift - # Pro's own source root — reserved now, filled by pro-m1. See KanbanPro/Edition/ProEdition.swift. - - path: KanbanPro - excludes: - - Info.plist - - KanbanPro.entitlements - # The same bundled template store — the inventory is the app's, not an edition's. - - path: Templates - type: folder - buildPhase: resources - # The About documents, same files as base — one license, one changelog, two bundles. - - path: LICENSE.md - type: file - buildPhase: resources - - path: CHANGELOG.md - type: file - buildPhase: resources - dependencies: *appDependencies - postBuildScripts: *updateBuildInfo - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanPro - MARKETING_VERSION: "2.0" - INFOPLIST_FILE: KanbanPro/Info.plist - CODE_SIGN_ENTITLEMENTS: KanbanPro/KanbanPro.entitlements - GENERATE_INFOPLIST_FILE: false - SWIFT_STRICT_CONCURRENCY: complete - ASSETCATALOG_COMPILER_APPICON_NAME: AppIconPro - # Spelled out because the whole shared-test-sources arrangement below hangs on it: the two - # apps must have *different* Swift module names, since both emit `.swiftmodule` - # into the same `Build/Products//` and equal names would have one app silently - # overwrite the other's testable interface. - PRODUCT_MODULE_NAME: KanbanPro - # MARK: - Unit tests # - # One suite, run twice — once hosted by each app. Every test in `KanbanTests/` is - # edition-agnostic (it exercises the storage format, the live store and the UI model, none of - # which differ between editions), so duplicating the *sources* would be duplicating the thing - # that is identical; what is worth running twice is the suite against each *binary*, which is - # what these two targets do. The Pro bundle compiles the very same files — it is the same - # `sources` list, by anchor — against `KanbanPro`. + # The suite, hosted by the app: every test in `KanbanTests/` exercises the storage format, the + # live store or the UI model through `@testable import Kanban`, so it needs the app's module and + # its bundle around it. `Fixtures/` rides in as a folder reference for the same reason the + # templates do — a fixture is a board, and a board is directories on disk. KanbanTests: type: bundle.unit-test platform: macOS - sources: &unitTestSources + sources: - KanbanTests - path: Fixtures type: folder @@ -172,31 +120,11 @@ targets: GENERATE_INFOPLIST_FILE: true SWIFT_STRICT_CONCURRENCY: complete - KanbanProTests: - type: bundle.unit-test - platform: macOS - sources: *unitTestSources - dependencies: - - target: KanbanPro - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanProTests - GENERATE_INFOPLIST_FILE: true - SWIFT_STRICT_CONCURRENCY: complete - # The shared sources say `@testable import Kanban`, and they should: they are testing the - # app, not an edition. `-module-alias` (Swift 5.7+) resolves that import to the Pro - # module for this bundle only — the supported way to bind one source name to a different - # module, and it keeps the 56 test files edition-blind instead of littering them with - # conditional imports. - OTHER_SWIFT_FLAGS: -module-alias Kanban=KanbanPro - # MARK: - UI tests # - # Base-only, deliberately: these tests launch the real app and drive its menu bar, which is the - # slowest, most environment-dependent part of the suite, and a second copy would double it to - # re-assert something the Pro unit bundle already proves (it launches the Pro app as its test host - # on every run). The accessibility audits (10-accessibility.md ▸ Verification) are edition-blind — - # every surface they walk is built from the shared tree. + # These launch the real app and drive its menu bar — the slowest, most environment-dependent part + # of the suite. The accessibility audits (10-accessibility.md ▸ Verification) walk every surface + # the design names. KanbanUITests: type: bundle.ui-testing @@ -224,8 +152,8 @@ targets: SWIFT_STRICT_CONCURRENCY: complete schemes: - # `Kanban` is unchanged, on purpose: `xcodebuild … -scheme Kanban` is the established command - # for this repo and base is the app it has always meant. + # `xcodebuild … -scheme Kanban` is the established command for this repo, and it now means the + # only app there is. Kanban: build: targets: @@ -244,21 +172,3 @@ schemes: config: Debug archive: config: Release - - LaneworkPro: - build: - targets: - KanbanPro: all - run: - config: Debug - test: - config: Debug - gatherCoverageData: false - targets: - - KanbanProTests - profile: - config: Release - analyze: - config: Debug - archive: - config: Release diff --git a/scripts/verify-editions.sh b/scripts/verify-editions.sh deleted file mode 100755 index a3b160f..0000000 --- a/scripts/verify-editions.sh +++ /dev/null @@ -1,323 +0,0 @@ -#!/bin/bash -# -# verify-editions.sh — the edition split, checked against the *built binaries*. -# -# 12-editions.md ▸ Distribution: "the editions differ at the binary level — base ships without -# libgit2 and without the network-client entitlement". That is a claim about a signed app bundle, -# so it cannot be a unit test: a test runs inside the app and can only report what the app's own -# code believes. This script reads the products instead — the signature's entitlements, the Mach-O -# load commands and symbol table, and the bundled Info.plist — and is the check the release -# pipeline should run before either edition is submitted. -# -# Usage: -# scripts/verify-editions.sh # resolves the Debug products via xcodebuild -# scripts/verify-editions.sh -# CONFIGURATION=Release scripts/verify-editions.sh -# -# Exits non-zero if any check fails. - -set -uo pipefail - -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIGURATION="${CONFIGURATION:-Debug}" - -failures=0 -checks=0 - -pass() { checks=$((checks + 1)); printf ' ok %s\n' "$1"; } -fail() { checks=$((checks + 1)); failures=$((failures + 1)); printf ' FAIL %s\n' "$1"; } -note() { printf ' %s\n' "$1"; } -head2() { printf '\n%s\n' "$1"; } - -# ---------------------------------------------------------------------------- locating the apps - -if [ "$#" -ge 2 ]; then - BASE_APP="$1" - PRO_APP="$2" -else - products_dir="$( - xcodebuild -project "$PROJECT_DIR/Kanban.xcodeproj" -scheme Kanban \ - -configuration "$CONFIGURATION" -destination 'platform=macOS' \ - -showBuildSettings 2>/dev/null | - awk -F' = ' '/ BUILT_PRODUCTS_DIR = /{print $2; exit}' - )" - if [ -z "${products_dir:-}" ]; then - echo "could not resolve BUILT_PRODUCTS_DIR — build first, or pass the two .app paths" >&2 - exit 2 - fi - BASE_APP="$products_dir/Kanban.app" - PRO_APP="$products_dir/KanbanPro.app" -fi - -for app in "$BASE_APP" "$PRO_APP"; do - if [ ! -d "$app" ]; then - echo "no app bundle at $app — build both schemes first (Kanban, LaneworkPro)" >&2 - exit 2 - fi -done - -BASE_BIN="$BASE_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$BASE_APP/Contents/Info.plist")" -PRO_BIN="$PRO_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$PRO_APP/Contents/Info.plist")" - -echo "Lanework edition verification ($CONFIGURATION)" -note "base: $BASE_APP" -note "pro: $PRO_APP" - -# ------------------------------------------------------------------------------------ helpers - -# The signature's entitlements as an XML plist. Reads the *signature*, not the source -# `.entitlements` file — the whole point is that the shipped binary is what gets checked. -entitlements_of() { - codesign -d --entitlements - --xml "$1" 2>/dev/null -} - -# The entitlement *keys* the signature actually carries, one per line. Extracted from the XML -# rather than queried with PlistBuddy because PlistBuddy needs a seekable file, and writing the -# signature out to a temp file only to read one key back is a step this does not need. -entitlement_keys() { - entitlements_of "$1" | grep -o '[^<]*' | sed 's/<[^>]*>//g' -} - -has_entitlement() { - entitlement_keys "$1" | grep -qxF "$2" -} - -plist_value() { - /usr/libexec/PlistBuddy -c "Print :$2" "$1/Contents/Info.plist" 2>/dev/null -} - -# Every dylib the binary and its embedded frameworks link, plus every undefined symbol — the two -# places a libgit2 dependency can hide (dynamically linked, or statically linked and visible only -# as defined symbols). -linked_libraries() { - local app="$1" - find "$app/Contents/MacOS" "$app/Contents/Frameworks" -type f -perm -u+x 2>/dev/null | - while read -r file; do otool -L "$file" 2>/dev/null; done -} - -git_symbols() { - local bin="$1" - # `git_` is libgit2's exported prefix (git_repository_open, git_commit_create, …). Anchored so - # ordinary Swift symbols with "git" inside a longer name do not register. - nm -a "$bin" 2>/dev/null | awk '{print $NF}' | grep -E '^_?git_[a-z_]+$' | sort -u -} - -# ------------------------------------------------------- base: no libgit2, no network, no keychain - -head2 "Base — no git machinery" - -base_libs="$(linked_libraries "$BASE_APP")" -if printf '%s' "$base_libs" | grep -qiE 'git2|libgit'; then - fail "base links a libgit2 dylib" - printf '%s' "$base_libs" | grep -iE 'git2|libgit' | sed 's/^/ /' -else - pass "base links no libgit2 dylib (otool -L over the executable and embedded frameworks)" -fi - -base_git_syms="$(git_symbols "$BASE_BIN")" -if [ -n "$base_git_syms" ]; then - fail "base's symbol table contains libgit2 symbols" - printf '%s\n' "$base_git_syms" | sed 's/^/ /' -else - pass "base's symbol table contains no libgit2 symbols (nm -a, /^_?git_[a-z_]+\$/)" -fi - -head2 "Base — entitlements" - -if entitlements_of "$BASE_APP" | grep -q ''; then - pass "base is signed with an entitlements dictionary" -else - fail "base has no readable entitlements — is it signed?" -fi - -if has_entitlement "$BASE_APP" "com.apple.security.network.client"; then - fail "base carries com.apple.security.network.client" -else - pass "base does not carry com.apple.security.network.client" -fi - -if has_entitlement "$BASE_APP" "keychain-access-groups"; then - fail "base carries keychain-access-groups" -else - pass "base does not carry keychain-access-groups" -fi - -for key in com.apple.security.app-sandbox \ - com.apple.security.files.user-selected.read-write \ - com.apple.security.files.bookmarks.app-scope \ - com.apple.security.application-groups; do - if has_entitlement "$BASE_APP" "$key"; then - pass "base carries $key" - else - fail "base is missing $key" - fi -done - -# Anything beyond the three above would be entitlement creep — base's posture is "current minimal -# entitlements exactly" (12 ▸ Targets). Four families of key are the toolchain's, not the app's, -# and are discounted rather than counted: -# -# get-task-allow Debug builds, so the debugger can attach. -# application-identifier injected by signing when a profile is involved. -# developer.team-identifier likewise. -# temporary-exception.{files,mach} injected into a *test host* so the sandboxed app can load -# XCTest. Present after `xcodebuild test`, absent from an -# archive — which is why they are named out loud below rather -# than quietly filtered. -toolchain_injected='^(com\.apple\.security\.get-task-allow|com\.apple\.application-identifier|com\.apple\.developer\.team-identifier|com\.apple\.security\.temporary-exception\.[a-z.-]*)$' -settled_minimum='^(com\.apple\.security\.app-sandbox|com\.apple\.security\.files\.user-selected\.read-write|com\.apple\.security\.files\.bookmarks\.app-scope|com\.apple\.security\.application-groups)$' - -base_extra="$(entitlement_keys "$BASE_APP" | grep -vE "$settled_minimum" | grep -vE "$toolchain_injected")" -if [ -n "$base_extra" ]; then - fail "base carries entitlements beyond the settled minimum" - printf '%s\n' "$base_extra" | sed 's/^/ /' -else - pass "base carries no entitlements beyond the settled minimum" -fi - -base_test_exceptions="$(entitlement_keys "$BASE_APP" | grep -E '^com\.apple\.security\.temporary-exception\.')" -if [ -n "$base_test_exceptions" ]; then - note "(built for testing — Xcode's own test-host exceptions are present and discounted:)" - printf '%s\n' "$base_test_exceptions" | sed 's/^/ /' -fi - -# ------------------------------------------------------------------------- pro: the added grants - -head2 "Pro — entitlements" - -for key in com.apple.security.app-sandbox \ - com.apple.security.files.user-selected.read-write \ - com.apple.security.files.bookmarks.app-scope \ - com.apple.security.application-groups \ - com.apple.security.network.client \ - keychain-access-groups; do - if has_entitlement "$PRO_APP" "$key"; then - pass "pro carries $key" - else - fail "pro is missing $key" - fi -done - -# ------------------------------------------------------------------------------ the App Group -# -# 12-editions.md ▸ Distribution (ruled 2026-07-29): "every edition declares the family group — -# `group.dev.rzen.indie.Kanban` — and the board registry with its Application Support peers homes in -# the group container **from day one**". The upgrade story is exactly this string matching across the -# two signatures: an edition declaring a group of its own would share nothing while looking as though -# it did, and nothing in the app could detect the difference — the container would simply be empty. - -head2 "The App Group — one container, either app" - -# The group ids the signature declares, one per line. Read from the signature rather than the source -# `.entitlements` for the reason every check here does: the shipped binary is the claim. -# -# `sed` over the whole stream rather than a line-oriented pass, because `codesign --xml` emits the -# entire plist on **one line** — which is also why `entitlement_keys` above reaches for `grep -o` -# instead of PlistBuddy. The two substitutions trim everything before this key's `` and -# everything after its close, leaving only its own ``s to unwrap. -app_groups() { - entitlements_of "$1" | - sed -e 's/.*com\.apple\.security\.application-groups<\/key>//' \ - -e 's/<\/array>.*//' | - grep -o '[^<]*' | - sed 's/<[^>]*>//g' -} - -expected_group="group.dev.rzen.indie.Kanban" -base_groups="$(app_groups "$BASE_APP")" -pro_groups="$(app_groups "$PRO_APP")" - -for app_label in "base:$base_groups" "pro:$pro_groups"; do - label="${app_label%%:*}" - groups="${app_label#*:}" - if [ "$groups" = "$expected_group" ]; then - pass "$label declares exactly $expected_group" - else - fail "$label declares '$groups', expected '$expected_group'" - fi -done - -# ------------------------------------------------------------------------------ bundle identity - -head2 "Bundle identity" - -check_value() { - local label="$1" actual="$2" expected="$3" - if [ "$actual" = "$expected" ]; then - pass "$label = $expected" - else - fail "$label = '$actual', expected '$expected'" - fi -} - -check_value "base CFBundleIdentifier" "$(plist_value "$BASE_APP" CFBundleIdentifier)" "dev.rzen.indie.Kanban" -check_value "pro CFBundleIdentifier" "$(plist_value "$PRO_APP" CFBundleIdentifier)" "dev.rzen.indie.KanbanPro" -check_value "base CFBundleDisplayName" "$(plist_value "$BASE_APP" CFBundleDisplayName)" "Lanework" -check_value "pro CFBundleDisplayName" "$(plist_value "$PRO_APP" CFBundleDisplayName)" "Lanework Pro" -check_value "base CFBundleShortVersionString" "$(plist_value "$BASE_APP" CFBundleShortVersionString)" "2.0" -check_value "pro CFBundleShortVersionString" "$(plist_value "$PRO_APP" CFBundleShortVersionString)" "2.0" - -base_icon="$(plist_value "$BASE_APP" CFBundleIconName)" -pro_icon="$(plist_value "$PRO_APP" CFBundleIconName)" -if [ -n "$base_icon" ] && [ -n "$pro_icon" ] && [ "$base_icon" != "$pro_icon" ]; then - pass "distinct app icon assets ($base_icon / $pro_icon)" -else - fail "app icon assets are not distinct (base '$base_icon', pro '$pro_icon')" -fi - -# ------------------------------------------------------------------------------ the shared UTI - -head2 "The board UTI — one format, either app" - -# Base exports `dev.rzen.indie.kanban-board`, Pro imports the identical declaration (12 ▸ -# Distribution: "both editions declare the same .kanban package UTI verbatim … base remains the -# exporter"). Compared as normalized plist fragments so a drifted conformance list or tag spec -# fails here rather than in the field, where it would show up as a board that opens in one app and -# not the other. -uti_declaration() { - local app="$1" key="$2" - /usr/libexec/PlistBuddy -x -c "Print :$key" "$app/Contents/Info.plist" 2>/dev/null | - tr -d ' \t' | grep -v '^$' -} - -base_uti="$(uti_declaration "$BASE_APP" UTExportedTypeDeclarations)" -pro_uti="$(uti_declaration "$PRO_APP" UTImportedTypeDeclarations)" - -if [ -n "$base_uti" ]; then - pass "base exports its type declarations" -else - fail "base has no UTExportedTypeDeclarations" -fi - -if [ -n "$pro_uti" ]; then - pass "pro imports its type declarations" -else - fail "pro has no UTImportedTypeDeclarations" -fi - -if [ "$base_uti" = "$pro_uti" ]; then - pass "the two declarations are verbatim identical" -else - fail "the two declarations differ" - diff <(printf '%s\n' "$base_uti") <(printf '%s\n' "$pro_uti") | sed 's/^/ /' -fi - -for app_label in "base:$BASE_APP" "pro:$PRO_APP"; do - label="${app_label%%:*}" - app="${app_label#*:}" - if /usr/libexec/PlistBuddy -c 'Print :CFBundleDocumentTypes:0:LSItemContentTypes:0' "$app/Contents/Info.plist" 2>/dev/null | - grep -q '^dev\.rzen\.indie\.kanban-board$'; then - pass "$label opens dev.rzen.indie.kanban-board" - else - fail "$label does not claim dev.rzen.indie.kanban-board as a document type" - fi -done - -# ------------------------------------------------------------------------------------- verdict - -head2 "$((checks - failures))/$checks checks passed" -if [ "$failures" -gt 0 ]; then - echo "EDITION VERIFICATION FAILED" - exit 1 -fi -echo "EDITION VERIFICATION PASSED"