diff --git a/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json b/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..7a9d8ed --- /dev/null +++ b/KanbanPro/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..57d0737 --- /dev/null +++ b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "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 new file mode 100644 index 0000000..de4e8b2 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128.png 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 new file mode 100644 index 0000000..94cc0b3 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-128@2x.png differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png new file mode 100644 index 0000000..c7c3736 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16.png 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 new file mode 100644 index 0000000..984dce5 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-16@2x.png differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png new file mode 100644 index 0000000..94cc0b3 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256.png 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 new file mode 100644 index 0000000..c08a4bb Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-256@2x.png differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png new file mode 100644 index 0000000..984dce5 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32.png 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 new file mode 100644 index 0000000..fa30a35 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-32@2x.png differ diff --git a/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png new file mode 100644 index 0000000..c08a4bb Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512.png 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 new file mode 100644 index 0000000..e9ec593 Binary files /dev/null and b/KanbanPro/Assets.xcassets/AppIconPro.appiconset/icon-mac-512@2x.png differ diff --git a/KanbanPro/Assets.xcassets/Contents.json b/KanbanPro/Assets.xcassets/Contents.json new file mode 100644 index 0000000..2673658 --- /dev/null +++ b/KanbanPro/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"version":1,"author":"xcode"}} diff --git a/KanbanPro/Edition/ProEdition.swift b/KanbanPro/Edition/ProEdition.swift new file mode 100644 index 0000000..6d5b8f6 --- /dev/null +++ b/KanbanPro/Edition/ProEdition.swift @@ -0,0 +1,36 @@ +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 arrives with the providers it chooses between (base's native +/// stack in m8-native-undo, Pro's git stack in pro-m1), and inventing a seam here before either +/// exists would be guessing at its shape. +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 new file mode 100644 index 0000000..3867a92 --- /dev/null +++ b/KanbanPro/Info.plist @@ -0,0 +1,107 @@ + + + + + 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 new file mode 100644 index 0000000..51d27c0 --- /dev/null +++ b/KanbanPro/KanbanPro.entitlements @@ -0,0 +1,27 @@ + + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + com.apple.security.network.client + + + keychain-access-groups + + $(AppIdentifierPrefix)dev.rzen.indie.KanbanPro + + + diff --git a/KanbanTests/InertGitTests.swift b/KanbanTests/InertGitTests.swift new file mode 100644 index 0000000..a3d0ed6 --- /dev/null +++ b/KanbanTests/InertGitTests.swift @@ -0,0 +1,261 @@ +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." +/// +/// 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 +/// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a +/// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims: +/// 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 +/// 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). +/// +/// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even +/// if the app rewrote a file with identical content, and re-writing git's index with identical +/// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the +/// assertion that nothing *opened for writing* down there at all. + +// MARK: - Subtree snapshots + +/// One entry under `.git`: its path relative to the board root, its bytes (nil for directories), +/// and its on-disk modification date. Directories carry an mtime too — a file created or removed +/// inside a directory moves *that directory's* mtime, so including them catches a write the +/// per-file comparison would miss because the file it added is not in the "before" set. +private struct SubtreeEntry: Equatable, CustomStringConvertible { + let relativePath: String + let data: Data? + let modified: Date + + var description: String { + "\(relativePath) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))" + } +} + +/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself +/// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately +/// *not* passed — a snapshot that skipped hidden files would snapshot nothing at all. +private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] { + let base = root.appendingPathComponent(subtree, isDirectory: true) + let manager = FileManager.default + guard let walker = manager.enumerator(atPath: base.path) else { + Issue.record("could not enumerate \(subtree)") + return [] + } + + var entries: [SubtreeEntry] = [] + for case let relative as String in walker { + let url = base.appendingPathComponent(relative) + let attributes = try manager.attributesOfItem(atPath: url.path) + guard let modified = attributes[.modificationDate] as? Date else { + Issue.record("no modification date for \(subtree)/\(relative)") + continue + } + let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory + entries.append(SubtreeEntry( + relativePath: "\(subtree)/\(relative)", + data: isDirectory ? nil : try Data(contentsOf: url), + modified: modified + )) + } + + // The directory the subtree hangs from, which the enumerator above does not yield. + let rootAttributes = try manager.attributesOfItem(atPath: base.path) + if let modified = rootAttributes[.modificationDate] as? Date { + entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified)) + } + + return entries.sorted { $0.relativePath < $1.relativePath } +} + +// MARK: - Fixture construction + +/// A `.git` directory with the shape a real one has — a ref file, a binary index, a loose object +/// two levels down, a packed-refs file, and an empty `objects/pack` — written under `parent`. +/// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has +/// any reader for. +@discardableResult +private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String { + let prefix = parent.isEmpty ? ".git" : "\(parent)/.git" + + try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8)) + try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8)) + // Deliberately not valid UTF-8 — a real `.git/index` is binary, and a byte comparison that + // only ever sees text is not testing the thing that matters. + try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01])) + try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00])) + try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8)) + try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8)) + try FileManager.default.createDirectory( + at: fixture.url("\(prefix)/objects/pack"), + withIntermediateDirectories: true + ) + + return prefix +} + +/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root +/// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the +/// root's own — a repo-nested board or a clone dropped inside a card is the same promise). +private struct InertGitBoard { + let fixture: WriterFixture + let laneA: String + let laneB: String + let card1: String + let card2: String + /// The card carrying the nested repo — the one the move test drags across lanes. + let cardWithRepo: String + + init() throws { + fixture = try WriterFixture() + laneA = Ident.lane1 + laneB = Ident.lane2 + card1 = Ident.card1 + card2 = Ident.card2 + cardWithRepo = Ident.card3 + + try fixture.item("", Item.board) + try fixture.item(laneA, Item.rich(order: "1024", title: "Doing")) + try fixture.item(laneB, Item.rich(order: "2048", title: "Done")) + try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone")) + + try makeGitDirectory(in: fixture, under: "") + try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)") + } + + var root: URL { fixture.root } + func url(_ relativePath: String) -> URL { fixture.url(relativePath) } + func tearDown() { fixture.tearDown() } +} + +// MARK: - The posture + +struct BaseInertGitTests { + @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched") + func anEditingSessionNeverTouchesGit() throws { + let board = try InertGitBoard() + defer { board.tearDown() } + + 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 + // 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")) + } + let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later") + let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh") + try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n") + try BoardWriter.updateIndex( + inItemFolder: board.url("\(board.laneA)/\(board.card1)"), + operation: .style(title: nil) + ) { document in + document.set(FrontmatterKeys.title, to: .string("Retitled")) + } + _ = try BoardWriter.moveItem( + at: board.url("\(board.laneA)/\(board.card2)"), + toParent: board.url(board.laneB), + sourceBoardRoot: board.root, + destinationBoardRoot: board.root, + order: nil + ) + _ = try BoardWriter.copyItem( + at: board.url("\(board.laneA)/\(board.card1)"), + toParent: board.url(board.laneB), + order: nil, + stamps: .fork + ) + try BoardWriter.deleteItem(at: board.url("\(board.laneA)/\(board.card1)")) + try BoardWriter.restoreItem(at: board.url("\(board.laneA)/\(board.card1)")) + // The renumbers are the pointed ones: both walk a parent's whole directory listing, which + // is where a `.git` entry actually gets looked at. + try BoardWriter.renumberVisibleChildren(of: board.root) + try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA)) + + #expect(try snapshotSubtree(board.root, ".git") == before) + } + + @Test("The board loads and renders normally with a .git at its root, without a warning") + func aGitBearingBoardLoadsLikeAnyOther() throws { + let board = try InertGitBoard() + defer { board.tearDown() } + + let before = try snapshotSubtree(board.root, ".git") + + let result = try BoardLoader.load(boardRoot: board.root) + + #expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB]) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo]) + #expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent") + // A load is a read, but a read that opened `.git` would still move its atimes and would + // still be the app "touching" history; the mtime equality is what is checkable, and a + // loader that decided to *repair* something down there would break it. + #expect(try snapshotSubtree(board.root, ".git") == before) + } + + @Test("A nested .git rides along a card move byte- and mtime-verbatim") + func aNestedRepositorySurvivesACardMove() throws { + let board = try InertGitBoard() + defer { board.tearDown() } + + let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" + let before = try snapshotSubtree(board.root, nested) + + let result = try BoardWriter.moveItem( + at: board.url("\(board.laneA)/\(board.cardWithRepo)"), + toParent: board.url(board.laneB), + sourceBoardRoot: board.root, + destinationBoardRoot: board.root, + order: nil + ) + + // Same identity, new parent — the folder moved whole, `.git` inside it. + let moved = "\(board.laneB)/\(result.id.rawValue)/.git" + let after = try snapshotSubtree(board.root, moved) + + #expect(after.map(\.data) == before.map(\.data)) + #expect(after.map(\.modified) == before.map(\.modified)) + #expect( + after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath) + ) + } + + @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone") + func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws { + let board = try InertGitBoard() + defer { board.tearDown() } + + let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" + let before = try snapshotSubtree(board.root, nested) + + let copyID = try BoardWriter.copyItem( + at: board.url("\(board.laneA)/\(board.cardWithRepo)"), + toParent: board.url(board.laneB), + order: nil, + stamps: .fork + ) + + #expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched") + + // The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set — + // `FileManager.copyItem` preserves them, but the promise being made here is about content + // and shape, not about a copy having been a rename). + let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git") + #expect(copied.map(\.data) == before.map(\.data)) + #expect( + copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) } + == before.map(\.relativePath) + ) + } +} diff --git a/README.md b/README.md index 8c3f4b1..3e6b684 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,13 @@ 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' +xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS' +xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64' +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 ``` 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. diff --git a/project.yml b/project.yml index 1e1a8da..b39d4d9 100644 --- a/project.yml +++ b/project.yml @@ -25,17 +25,29 @@ 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. +# +# 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. targets: + # MARK: - Lanework (base) + Kanban: type: application platform: macOS sources: - Kanban - dependencies: + dependencies: &appDependencies - package: Yams - package: swift-markdown product: Markdown - postBuildScripts: + postBuildScripts: &updateBuildInfo - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"' name: Update Build Info shell: /bin/sh @@ -45,6 +57,8 @@ 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). PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban MARKETING_VERSION: "2.0" INFOPLIST_FILE: Kanban/Info.plist @@ -53,10 +67,57 @@ targets: 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/** + # Pro's own source root — reserved now, filled by pro-m1. See KanbanPro/Edition/ProEdition.swift. + - path: KanbanPro + excludes: + - Info.plist + - KanbanPro.entitlements + 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`. + KanbanTests: type: bundle.unit-test platform: macOS - sources: + sources: &unitTestSources - KanbanTests - path: Fixtures type: folder @@ -69,6 +130,30 @@ 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: the single test here is `testAppLaunches`, and a second copy would + # double the slowest, most environment-dependent part of the suite to re-assert something the + # Pro unit bundle already proves (it launches the Pro app as its test host on every run). + KanbanUITests: type: bundle.ui-testing platform: macOS @@ -83,6 +168,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. Kanban: build: targets: @@ -101,3 +188,21 @@ 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 new file mode 100755 index 0000000..70b4d81 --- /dev/null +++ b/scripts/verify-editions.sh @@ -0,0 +1,282 @@ +#!/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; 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)$' + +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.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 + +# ------------------------------------------------------------------------------ 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"