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
|
||||
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.
|
||||
|
||||
@@ -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 `<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:
|
||||
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
|
||||
|
||||
@@ -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"
|
||||