Split the project into Lanework and Lanework Pro targets
Two app targets from one source tree — no build flags, no #if in shared code: an edition difference is a file one target compiles and the other does not. Base keeps everything it had (dev.rzen.indie.Kanban, minimal entitlements, AppIcon); KanbanPro compiles the same sources plus the reserved KanbanPro/ root (Git/, Remote/, Auth/ land with pro-m1 — libgit2 deliberately not added yet), adds network-client and its keychain group, and hand-writes its Info.plist with the UTI block verbatim — base exports the type, Pro imports it, one format either app opens. The unit-test sources compile twice, once per host, with Pro's module aliased so 56 test files keep @testable import Kanban unchanged; scheme Kanban stays the muscle-memory command and LaneworkPro joins it. InertGitTests pins the base posture with bytes and mtimes — a full editing session over boards carrying realistic .git trees at root and nested in a card leaves all twelve entries untouched, and moves and copies carry them verbatim. scripts/verify-editions.sh proves the rest: 26 checks over signatures, symbols, entitlements, identity, and the shared UTI, discounting Xcode's test-host exceptions by name rather than silently. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
@@ -0,0 +1 @@
|
|||||||
|
{"colors":[{"idiom":"universal"}],"info":{"version":1,"author":"xcode"}}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "icon-mac-16.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "16x16",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "[email protected]",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "16x16",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon-mac-32.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "32x32",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "[email protected]",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "32x32",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon-mac-128.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "128x128",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "[email protected]",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "128x128",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon-mac-256.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "256x256",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "[email protected]",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "256x256",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "icon-mac-512.png",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "512x512",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "[email protected]",
|
||||||
|
"idiom": "mac",
|
||||||
|
"size": "512x512",
|
||||||
|
"scale": "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 538 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"info":{"version":1,"author":"xcode"}}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Lanework Pro</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Lanework Pro</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026 rzen</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<!-- The board format is one format, owned by 01-storage-format.md, with no edition-specific
|
||||||
|
keys (12-editions.md ▸ Distribution). Base **exports** `dev.rzen.indie.kanban-board`;
|
||||||
|
Pro **imports** the identical declaration verbatim — same identifier, same conformance,
|
||||||
|
same tag spec — so a board opens in either app and neither edition's copy can drift into
|
||||||
|
claiming a different format. The drag and clipboard payload types ride along for the same
|
||||||
|
reason: the two editions run the same drag code, so they must carry the same types. -->
|
||||||
|
<key>UTImportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.kanban-board</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>com.apple.package</string>
|
||||||
|
<string>public.directory</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Lanework Board</string>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<string>kanban</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<!-- The drag session payload types (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||||
|
Same-app transfer is the only real consumer — both boards are open in this app — but a
|
||||||
|
system drag session is what crosses window boundaries, draws the copy badge and gives
|
||||||
|
the full-size replica, and a system session needs a declared type to carry. -->
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.kanban.cards</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Lanework Cards</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.kanban.lanes</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Lanework Lanes</string>
|
||||||
|
</dict>
|
||||||
|
<!-- The clipboard manifest (04-interactions.md ▸ Clipboard). Its own type rather than either
|
||||||
|
drag type above: a clipboard payload carries cards or lanes under one identifier, plus
|
||||||
|
the staging copyID and the fallback index.md text a drag has no use for. -->
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>dev.rzen.indie.kanban.clipboard</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Lanework Clipboard</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>Lanework Board</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.rzen.indie.kanban-board</string>
|
||||||
|
</array>
|
||||||
|
<key>LSTypeIsPackage</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Base's three, verbatim (Kanban/Kanban.entitlements) — the board experience is identical
|
||||||
|
across editions, so its sandbox posture is too. -->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Pro only (12-editions.md ▸ Targets): remotes. Base ships without this key at all, which
|
||||||
|
is what makes "base cannot talk to a network" a checkable property of the signed binary
|
||||||
|
rather than a promise about the source (scripts/verify-editions.sh). -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Pro only: remote auth credentials (07-sync-collab.md ▸ Auth — Keychain, SSH, TOFU).
|
||||||
|
A sandboxed app reaches its own keychain items without this key; the group is declared
|
||||||
|
so the credential store has one stable, named home from pro-m2 onward instead of an
|
||||||
|
implicit per-signature one that moves when the signing identity does. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.rzen.indie.KanbanPro</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ The Xcode project is generated — `project.yml` is the source of truth, not the
|
|||||||
xcodegen generate
|
xcodegen generate
|
||||||
xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
|
xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
|
||||||
xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64'
|
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**.
|
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.
|
||||||
|
|||||||
@@ -25,17 +25,29 @@ settings:
|
|||||||
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
|
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
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:
|
targets:
|
||||||
|
# MARK: - Lanework (base)
|
||||||
|
|
||||||
Kanban:
|
Kanban:
|
||||||
type: application
|
type: application
|
||||||
platform: macOS
|
platform: macOS
|
||||||
sources:
|
sources:
|
||||||
- Kanban
|
- Kanban
|
||||||
dependencies:
|
dependencies: &appDependencies
|
||||||
- package: Yams
|
- package: Yams
|
||||||
- package: swift-markdown
|
- package: swift-markdown
|
||||||
product: Markdown
|
product: Markdown
|
||||||
postBuildScripts:
|
postBuildScripts: &updateBuildInfo
|
||||||
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
||||||
name: Update Build Info
|
name: Update Build Info
|
||||||
shell: /bin/sh
|
shell: /bin/sh
|
||||||
@@ -45,6 +57,8 @@ targets:
|
|||||||
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
|
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
|
||||||
settings:
|
settings:
|
||||||
base:
|
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
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban
|
||||||
MARKETING_VERSION: "2.0"
|
MARKETING_VERSION: "2.0"
|
||||||
INFOPLIST_FILE: Kanban/Info.plist
|
INFOPLIST_FILE: Kanban/Info.plist
|
||||||
@@ -53,10 +67,57 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
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 `<module>.swiftmodule`
|
||||||
|
# into the same `Build/Products/<config>/` 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:
|
KanbanTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: macOS
|
platform: macOS
|
||||||
sources:
|
sources: &unitTestSources
|
||||||
- KanbanTests
|
- KanbanTests
|
||||||
- path: Fixtures
|
- path: Fixtures
|
||||||
type: folder
|
type: folder
|
||||||
@@ -69,6 +130,30 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: true
|
GENERATE_INFOPLIST_FILE: true
|
||||||
SWIFT_STRICT_CONCURRENCY: complete
|
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:
|
KanbanUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: macOS
|
platform: macOS
|
||||||
@@ -83,6 +168,8 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
|
||||||
schemes:
|
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:
|
Kanban:
|
||||||
build:
|
build:
|
||||||
targets:
|
targets:
|
||||||
@@ -101,3 +188,21 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
archive:
|
archive:
|
||||||
config: Release
|
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
|
||||||
|
|||||||
@@ -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 <base.app> <pro.app>
|
||||||
|
# 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 '<key>[^<]*</key>' | 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 '<key>'; 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"
|
||||||