From 4d1f182edc40bd53988cac9edbe3ebcb619fd44c Mon Sep 17 00:00:00 2001 From: Hugo Pretorius Date: Sun, 18 Oct 2026 20:15:31 -0700 Subject: [PATCH] iCloud sync of the Library through CloudKit (GDR-0023 decision 1, ADR-0017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimbreCloud: CKSyncEngine over one custom zone of the private database. Events are immutable records named by their id and saved once; entries are replaced whole on edit with the later edit winning; the profile is one record; the Library's own merge does the merging, and a server-changed conflict takes the server's copy, merges, and sends again over its system fields. A small state file beside the Library remembers the engine's state and what the server has; nothing in it is a word. ADR-0017 records the schema, which can only ever grow. Off by default on every device: Settings › Library on the Mac, You on the iPhone, a toggle on the Watch, each with the copy GDR-0010 asked for. The binary still makes no request; the iCloud entitlements join all three apps. The Library gains several save observers so the card and the sync both follow it. The site keeps GDR-0010's promise: the headline becomes "never leaves your devices", the FAQ and the network page say exactly what moves, by whom, and that dictation never does. TimbreCloud: 6 offline tests (every record type round-trips; the state persists). The two-device proof is the joint test. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/app.yml | 3 + CHANGELOG.md | 5 + CLAUDE.md | 8 + .../Companion.xcodeproj/project.pbxproj | 19 ++ apps/Companion/Config/Watch.entitlements | 11 + apps/Companion/Config/iOS.entitlements | 11 + apps/Companion/Watch/Sources/WatchModel.swift | 17 +- .../Watch/Sources/WatchRootView.swift | 7 + .../iOS/Sources/CompanionModel.swift | 19 +- apps/Companion/iOS/Sources/YouView.swift | 31 +++ apps/Timbre/Sources/LibrarySettings.swift | 63 +++++ apps/Timbre/Timbre.entitlements | 11 + docs/decisions/README.md | 1 + .../adr/0017-the-librarys-cloudkit-schema.md | 83 ++++++ docs/design/product-map.md | 2 +- packages/TimbreCloud/Package.swift | 36 +++ .../TimbreCloud/CloudKitLibrarySync.swift | 245 ++++++++++++++++++ .../Sources/TimbreCloud/CloudRecords.swift | 126 +++++++++ .../Sources/TimbreCloud/SyncState.swift | 53 ++++ .../TimbreCloudTests/CloudRecordsTests.swift | 72 +++++ .../Sources/TimbreCore/Library/Library.swift | 12 +- .../Tests/TimbreCoreTests/LibraryTests.swift | 2 +- packages/TimbreKit/Package.swift | 3 +- .../TimbreKit/DictationController.swift | 19 +- web/src/lib/decisions/records.json | 30 +++ web/src/lib/design/design.json | 2 +- web/src/lib/releases/releases.json | 4 +- .../lib/university/icloud-and-cloudkit.html | 2 +- web/src/routes/(public)/+page.svelte | 10 +- web/src/routes/(public)/network/+page.svelte | 9 +- 30 files changed, 897 insertions(+), 19 deletions(-) create mode 100644 docs/decisions/adr/0017-the-librarys-cloudkit-schema.md create mode 100644 packages/TimbreCloud/Package.swift create mode 100644 packages/TimbreCloud/Sources/TimbreCloud/CloudKitLibrarySync.swift create mode 100644 packages/TimbreCloud/Sources/TimbreCloud/CloudRecords.swift create mode 100644 packages/TimbreCloud/Sources/TimbreCloud/SyncState.swift create mode 100644 packages/TimbreCloud/Tests/TimbreCloudTests/CloudRecordsTests.swift diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 5554c28..24e4344 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -49,6 +49,9 @@ jobs: - name: Test TimbreReminders run: swift test --package-path packages/TimbreReminders + - name: Test TimbreCloud + run: swift test --package-path packages/TimbreCloud + - name: Test TimbreRelay (iOS simulator; the package has no macOS) run: > xcodebuild test -scheme TimbreRelay diff --git a/CHANGELOG.md b/CHANGELOG.md index 30026ad..114b09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ turns the section for that version into the release notes shown in the app. up as they fade. A streak, the week's count and the five words most worth a minute, in Settings › Library. Nothing is ranked or shared. +- **iCloud, if you want it.** Settings › Library › Sync my Library through + iCloud: your words, meanings and practice history in your own iCloud + account, so your iPhone, Watch and other Macs have the same Library. Off + by default. Timbre still sends nothing itself; macOS's iCloud daemon does + the syncing. Never a dictation, a capture or a usage count. ### Changed - The Library lives in its own package, TimbreCore, which the iPhone and Watch apps will share. Your words are in diff --git a/CLAUDE.md b/CLAUDE.md index e4bcb99..1d04721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,8 @@ packages/TimbreReminders/ The Timbre list in Reminders (EventKit). All three; read-only on the Watch (GDR-0029). packages/TimbreRelay/ Watch to phone over WatchConnectivity: the to-do queue and the session (GDR-0029). iOS and watchOS only. +packages/TimbreCloud/ The Library through the user's iCloud: CKSyncEngine, + events as immutable records (ADR-0017). All three. packages/TimbreKit/ The macOS pipeline + tests: the hotkey, the overlay, insertion, the polisher, command mode. Depends on the four above. Dictation work happens here. @@ -63,6 +65,7 @@ swift test --package-path packages/TimbreCore swift test --package-path packages/TimbreSpeech swift test --package-path packages/TimbreModel swift test --package-path packages/TimbreReminders +swift test --package-path packages/TimbreCloud # Measure the polisher against the fixed corpus (ADR-0004). Decoding is # greedy (ADR-0011), so --repeat checks determinism; still never judge a @@ -198,6 +201,11 @@ TimbreCore's types reach the app through TimbreKit's re-export. - **Widgets read a snapshot, never the Library** (ADR-0016). The app writes `today.json` to the shared container on every rebuild and reloads the timelines; `SharedContainer` is the one place that knows the paths. +- **iCloud sync is a union of events, never a stored number** (ADR-0017). + `TimbreCloud` sends events once and entries by latest edit through + CKSyncEngine; the Library's own `merge` does the merging. A field is never + renamed, retyped or removed: the schema is forever. Off by default; the + binary still makes no request. - **Strength is never stored** (ADR-0014). The Library keeps events — taught, met, passed, failed, forgot — and `Strength` derives retrievability with FSRS-4.5; the daily pick is computed from the events before the start of diff --git a/apps/Companion/Companion.xcodeproj/project.pbxproj b/apps/Companion/Companion.xcodeproj/project.pbxproj index 754c458..9f68a7b 100644 --- a/apps/Companion/Companion.xcodeproj/project.pbxproj +++ b/apps/Companion/Companion.xcodeproj/project.pbxproj @@ -12,11 +12,13 @@ BB0000000000000000000113 /* TimbreModel in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000123 /* TimbreModel */; }; BB0000000000000000000114 /* TimbreReminders in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000124 /* TimbreReminders */; }; BB0000000000000000000117 /* TimbreRelay in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000125 /* TimbreRelay */; }; + BB0000000000000000000118 /* TimbreCloud in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000126 /* TimbreCloud */; }; BB0000000000000000000115 /* TimbreWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000302 /* TimbreWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BB0000000000000000000116 /* Timbre Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000202 /* Timbre Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BB0000000000000000000211 /* TimbreCore in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000221 /* TimbreCore */; }; BB0000000000000000000212 /* TimbreReminders in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000222 /* TimbreReminders */; }; BB0000000000000000000214 /* TimbreRelay in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000223 /* TimbreRelay */; }; + BB0000000000000000000215 /* TimbreCloud in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000224 /* TimbreCloud */; }; BB0000000000000000000213 /* Timbre Watch Widgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000402 /* Timbre Watch Widgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BB0000000000000000000311 /* TimbreCore in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000321 /* TimbreCore */; }; BB0000000000000000000411 /* TimbreCore in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000421 /* TimbreCore */; }; @@ -136,6 +138,7 @@ BB0000000000000000000113 /* TimbreModel in Frameworks */, BB0000000000000000000114 /* TimbreReminders in Frameworks */, BB0000000000000000000117 /* TimbreRelay in Frameworks */, + BB0000000000000000000118 /* TimbreCloud in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -146,6 +149,7 @@ BB0000000000000000000211 /* TimbreCore in Frameworks */, BB0000000000000000000212 /* TimbreReminders in Frameworks */, BB0000000000000000000214 /* TimbreRelay in Frameworks */, + BB0000000000000000000215 /* TimbreCloud in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -241,6 +245,7 @@ BB0000000000000000000123 /* TimbreModel */, BB0000000000000000000124 /* TimbreReminders */, BB0000000000000000000125 /* TimbreRelay */, + BB0000000000000000000126 /* TimbreCloud */, ); productName = Timbre; productReference = BB0000000000000000000102 /* Timbre.app */; @@ -268,6 +273,7 @@ BB0000000000000000000221 /* TimbreCore */, BB0000000000000000000222 /* TimbreReminders */, BB0000000000000000000223 /* TimbreRelay */, + BB0000000000000000000224 /* TimbreCloud */, ); productName = "Timbre Watch App"; productReference = BB0000000000000000000202 /* Timbre Watch App.app */; @@ -343,6 +349,7 @@ BB0000000000000000000023 /* XCLocalSwiftPackageReference "../../packages/TimbreModel" */, BB0000000000000000000024 /* XCLocalSwiftPackageReference "../../packages/TimbreReminders" */, BB0000000000000000000025 /* XCLocalSwiftPackageReference "../../packages/TimbreRelay" */, + BB0000000000000000000026 /* XCLocalSwiftPackageReference "../../packages/TimbreCloud" */, ); preferredProjectObjectVersion = 77; productRefGroup = BB0000000000000000000004 /* Products */; @@ -579,6 +586,10 @@ isa = XCLocalSwiftPackageReference; relativePath = ../../packages/TimbreRelay; }; + BB0000000000000000000026 /* XCLocalSwiftPackageReference "../../packages/TimbreCloud" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../packages/TimbreCloud; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -602,6 +613,10 @@ isa = XCSwiftPackageProductDependency; productName = TimbreRelay; }; + BB0000000000000000000126 /* TimbreCloud */ = { + isa = XCSwiftPackageProductDependency; + productName = TimbreCloud; + }; BB0000000000000000000221 /* TimbreCore */ = { isa = XCSwiftPackageProductDependency; productName = TimbreCore; @@ -614,6 +629,10 @@ isa = XCSwiftPackageProductDependency; productName = TimbreRelay; }; + BB0000000000000000000224 /* TimbreCloud */ = { + isa = XCSwiftPackageProductDependency; + productName = TimbreCloud; + }; BB0000000000000000000321 /* TimbreCore */ = { isa = XCSwiftPackageProductDependency; productName = TimbreCore; diff --git a/apps/Companion/Config/Watch.entitlements b/apps/Companion/Config/Watch.entitlements index 708871b..bc93fe2 100644 --- a/apps/Companion/Config/Watch.entitlements +++ b/apps/Companion/Config/Watch.entitlements @@ -8,5 +8,16 @@ group.dev.hugopretorius.Timbre + + com.apple.developer.icloud-container-identifiers + + iCloud.dev.hugopretorius.Timbre + + com.apple.developer.icloud-services + + CloudKit + diff --git a/apps/Companion/Config/iOS.entitlements b/apps/Companion/Config/iOS.entitlements index 708871b..bc93fe2 100644 --- a/apps/Companion/Config/iOS.entitlements +++ b/apps/Companion/Config/iOS.entitlements @@ -8,5 +8,16 @@ group.dev.hugopretorius.Timbre + + com.apple.developer.icloud-container-identifiers + + iCloud.dev.hugopretorius.Timbre + + com.apple.developer.icloud-services + + CloudKit + diff --git a/apps/Companion/Watch/Sources/WatchModel.swift b/apps/Companion/Watch/Sources/WatchModel.swift index fc20b76..3a07395 100644 --- a/apps/Companion/Watch/Sources/WatchModel.swift +++ b/apps/Companion/Watch/Sources/WatchModel.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import TimbreCloud import TimbreCore import TimbreRelay import TimbreReminders @@ -16,6 +17,8 @@ final class WatchModel { let library: Library let reminders = ReminderStore() + /// The Library through the user's own iCloud (GDR-0023 decision 1). + let cloud: CloudKitLibrarySync /// Hands each to-do to the iPhone, which files it (GDR-0029). let relay = Relay(queueURL: SharedContainer.directory.appending(path: "pending-todos.json")) private(set) var card: WordCard? @@ -24,12 +27,24 @@ final class WatchModel { private init() { library = Library(fileURL: SharedContainer.libraryURL, device: .watch, legacy: nil) - library.didSave = { [weak self] in self?.refresh() } + cloud = CloudKitLibrarySync( + library: library, stateURL: SharedContainer.directory.appending(path: "library-sync.json")) + library.addSaveObserver { [weak self] in self?.refresh() } relay.onFiled = { [weak self] item, list in self?.lastFiled = TodoPhrasing.confirmation(item.todo, list: list) WKInterfaceDevice.current().play(.success) } relay.activate() + if syncsThroughICloud { cloud.start() } + } + + /// The toggle (GDR-0010): off by default. + var syncsThroughICloud: Bool { + get { UserDefaults.standard.bool(forKey: "librarySyncsThroughICloud") } + set { + UserDefaults.standard.set(newValue, forKey: "librarySyncsThroughICloud") + if newValue { cloud.start() } else { cloud.stop() } + } } func refresh() { diff --git a/apps/Companion/Watch/Sources/WatchRootView.swift b/apps/Companion/Watch/Sources/WatchRootView.swift index 16aff3d..91c51a2 100644 --- a/apps/Companion/Watch/Sources/WatchRootView.swift +++ b/apps/Companion/Watch/Sources/WatchRootView.swift @@ -50,6 +50,13 @@ struct WatchRootView: View { model.addHeardWord(word) confirmation = "Banked \(word)." } + + Toggle( + "Sync through iCloud", + isOn: Binding( + get: { model.syncsThroughICloud }, set: { model.syncsThroughICloud = $0 }) + ) + .font(.caption) } } .navigationTitle("Timbre") diff --git a/apps/Companion/iOS/Sources/CompanionModel.swift b/apps/Companion/iOS/Sources/CompanionModel.swift index 431f178..db93010 100644 --- a/apps/Companion/iOS/Sources/CompanionModel.swift +++ b/apps/Companion/iOS/Sources/CompanionModel.swift @@ -1,6 +1,7 @@ import AVFAudio import Foundation import Observation +import TimbreCloud import TimbreCore import TimbreModel import TimbreRelay @@ -22,6 +23,8 @@ final class CompanionModel { let library: Library let reminders = ReminderStore() let reader = SpeechReader() + /// The Library through the user's own iCloud (GDR-0023 decision 1, ADR-0017). + let cloud: CloudKitLibrarySync /// Files the to-dos the Watch says (GDR-0029). private let relay = Relay(queueURL: SharedContainer.directory.appending(path: "pending-todos.json")) private let speech = SpokenInput() @@ -38,7 +41,9 @@ final class CompanionModel { private init() { library = Library(fileURL: SharedContainer.libraryURL, device: .iPhone, legacy: nil) - library.didSave = { [weak self] in self?.refresh() } + cloud = CloudKitLibrarySync( + library: library, stateURL: SharedContainer.directory.appending(path: "library-sync.json")) + library.addSaveObserver { [weak self] in self?.refresh() } relay.onTodo = { [weak self] todo in guard let self else { throw ReminderStore.Failure.noAccess } return try await self.file(todo) @@ -50,6 +55,7 @@ final class CompanionModel { func start() async { refresh() relay.activate() + if syncsThroughICloud { cloud.start() } reader.prewarm() do { try await speech.prepare() @@ -226,3 +232,14 @@ final class CompanionModel { return try await reminders.openTodos() } } + +extension CompanionModel { + /// The toggle (GDR-0010): off by default; stopping keeps what is on the phone. + var syncsThroughICloud: Bool { + get { UserDefaults.standard.bool(forKey: "librarySyncsThroughICloud") } + set { + UserDefaults.standard.set(newValue, forKey: "librarySyncsThroughICloud") + if newValue { cloud.start() } else { cloud.stop() } + } + } +} diff --git a/apps/Companion/iOS/Sources/YouView.swift b/apps/Companion/iOS/Sources/YouView.swift index 3d4eaa8..e4d58a0 100644 --- a/apps/Companion/iOS/Sources/YouView.swift +++ b/apps/Companion/iOS/Sources/YouView.swift @@ -1,4 +1,5 @@ import SwiftUI +import TimbreCloud import TimbreCore /// About you, your file, and what never leaves the phone. @@ -22,6 +23,23 @@ struct YouView: View { Text("Your field chooses the word lists, and will make explain right for your reader.") } + Section { + Toggle( + "Sync through iCloud", + isOn: Binding( + get: { model.syncsThroughICloud }, set: { model.syncsThroughICloud = $0 })) + LabeledContent("Status", value: cloudStatus) + if model.syncsThroughICloud { + Button("Sync now") { Task { await model.cloud.syncNow() } } + } + } header: { + Text("iCloud") + } footer: { + Text( + "Your words, meanings and practice history in your own iCloud, so your Mac and Watch have the same Library. Timbre sends nothing itself; iOS's iCloud daemon does the syncing. Never a dictation. End-to-end encrypted with Advanced Data Protection." + ) + } + Section { if let exportURL { ShareLink(item: exportURL) { @@ -87,3 +105,16 @@ struct YouView: View { } } } + +extension YouView { + fileprivate var cloudStatus: String { + switch model.cloud.status { + case .off: "Off" + case .starting: "Starting…" + case .syncing: "Syncing…" + case .upToDate(let date): + date.map { "Up to date, \($0.formatted(.relative(presentation: .named)))" } ?? "Up to date" + case .unavailable(let reason): reason + } + } +} diff --git a/apps/Timbre/Sources/LibrarySettings.swift b/apps/Timbre/Sources/LibrarySettings.swift index 558060e..80f4d40 100644 --- a/apps/Timbre/Sources/LibrarySettings.swift +++ b/apps/Timbre/Sources/LibrarySettings.swift @@ -20,6 +20,8 @@ struct LibrarySettings: View { Divider() ProfileSection(library: controller.library) Divider() + CloudSection(controller: controller) + Divider() ExportSection(library: controller.library) } .padding(24) @@ -564,3 +566,64 @@ private struct PracticeSection: View { } } } + +// MARK: - iCloud + +/// The one boundary, described exactly (GDR-0010, GDR-0023 decision 1): the +/// Library can sync through the user's own iCloud, off by default, and +/// Timbre still makes no network request — macOS's own daemon does the +/// syncing. Dictations never go. +private struct CloudSection: View { + let controller: DictationController + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("iCloud") + .font(.headline) + Toggle( + "Sync my Library through iCloud", + isOn: Binding( + get: { controller.syncsThroughICloud }, + set: { controller.syncsThroughICloud = $0 } + ) + ) + Text( + "Your words, meanings and practice history, in your own iCloud account, so your iPhone, Watch and " + + "other Macs have the same Library. Timbre sends nothing itself: macOS's iCloud daemon does the " + + "syncing. Never a dictation, a capture or a usage count. End-to-end encrypted when Advanced Data " + + "Protection is on for your account." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if controller.syncsThroughICloud { + Button("Sync Now") { Task { await controller.cloud.syncNow() } } + .buttonStyle(.link) + .font(.caption) + } + Link( + "About Advanced Data Protection", + destination: URL(string: "https://support.apple.com/en-us/102651")! + ) + .font(.caption) + } + } + } + + private var status: String { + switch controller.cloud.status { + case .off: "Off. Your Library stays on this Mac; Export in the section below is the other way out." + case .starting: "Starting…" + case .syncing: "Syncing…" + case .upToDate(let date): + date.map { "Up to date, last fetched \($0.formatted(.relative(presentation: .named)))." } + ?? "Up to date." + case .unavailable(let reason): reason + } + } +} diff --git a/apps/Timbre/Timbre.entitlements b/apps/Timbre/Timbre.entitlements index 0f046b4..7d0d468 100644 --- a/apps/Timbre/Timbre.entitlements +++ b/apps/Timbre/Timbre.entitlements @@ -9,5 +9,16 @@ usage string is necessary but not sufficient. --> com.apple.security.device.audio-input + + com.apple.developer.icloud-container-identifiers + + iCloud.dev.hugopretorius.Timbre + + com.apple.developer.icloud-services + + CloudKit + diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 9373dd2..3849cb3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -45,6 +45,7 @@ Two kinds of records live here. The boundary rule: | [0014](adr/0014-strength-from-events-fsrs.md) | Strength is retrievability, derived from an entry's events with FSRS-4.5 | Accepted | | [0015](adr/0015-a-package-per-framework.md) | Framework-bound code lives in a package per framework, with that framework's platforms | Accepted | | [0016](adr/0016-the-companion-project.md) | The companion: one project, four targets, a shared container, and widgets that read a snapshot | Accepted | +| [0017](adr/0017-the-librarys-cloudkit-schema.md) | The Library's CloudKit schema: events as immutable records, entries by latest edit, one zone | Accepted | ### GDRs diff --git a/docs/decisions/adr/0017-the-librarys-cloudkit-schema.md b/docs/decisions/adr/0017-the-librarys-cloudkit-schema.md new file mode 100644 index 0000000..95498ba --- /dev/null +++ b/docs/decisions/adr/0017-the-librarys-cloudkit-schema.md @@ -0,0 +1,83 @@ +# 0017 — The Library's CloudKit schema: events as immutable records, entries by latest edit, one zone + +- **Status:** Accepted +- **Date:** 2026-10-18 +- **Builds on:** [GDR-0023](../gdr/0023-the-library.md) (decision 1: the + Library syncs through iCloud, behind a sync interface), + [GDR-0010](../gdr/0010-optional-icloud-sync-of-personal-dictionary.md) + (off by default, plainly described, never dictations), + [ADR-0013](0013-timbrecore-platform-neutral-package.md) (`LibrarySync`; + the merge is the Library's own rule), + [ADR-0014](0014-strength-from-events-fsrs.md) (events are the record), + [ADR-0015](0015-a-package-per-framework.md) (a package per framework). + University module 13 is the long version. + +## Context + +GDR-0023 chose CloudKit's private database and left the shape to a record +written with the code. A CloudKit schema is forever: once deployed to +production, a record type or field can be added but never renamed, retyped +or removed, and every build a user ever ran must still read what a newer +one writes. The Library's own design (ADR-0014) points the way: events are +immutable and only ever added; strength is never stored; entries are +edited rarely; the profile is small. + +## Decision + +**One custom zone, three record types, every field additive, and the +Library's merge rule doing the merging — never CloudKit's.** + +``` +Zone "Library" in the private database (custom, so changes can be fetched +by token; the default zone cannot). + +Event recordName = the event's composed id (ADR-0014) + entry, kind, date, device, app?, drill? + Immutable: saved once, never modified, never deleted. +Entry recordName = the entry's id + kind, term, heard?, meaning?, example?, tags, sourceDevice, + sourceDate, sourceHow, sourceApp?, edited + Replaced whole on edit; the later `edited` wins. +Profile recordName = "profile" + industries, role?, firstLanguage? +``` + +- **`CKSyncEngine` carries the records**; `TimbreCloud` is the package + (CloudKit on all three platforms). It keeps a small state file beside the + Library — the engine's serialization, which events and entry edits the + server has, the server's system fields for each entry — and nothing in it + is a word. +- **Merge is a union of events, then entries by latest edit** — the + Library's `merge(entries:events:)`, unchanged. A server-changed conflict + on an entry is resolved by taking the server's record, merging, and + sending ours again over the server's system fields. +- **Deletions are events.** No record is ever deleted; a `forgot` event is + the tombstone, and the merge honours it on every device. +- **The profile is taken from the server only when this device's is + empty**; otherwise this device's is sent. Two profiles edited apart is a + case we do not pretend to solve. +- **Off by default; a toggle per device; stopping keeps what is local.** + The container is `iCloud.dev.hugopretorius.Timbre`, shared by the Mac, + the iPhone and the Watch regardless of bundle identifier. + +## Consequences + +- The mapping to and from `CKRecord` is pure and tested offline; the engine + is not, and the two-device proof the product map asks for — teach on one, + see it on the other, `nettop` empty — is a manual test on hardware. +- Adding a field is a change to a record type and a default on read; a + rename is forbidden and the code says so where the fields are named. +- The iCloud entitlements join all three apps. A build without a team runs + with the toggle off; with a team, Xcode's automatic signing registers the + container the first time. +- Strength is still never stored, here or anywhere: a device that fetches + a year of events recomputes it. + +## Alternatives considered + +- **SwiftData with CloudKit mirroring:** rejected (ADR-0013); it cannot + express a union of histories. +- **One record per entry holding its history as a list:** rejected; two + devices appending to one list is the conflict the event design avoids. +- **The key-value store, as GDR-0010 first chose:** rejected; too small, + whole-value replacement, no per-record merge. diff --git a/docs/design/product-map.md b/docs/design/product-map.md index 0b2a0e1..0713a6c 100644 --- a/docs/design/product-map.md +++ b/docs/design/product-map.md @@ -115,7 +115,7 @@ That split is a record of its own (an ADR) before the first iOS line. |---|---|---| | 0 | 0.4.0: icon, disk image, to-dos, plain, live typing. Launch. | Public. | | 1 | **The Library on Mac.** One store with kinds; Settings › Dictionary becomes *Library*; explain, fix and define write to it; export to a file. | Every existing list migrates; nothing lost. | -| 2 | **`TimbreCore` + iCloud sync.** The split, the CloudKit container, sync on two Macs. | Teach on one Mac, see it on the other, `nettop` still empty. | +| 2 | **`TimbreCore` + iCloud sync.** The split (ADR-0013, ADR-0015), the CloudKit container and schema (ADR-0017), sync on two Macs. | Teach on one Mac, see it on the other, `nettop` still empty. | | 3 | **Timbre for iPhone.** The Library, profile, word of the day, Spell it. | On the phone a minute after teaching on the Mac. | | 4 | **Practice that listens.** Say it, Explain it, strength and streaks. | A drill corpus the harness can score. | | 5 | **Profile-aware explain** on the Mac. | A commands corpus case per industry. | diff --git a/packages/TimbreCloud/Package.swift b/packages/TimbreCloud/Package.swift new file mode 100644 index 0000000..4a81fcf --- /dev/null +++ b/packages/TimbreCloud/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.2 +import PackageDescription + +// The same language mode and concurrency defaults as TimbreKit (ADR-0001). +let concurrencySettings: [SwiftSetting] = [ + .defaultIsolation(MainActor.self), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), +] + +// The Library through the user's own iCloud (GDR-0023, decision 1; ADR-0017): +// CloudKit's private database, Apple's daemons moving the records, the +// binary still making no request. CloudKit exists on all three platforms, +// so that is the package's reach (ADR-0015). +let package = Package( + name: "TimbreCloud", + platforms: [.macOS(.v26), .iOS(.v26), .watchOS(.v26)], + products: [ + .library(name: "TimbreCloud", targets: ["TimbreCloud"]) + ], + dependencies: [ + .package(path: "../TimbreCore") + ], + targets: [ + .target( + name: "TimbreCloud", + dependencies: ["TimbreCore"], + swiftSettings: concurrencySettings + ), + .testTarget( + name: "TimbreCloudTests", + dependencies: ["TimbreCloud"], + swiftSettings: concurrencySettings + ), + ] +) diff --git a/packages/TimbreCloud/Sources/TimbreCloud/CloudKitLibrarySync.swift b/packages/TimbreCloud/Sources/TimbreCloud/CloudKitLibrarySync.swift new file mode 100644 index 0000000..a3f6cd9 --- /dev/null +++ b/packages/TimbreCloud/Sources/TimbreCloud/CloudKitLibrarySync.swift @@ -0,0 +1,245 @@ +import CloudKit +import Foundation +import Observation +import TimbreCore + +/// The Library through the user's own iCloud (GDR-0023, decision 1): +/// `CKSyncEngine` over the private database's "Library" zone, events as +/// immutable records, entries by latest edit, the profile as one record +/// (ADR-0017). Off until the user turns it on; stopped when they turn it +/// off. The binary makes no request: the engine hands records to the +/// system, and the system's daemon talks to Apple. +@Observable +public final class CloudKitLibrarySync: NSObject { + + public enum Status: Equatable, Sendable { + case off + case starting + case syncing + case upToDate(Date?) + case unavailable(String) + } + + public static let containerIdentifier = "iCloud.dev.hugopretorius.Timbre" + + public private(set) var status: Status = .off + + private let library: Library + private let stateURL: URL + private var state: SyncState + private var engine: CKSyncEngine? + private var observing = false + + public init(library: Library, stateURL: URL) { + self.library = library + self.stateURL = stateURL + self.state = SyncState.load(from: stateURL) + super.init() + } + + /// Builds the engine and sends everything the server does not have yet. + public func start() { + guard engine == nil else { return } + status = .starting + let container = CKContainer(identifier: Self.containerIdentifier) + var configuration = CKSyncEngine.Configuration( + database: container.privateCloudDatabase, + stateSerialization: state.engine.flatMap { + try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0) + }, + delegate: self) + configuration.automaticallySync = true + let engine = CKSyncEngine(configuration) + self.engine = engine + engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: CloudRecords.zoneID))]) + if !observing { + observing = true + library.addSaveObserver { [weak self] in self?.queueChanges() } + } + queueChanges() + Task { await self.checkAccount(container) } + } + + /// Stops syncing; what is on the Mac stays on the Mac. + public func stop() { + engine = nil + status = .off + } + + /// Asks for a fetch and a send now. + public func syncNow() async { + guard let engine else { return } + status = .syncing + try? await engine.fetchChanges() + try? await engine.sendChanges() + status = .upToDate(state.lastFetched) + } + + // MARK: - What to send + + /// Everything the Library has that the server has not been told of. + private func queueChanges() { + guard let engine else { return } + var pending: [CKSyncEngine.PendingRecordZoneChange] = [] + for event in library.events where !state.pushedEventIDs.contains(event.id) { + pending.append(.saveRecord(CKRecord.ID(recordName: event.id, zoneID: CloudRecords.zoneID))) + } + for entry in library.entries where state.pushedEntryEdits[entry.id].map({ $0 < entry.edited }) ?? true + { + pending.append(.saveRecord(CKRecord.ID(recordName: entry.id, zoneID: CloudRecords.zoneID))) + } + if !state.profilePushed { + pending.append( + .saveRecord(CKRecord.ID(recordName: CloudRecords.profileName, zoneID: CloudRecords.zoneID))) + } + if !pending.isEmpty { engine.state.add(pendingRecordZoneChanges: pending) } + } + + private func record(for recordID: CKRecord.ID) -> CKRecord? { + let name = recordID.recordName + if name == CloudRecords.profileName { + let base = + state.systemFields[name].flatMap(CloudRecords.record(fromSystemFields:)) + ?? CKRecord(recordType: CloudRecords.profileType, recordID: recordID) + return CloudRecords.fill(base, with: library.profile) + } + if let entry = library.entry(name) { + let base = + state.systemFields[name].flatMap(CloudRecords.record(fromSystemFields:)) + ?? CKRecord(recordType: CloudRecords.entryType, recordID: recordID) + return CloudRecords.fill(base, with: entry) + } + if let event = library.events.first(where: { $0.id == name }) { + return CloudRecords.record(for: event) + } + return nil + } + + // MARK: - What arrived + + private func take(_ records: [CKRecord]) { + var entries: [LibraryEntry] = [] + var events: [LibraryEvent] = [] + var profile: Profile? + for record in records { + switch record.recordType { + case CloudRecords.eventType: + if let event = CloudRecords.event(from: record) { + events.append(event) + state.pushedEventIDs.insert(event.id) + } + case CloudRecords.entryType: + if let entry = CloudRecords.entry(from: record) { + entries.append(entry) + state.pushedEntryEdits[entry.id] = entry.edited + state.systemFields[entry.id] = CloudRecords.systemFields(of: record) + } + case CloudRecords.profileType: + profile = CloudRecords.profile(from: record) + state.systemFields[CloudRecords.profileName] = CloudRecords.systemFields(of: record) + state.profilePushed = true + default: + break + } + } + if !entries.isEmpty || !events.isEmpty { + library.merge(entries: entries, events: events) + } + if let profile, profile != library.profile, library.profile == .empty { + library.profile = profile + } + state.lastFetched = Date() + persist() + } + + private func persist() { + try? state.save(to: stateURL) + } + + /// The engine hands over its state to keep whenever it changes; it is + /// handed back at the next launch so nothing is fetched twice. + private func keep(_ serialization: CKSyncEngine.State.Serialization) { + state.engine = try? JSONEncoder().encode(serialization) + persist() + } + + private func checkAccount(_ container: CKContainer) async { + do { + switch try await container.accountStatus() { + case .available: + if case .starting = status { status = .upToDate(state.lastFetched) } + case .noAccount: status = .unavailable("Sign in to iCloud in System Settings to sync.") + case .restricted: status = .unavailable("iCloud is restricted on this device.") + case .temporarilyUnavailable: status = .unavailable("iCloud is temporarily unavailable.") + case .couldNotDetermine: status = .unavailable("Could not reach iCloud.") + @unknown default: status = .unavailable("iCloud is unavailable.") + } + } catch { + status = .unavailable(error.localizedDescription) + } + } +} + +extension CloudKitLibrarySync: CKSyncEngineDelegate { + + public nonisolated func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async { + switch event { + case .stateUpdate(let update): + let serialization = update.stateSerialization + await MainActor.run { keep(serialization) } + case .accountChange(let change): + if case .signOut = change.changeType { + await MainActor.run { status = .unavailable("Signed out of iCloud.") } + } + case .fetchedRecordZoneChanges(let changes): + let records = changes.modifications.map(\.record) + await MainActor.run { take(records) } + case .sentRecordZoneChanges(let sent): + let saved = sent.savedRecords + let conflicts = sent.failedRecordSaves.compactMap { failure -> CKRecord? in + if case .serverRecordChanged = failure.error.code { return failure.error.serverRecord } + return nil + } + await MainActor.run { + for record in saved { + let name = record.recordID.recordName + switch record.recordType { + case CloudRecords.eventType: state.pushedEventIDs.insert(name) + case CloudRecords.entryType: + state.pushedEntryEdits[name] = record["edited"] as? Date + state.systemFields[name] = CloudRecords.systemFields(of: record) + case CloudRecords.profileType: + state.profilePushed = true + state.systemFields[name] = CloudRecords.systemFields(of: record) + default: break + } + } + // The server had a newer copy: take it, merge, and send ours again. + if !conflicts.isEmpty { + take(conflicts) + queueChanges() + } + persist() + } + case .willFetchChanges, .willSendChanges: + await MainActor.run { status = .syncing } + case .didFetchChanges, .didSendChanges: + await MainActor.run { status = .upToDate(state.lastFetched) } + case .fetchedDatabaseChanges, .sentDatabaseChanges, .willFetchRecordZoneChanges, + .didFetchRecordZoneChanges: + break + @unknown default: + break + } + } + + public nonisolated func nextRecordZoneChangeBatch( + _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine + ) async -> CKSyncEngine.RecordZoneChangeBatch? { + let scope = context.options.scope + let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) } + return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in + await MainActor.run { self.record(for: recordID) } + } + } +} diff --git a/packages/TimbreCloud/Sources/TimbreCloud/CloudRecords.swift b/packages/TimbreCloud/Sources/TimbreCloud/CloudRecords.swift new file mode 100644 index 0000000..3c8bd27 --- /dev/null +++ b/packages/TimbreCloud/Sources/TimbreCloud/CloudRecords.swift @@ -0,0 +1,126 @@ +import CloudKit +import Foundation +import TimbreCore + +/// The Library as CloudKit records (ADR-0017). Events are immutable +/// records named by their id — only ever added, never changed, so two +/// devices merge by union. Entries are records named by their id and +/// replaced whole on edit; the later `edited` wins. The profile is one +/// record. Every field is additive from the first release: a field is +/// never renamed, retyped or removed. +nonisolated public enum CloudRecords { + + public static let zoneName = "Library" + public static let eventType = "Event" + public static let entryType = "Entry" + public static let profileType = "Profile" + public static let profileName = "profile" + + public static var zoneID: CKRecordZone.ID { + CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) + } + + // MARK: Events + + public static func record(for event: LibraryEvent) -> CKRecord { + let record = CKRecord( + recordType: eventType, recordID: CKRecord.ID(recordName: event.id, zoneID: zoneID)) + record["entry"] = event.entry + record["kind"] = event.kind.rawValue + record["date"] = event.date + record["device"] = event.device.rawValue + record["app"] = event.app + record["drill"] = event.drill?.rawValue + return record + } + + public static func event(from record: CKRecord) -> LibraryEvent? { + guard record.recordType == eventType, + let entry = record["entry"] as? String, + let kindRaw = record["kind"] as? String, let kind = LibraryEvent.Kind(rawValue: kindRaw), + let date = record["date"] as? Date, + let deviceRaw = record["device"] as? String, let device = Device(rawValue: deviceRaw) + else { return nil } + return LibraryEvent( + entry: entry, kind: kind, date: date, device: device, app: record["app"] as? String, + drill: (record["drill"] as? String).flatMap(Drill.init(rawValue:))) + } + + // MARK: Entries + + /// Fills `record` — a fresh one, or the server's copy so its change tag + /// is kept — with the entry. + public static func fill(_ record: CKRecord, with entry: LibraryEntry) -> CKRecord { + record["kind"] = entry.kind.rawValue + record["term"] = entry.term + record["heard"] = entry.heard + record["meaning"] = entry.meaning + record["example"] = entry.example + record["tags"] = entry.tags + record["sourceDevice"] = entry.source.device.rawValue + record["sourceDate"] = entry.source.date + record["sourceHow"] = entry.source.how.rawValue + record["sourceApp"] = entry.source.app + record["edited"] = entry.edited + return record + } + + public static func record(for entry: LibraryEntry) -> CKRecord { + fill( + CKRecord(recordType: entryType, recordID: CKRecord.ID(recordName: entry.id, zoneID: zoneID)), + with: entry) + } + + public static func entry(from record: CKRecord) -> LibraryEntry? { + guard record.recordType == entryType, + let kindRaw = record["kind"] as? String, let kind = EntryKind(rawValue: kindRaw), + let term = record["term"] as? String, + let deviceRaw = record["sourceDevice"] as? String, let device = Device(rawValue: deviceRaw), + let sourceDate = record["sourceDate"] as? Date, + let howRaw = record["sourceHow"] as? String, let how = Provenance.How(rawValue: howRaw) + else { return nil } + let source = Provenance( + device: device, date: sourceDate, how: how, app: record["sourceApp"] as? String) + return LibraryEntry( + kind: kind, term: term, heard: record["heard"] as? String, meaning: record["meaning"] as? String, + example: record["example"] as? String, tags: record["tags"] as? [String] ?? [], source: source, + edited: record["edited"] as? Date ?? sourceDate) + } + + // MARK: Profile + + public static func fill(_ record: CKRecord, with profile: Profile) -> CKRecord { + record["industries"] = profile.industries + record["role"] = profile.role + record["firstLanguage"] = profile.firstLanguage + return record + } + + public static func record(for profile: Profile) -> CKRecord { + fill( + CKRecord(recordType: profileType, recordID: CKRecord.ID(recordName: profileName, zoneID: zoneID)), + with: profile) + } + + public static func profile(from record: CKRecord) -> Profile? { + guard record.recordType == profileType else { return nil } + return Profile( + industries: record["industries"] as? [String] ?? [], role: record["role"] as? String, + firstLanguage: record["firstLanguage"] as? String) + } + + /// The server's system fields, kept so an edit saves over the server's + /// copy rather than fighting it. + public static func systemFields(of record: CKRecord) -> Data { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + record.encodeSystemFields(with: archiver) + archiver.finishEncoding() + return archiver.encodedData + } + + public static func record(fromSystemFields data: Data) -> CKRecord? { + guard let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } + unarchiver.requiresSecureCoding = true + return CKRecord(coder: unarchiver) + } +} diff --git a/packages/TimbreCloud/Sources/TimbreCloud/SyncState.swift b/packages/TimbreCloud/Sources/TimbreCloud/SyncState.swift new file mode 100644 index 0000000..8ba0cf4 --- /dev/null +++ b/packages/TimbreCloud/Sources/TimbreCloud/SyncState.swift @@ -0,0 +1,53 @@ +import Foundation + +/// What the sync remembers between launches (ADR-0017): the engine's own +/// state, which events and entry edits the server has, the server's copy +/// of each entry's system fields, and when the last fetch finished. One +/// file beside the Library; nothing in it is a word. +nonisolated public struct SyncState: Codable, Equatable, Sendable { + + /// `CKSyncEngine.State.Serialization`, as data. + public var engine: Data? + public var pushedEventIDs: Set + /// Entry id → the `edited` date the server holds. + public var pushedEntryEdits: [String: Date] + /// Entry id or the profile → the server record's system fields. + public var systemFields: [String: Data] + public var profilePushed: Bool + public var lastFetched: Date? + + public init( + engine: Data? = nil, pushedEventIDs: Set = [], pushedEntryEdits: [String: Date] = [:], + systemFields: [String: Data] = [:], profilePushed: Bool = false, lastFetched: Date? = nil + ) { + self.engine = engine + self.pushedEventIDs = pushedEventIDs + self.pushedEntryEdits = pushedEntryEdits + self.systemFields = systemFields + self.profilePushed = profilePushed + self.lastFetched = lastFetched + } + + public static func load(from url: URL) -> SyncState { + guard let data = try? Data(contentsOf: url) else { return SyncState() } + return (try? decoder.decode(SyncState.self, from: data)) ?? SyncState() + } + + public func save(to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Self.encoder.encode(self).write(to: url, options: .atomic) + } + + private static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/packages/TimbreCloud/Tests/TimbreCloudTests/CloudRecordsTests.swift b/packages/TimbreCloud/Tests/TimbreCloudTests/CloudRecordsTests.swift new file mode 100644 index 0000000..528b217 --- /dev/null +++ b/packages/TimbreCloud/Tests/TimbreCloudTests/CloudRecordsTests.swift @@ -0,0 +1,72 @@ +import CloudKit +import Foundation +import Testing +import TimbreCore + +@testable import TimbreCloud + +struct CloudRecordsTests { + + private let source = Provenance( + device: .iPhone, date: Date(timeIntervalSince1970: 1_700_000_000), how: .dictated, app: "Mail") + + @Test func anEventRoundTrips() { + let event = LibraryEvent( + entry: "word:ephemeral", kind: .passed, date: Date(timeIntervalSince1970: 1_700_000_100), + device: .watch, + app: nil, drill: .spellIt) + let record = CloudRecords.record(for: event) + #expect(record.recordID.recordName == event.id) + #expect(record.recordID.zoneID.zoneName == "Library") + #expect(CloudRecords.event(from: record) == event) + } + + @Test func anEntryRoundTripsWithEveryField() { + var entry = LibraryEntry( + kind: .acronym, term: "ADR", meaning: "Architecture Decision Record", example: "We wrote an ADR.", + tags: ["software"], source: source)! + entry.edited = Date(timeIntervalSince1970: 1_700_000_500) + let back = CloudRecords.entry(from: CloudRecords.record(for: entry)) + #expect(back == entry) + #expect(back?.source.app == "Mail") + } + + @Test func theProfileRoundTrips() { + let profile = Profile( + industries: ["software", "product"], role: "engineer", firstLanguage: "English") + #expect(CloudRecords.profile(from: CloudRecords.record(for: profile)) == profile) + #expect(CloudRecords.profile(from: CloudRecords.record(for: .empty)) == .empty) + } + + @Test func aRecordOfAnotherTypeIsRefused() { + let stray = CKRecord( + recordType: "Something", recordID: CKRecord.ID(recordName: "x", zoneID: CloudRecords.zoneID)) + #expect(CloudRecords.event(from: stray) == nil) + #expect(CloudRecords.entry(from: stray) == nil) + #expect(CloudRecords.profile(from: stray) == nil) + } + + @Test func systemFieldsRoundTrip() { + let record = CloudRecords.record(for: Profile(industries: ["law"])) + let data = CloudRecords.systemFields(of: record) + let back = CloudRecords.record(fromSystemFields: data) + #expect(back?.recordID == record.recordID) + #expect(back?.recordType == CloudRecords.profileType) + // System fields only: the values were not carried. + #expect(back?["industries"] == nil) + } + + @Test func syncStatePersists() throws { + let directory = URL(filePath: NSTemporaryDirectory()).appending( + path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "library-sync.json") + var state = SyncState() + state.pushedEventIDs = ["taught|word:a|1|mac||"] + state.pushedEntryEdits = ["word:a": Date(timeIntervalSince1970: 1_700_000_000)] + state.profilePushed = true + try state.save(to: url) + #expect(SyncState.load(from: url) == state) + #expect(SyncState.load(from: directory.appending(path: "missing.json")) == SyncState()) + } +} diff --git a/packages/TimbreCore/Sources/TimbreCore/Library/Library.swift b/packages/TimbreCore/Sources/TimbreCore/Library/Library.swift index 8ec0dc5..d66c003 100644 --- a/packages/TimbreCore/Sources/TimbreCore/Library/Library.swift +++ b/packages/TimbreCore/Sources/TimbreCore/Library/Library.swift @@ -259,9 +259,13 @@ public final class Library { } /// Called after every successful write, so a surface can follow the - /// Library without polling — the word of the day reschedules its - /// notification here. - public var didSave: (@MainActor () -> Void)? + /// Library without polling: the word of the day rebuilds its card, the + /// sync queues what changed. Several may listen. + public func addSaveObserver(_ observer: @escaping @MainActor () -> Void) { + saveObservers.append(observer) + } + + private var saveObservers: [@MainActor () -> Void] = [] // MARK: - Persistence @@ -279,7 +283,7 @@ public final class Library { at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: fileURL, options: .atomic) lastSaveError = nil - didSave?() + for observer in saveObservers { observer() } } catch { lastSaveError = error } diff --git a/packages/TimbreCore/Tests/TimbreCoreTests/LibraryTests.swift b/packages/TimbreCore/Tests/TimbreCoreTests/LibraryTests.swift index a8f2d21..534cf40 100644 --- a/packages/TimbreCore/Tests/TimbreCoreTests/LibraryTests.swift +++ b/packages/TimbreCore/Tests/TimbreCoreTests/LibraryTests.swift @@ -263,7 +263,7 @@ struct LibraryTests { defer { cleanup() } let library = Library(fileURL: file, legacy: defaults) var saves = 0 - library.didSave = { saves += 1 } + library.addSaveObserver { saves += 1 } library.add("one") library.remove("one") #expect(saves == 2) diff --git a/packages/TimbreKit/Package.swift b/packages/TimbreKit/Package.swift index 420f3c5..e9a44ec 100644 --- a/packages/TimbreKit/Package.swift +++ b/packages/TimbreKit/Package.swift @@ -24,11 +24,12 @@ let package = Package( .package(path: "../TimbreSpeech"), .package(path: "../TimbreModel"), .package(path: "../TimbreReminders"), + .package(path: "../TimbreCloud"), ], targets: [ .target( name: "TimbreKit", - dependencies: ["TimbreCore", "TimbreSpeech", "TimbreModel", "TimbreReminders"], + dependencies: ["TimbreCore", "TimbreSpeech", "TimbreModel", "TimbreReminders", "TimbreCloud"], swiftSettings: concurrencySettings ), .executableTarget( diff --git a/packages/TimbreKit/Sources/TimbreKit/DictationController.swift b/packages/TimbreKit/Sources/TimbreKit/DictationController.swift index e33f509..85cf858 100644 --- a/packages/TimbreKit/Sources/TimbreKit/DictationController.swift +++ b/packages/TimbreKit/Sources/TimbreKit/DictationController.swift @@ -1,6 +1,7 @@ import AVFoundation import Foundation import Observation +import TimbreCloud import TimbreCore import TimbreReminders import TimbreSpeech @@ -147,6 +148,21 @@ public final class DictationController { /// their own spoken input, so the hotkey pipeline is never borrowed. @ObservationIgnored public private(set) lazy var practice = PracticeSession( library: library, reader: speech) + + /// The Library through the user's own iCloud (GDR-0023 decision 1, ADR-0017). + /// Off until turned on; the binary still makes no request. + @ObservationIgnored public private(set) lazy var cloud = CloudKitLibrarySync( + library: library, + stateURL: Library.defaultFileURL.deletingLastPathComponent().appending(path: "library-sync.json")) + + /// The toggle. Stopping keeps what is on this Mac. + public var syncsThroughICloud: Bool { + get { UserDefaults.standard.bool(forKey: "librarySyncsThroughICloud") } + set { + UserDefaults.standard.set(newValue, forKey: "librarySyncsThroughICloud") + if newValue { cloud.start() } else { cloud.stop() } + } + } private let dictationLog = DictationLog() private let speech = SpeechReader() private let transformer = TextTransformer() @@ -197,8 +213,9 @@ public final class DictationController { public func bootstrap() async { // The word of the day follows the Library: every write rebuilds the // card and the pending notification. - library.didSave = { [weak self] in self?.wordOfTheDay.refresh() } + library.addSaveObserver { [weak self] in self?.wordOfTheDay.refresh() } wordOfTheDay.refresh() + if syncsThroughICloud { cloud.start() } // 1. Permissions. Microphone first and unconditionally: it is the one // macOS grants in place, and gating it behind Accessibility meant diff --git a/web/src/lib/decisions/records.json b/web/src/lib/decisions/records.json index babc649..9096471 100644 --- a/web/src/lib/decisions/records.json +++ b/web/src/lib/decisions/records.json @@ -191,6 +191,36 @@ "plain": "Accepted (implementation pending).\nContext.\nGDR-0023 made the iPhone a companion: the Library in your pocket, practice,\nthe word of the day. It said nothing about the wrist, and the wrist is where\ntwo of Timbre's gestures want to be. The to-do key (GDR-0016) — hold, say\nit, it is in Reminders — is a thing you do while walking, not at a desk. And\na word you have just heard is banked best in the three seconds before it is\nforgotten, which is a raise of the wrist, not a trip to a Mac.\nThe platform was checked, not assumed. In the watchOS 27 SDK on the\ndevelopment Mac: EventKit, WatchConnectivity, AppIntents, WidgetKit and\nAVFAudio are present. Speech — and with it SpeechAnalyzer — is not.\nFoundationModels ships in the SDK, but whether the model is eligible on\nWatch hardware is unknown until it runs on one. On iPhone the on-device\nmodel needs Apple Intelligence (iPhone 15 Pro and later); an older iPhone\nhas none. Hugo will test on all four: an iPhone with the model, one without,\nan Apple Watch Ultra (Action Button) and a Series 9 or later (Double Tap, no\nAction Button). Each of those is therefore a real user, not a hypothetical.\nDecision.\nTimbre ships on Apple Watch as part of the companion, and nothing on the\nwrist needs the model.\nOn the wrist:\nThe to-do key. The Action Button on an Ultra, Double Tap on a Series 9\n or later, or the app's own button: say the to-do, and it lands in the\n Timbre list in Reminders with the date read by the same deterministic\n detector as on the Mac (GDR-0016). A haptic confirms. A tap reads today's\n list back in the chosen voice. This works with no phone in range and no\n model: the detector is Foundation and the store is EventKit, both on the\n Watch.\nThe word of the day at a glance. A complication and a Smart Stack\n card: the word, a tap for the meaning, and the day's state — met,\n practised, or waiting (GDR-0027).\nSpell it (GDR-0023, decision 5): the meaning shown, the word said and\n spelled, checked deterministically from the system dictation's text.\nHeard a word. Raise the wrist, say it, and it is in the Library with\n provenance: the Watch, the day, the time. The meaning fills in on the\n phone or the Mac.\nThe rules that make the degraded cases designed rather than discovered:\nDeterministic on the wrist, the model elsewhere. The Watch's speech\n input is the system's dictation, which returns text; SpeechAnalyzer is\n not on the platform, so there is no streaming, no live result and no\n vocabulary bias there. Anything that needs the model — Say it, an example\n sentence, a verdict — is done on the phone or the Mac and arrives by sync.\n The Watch says waiting for your phone rather than pretending.\nAn iPhone without Apple Intelligence shows the Library, the word of\n the day and Spell it. Say it is unavailable there, labelled as such, and\n model-written lines arrive from a device that has the model.\nZero network requests from the Watch binary, as from every other.\n Watch to phone is WatchConnectivity; the Library syncs by iCloud\n (GDR-0023, decision 1). Both are Apple's transports between the user's own\n devices.\nNo dictation into other apps and no command mode, on either device.\n GDR-0002's reasons stand; GDR-0023 said so for the phone and this record\n says so for the wrist.\nConsequences.\nGDR-0002's index entry now reads: \"no iOS app\" superseded by 0023 and\n 0026; dictation stays macOS-only. The record itself stays as written.\nThe Watch app is a target of the companion's project, importing\n TimbreCore alone (GDR-0023, decision 4). The to-do parser moves to Core\n with the Library, because the wrist runs it without TimbreKit.\nDistribution for iPhone and Watch is the App Store — review, a privacy\n label that says no data collected, TestFlight — while the Mac stays\n Developer ID and direct (ADR-0010, GDR-0013). Two paths, one team, one\n claim.\nMemory, battery and whether FoundationModels answers on a Watch are\n measured on hardware before any copy promises them. If the model does run\n there, the rule above still stands until a drill's corpus proves it should\n not.\nThe phases (product map §7) gain a step after the iPhone: the Watch, with\n the to-do key first, because it needs nothing new.\nAlternatives considered.\nThe Watch as a remote microphone for the Mac: rejected; the Mac is not\n where the user is when the wrist is the right surface, and audio between\n devices is a stream to maintain for a gesture that needs only text.\nSkip the Watch; the phone is enough: rejected; the to-do key's value is\n capture at the moment of the thought (GDR-0016), and the moment is on the\n wrist.\nRun the model on the Watch where available: deferred; not until it is\n seen to answer on hardware, and never for the to-do key, which must work in\n a tunnel.", "html": "

Context

\n

GDR-0023 made the iPhone a companion: the Library in your pocket, practice,\nthe word of the day. It said nothing about the wrist, and the wrist is where\ntwo of Timbre's gestures want to be. The to-do key (GDR-0016) — hold, say\nit, it is in Reminders — is a thing you do while walking, not at a desk. And\na word you have just heard is banked best in the three seconds before it is\nforgotten, which is a raise of the wrist, not a trip to a Mac.

\n

The platform was checked, not assumed. In the watchOS 27 SDK on the\ndevelopment Mac: EventKit, WatchConnectivity, AppIntents, WidgetKit and\nAVFAudio are present. Speech — and with it SpeechAnalyzer — is not.\nFoundationModels ships in the SDK, but whether the model is eligible on\nWatch hardware is unknown until it runs on one. On iPhone the on-device\nmodel needs Apple Intelligence (iPhone 15 Pro and later); an older iPhone\nhas none. Hugo will test on all four: an iPhone with the model, one without,\nan Apple Watch Ultra (Action Button) and a Series 9 or later (Double Tap, no\nAction Button). Each of those is therefore a real user, not a hypothetical.

\n

Decision

\n

Timbre ships on Apple Watch as part of the companion, and nothing on the\nwrist needs the model.

\n

On the wrist:

\n
    \n
  • The to-do key. The Action Button on an Ultra, Double Tap on a Series 9\nor later, or the app's own button: say the to-do, and it lands in the\nTimbre list in Reminders with the date read by the same deterministic\ndetector as on the Mac (GDR-0016). A haptic confirms. A tap reads today's\nlist back in the chosen voice. This works with no phone in range and no\nmodel: the detector is Foundation and the store is EventKit, both on the\nWatch.
  • \n
  • The word of the day at a glance. A complication and a Smart Stack\ncard: the word, a tap for the meaning, and the day's state — met,\npractised, or waiting (GDR-0027).
  • \n
  • Spell it (GDR-0023, decision 5): the meaning shown, the word said and\nspelled, checked deterministically from the system dictation's text.
  • \n
  • Heard a word. Raise the wrist, say it, and it is in the Library with\nprovenance: the Watch, the day, the time. The meaning fills in on the\nphone or the Mac.
  • \n
\n

The rules that make the degraded cases designed rather than discovered:

\n
    \n
  • Deterministic on the wrist, the model elsewhere. The Watch's speech\ninput is the system's dictation, which returns text; SpeechAnalyzer is\nnot on the platform, so there is no streaming, no live result and no\nvocabulary bias there. Anything that needs the model — Say it, an example\nsentence, a verdict — is done on the phone or the Mac and arrives by sync.\nThe Watch says waiting for your phone rather than pretending.
  • \n
  • An iPhone without Apple Intelligence shows the Library, the word of\nthe day and Spell it. Say it is unavailable there, labelled as such, and\nmodel-written lines arrive from a device that has the model.
  • \n
  • Zero network requests from the Watch binary, as from every other.\nWatch to phone is WatchConnectivity; the Library syncs by iCloud\n(GDR-0023, decision 1). Both are Apple's transports between the user's own\ndevices.
  • \n
  • No dictation into other apps and no command mode, on either device.\nGDR-0002's reasons stand; GDR-0023 said so for the phone and this record\nsays so for the wrist.
  • \n
\n

Consequences

\n
    \n
  • GDR-0002's index entry now reads: "no iOS app" superseded by 0023 and\n0026; dictation stays macOS-only. The record itself stays as written.
  • \n
  • The Watch app is a target of the companion's project, importing\nTimbreCore alone (GDR-0023, decision 4). The to-do parser moves to Core\nwith the Library, because the wrist runs it without TimbreKit.
  • \n
  • Distribution for iPhone and Watch is the App Store — review, a privacy\nlabel that says no data collected, TestFlight — while the Mac stays\nDeveloper ID and direct (ADR-0010, GDR-0013). Two paths, one team, one\nclaim.
  • \n
  • Memory, battery and whether FoundationModels answers on a Watch are\nmeasured on hardware before any copy promises them. If the model does run\nthere, the rule above still stands until a drill's corpus proves it should\nnot.
  • \n
  • The phases (product map §7) gain a step after the iPhone: the Watch, with\nthe to-do key first, because it needs nothing new.
  • \n
\n

Alternatives considered

\n
    \n
  • The Watch as a remote microphone for the Mac: rejected; the Mac is not\nwhere the user is when the wrist is the right surface, and audio between\ndevices is a stream to maintain for a gesture that needs only text.
  • \n
  • Skip the Watch; the phone is enough: rejected; the to-do key's value is\ncapture at the moment of the thought (GDR-0016), and the moment is on the\nwrist.
  • \n
  • Run the model on the Watch where available: deferred; not until it is\nseen to answer on hardware, and never for the to-do key, which must work in\na tunnel.
  • \n
\n" }, + { + "id": "ADR-0017", + "kind": "ADR", + "number": "0017", + "slug": "the-librarys-cloudkit-schema", + "path": "docs/decisions/adr/0017-the-librarys-cloudkit-schema.md", + "title": "The Library's CloudKit schema: events as immutable records, entries by latest edit, one zone", + "status": "Accepted", + "statusKind": "accepted", + "statusNote": null, + "supersededBy": null, + "supersedes": [], + "date": "2026-10-18", + "relations": [ + { + "label": "Builds on", + "summary": "GDR-0023 (decision 1: the Library syncs through iCloud, behind a sync…", + "refs": [ + "GDR-0023", + "GDR-0010", + "ADR-0013", + "ADR-0014", + "ADR-0015" + ] + } + ], + "excerpt": "GDR-0023 chose CloudKit's private database and left the shape to a record\nwritten with the code. A CloudKit schema is forever: once deployed to\nproduction, a record type or field can be added but never renamed, retyped\nor removed, and every build a user ever ran must still read what a newer\none writes. The Library's own design (ADR-0014) points the way:…", + "plain": "Accepted.\nContext.\nGDR-0023 chose CloudKit's private database and left the shape to a record\nwritten with the code. A CloudKit schema is forever: once deployed to\nproduction, a record type or field can be added but never renamed, retyped\nor removed, and every build a user ever ran must still read what a newer\none writes. The Library's own design (ADR-0014) points the way: events are\nimmutable and only ever added; strength is never stored; entries are\nedited rarely; the profile is small.\nDecision.\nOne custom zone, three record types, every field additive, and the\nLibrary's merge rule doing the merging — never CloudKit's.\n Code sample omitted. \nCKSyncEngine carries the records; TimbreCloud is the package\n (CloudKit on all three platforms). It keeps a small state file beside the\n Library — the engine's serialization, which events and entry edits the\n server has, the server's system fields for each entry — and nothing in it\n is a word.\nMerge is a union of events, then entries by latest edit — the\n Library's merge(entries:events:), unchanged. A server-changed conflict\n on an entry is resolved by taking the server's record, merging, and\n sending ours again over the server's system fields.\nDeletions are events. No record is ever deleted; a forgot event is\n the tombstone, and the merge honours it on every device.\nThe profile is taken from the server only when this device's is\n empty; otherwise this device's is sent. Two profiles edited apart is a\n case we do not pretend to solve.\nOff by default; a toggle per device; stopping keeps what is local.\n The container is iCloud.dev.hugopretorius.Timbre, shared by the Mac,\n the iPhone and the Watch regardless of bundle identifier.\nConsequences.\nThe mapping to and from CKRecord is pure and tested offline; the engine\n is not, and the two-device proof the product map asks for — teach on one,\n see it on the other, nettop empty — is a manual test on hardware.\nAdding a field is a change to a record type and a default on read; a\n rename is forbidden and the code says so where the fields are named.\nThe iCloud entitlements join all three apps. A build without a team runs\n with the toggle off; with a team, Xcode's automatic signing registers the\n container the first time.\nStrength is still never stored, here or anywhere: a device that fetches\n a year of events recomputes it.\nAlternatives considered.\nSwiftData with CloudKit mirroring: rejected (ADR-0013); it cannot\n express a union of histories.\nOne record per entry holding its history as a list: rejected; two\n devices appending to one list is the conflict the event design avoids.\nThe key-value store, as GDR-0010 first chose: rejected; too small,\n whole-value replacement, no per-record merge.", + "html": "

Context

\n

GDR-0023 chose CloudKit's private database and left the shape to a record\nwritten with the code. A CloudKit schema is forever: once deployed to\nproduction, a record type or field can be added but never renamed, retyped\nor removed, and every build a user ever ran must still read what a newer\none writes. The Library's own design (ADR-0014) points the way: events are\nimmutable and only ever added; strength is never stored; entries are\nedited rarely; the profile is small.

\n

Decision

\n

One custom zone, three record types, every field additive, and the\nLibrary's merge rule doing the merging — never CloudKit's.

\n
Zone "Library" in the private database (custom, so changes can be fetched\nby token; the default zone cannot).\n\nEvent    recordName = the event's composed id (ADR-0014)\n         entry, kind, date, device, app?, drill?\n         Immutable: saved once, never modified, never deleted.\nEntry    recordName = the entry's id\n         kind, term, heard?, meaning?, example?, tags, sourceDevice,\n         sourceDate, sourceHow, sourceApp?, edited\n         Replaced whole on edit; the later `edited` wins.\nProfile  recordName = "profile"\n         industries, role?, firstLanguage?\n
\n
    \n
  • CKSyncEngine carries the records; TimbreCloud is the package\n(CloudKit on all three platforms). It keeps a small state file beside the\nLibrary — the engine's serialization, which events and entry edits the\nserver has, the server's system fields for each entry — and nothing in it\nis a word.
  • \n
  • Merge is a union of events, then entries by latest edit — the\nLibrary's merge(entries:events:), unchanged. A server-changed conflict\non an entry is resolved by taking the server's record, merging, and\nsending ours again over the server's system fields.
  • \n
  • Deletions are events. No record is ever deleted; a forgot event is\nthe tombstone, and the merge honours it on every device.
  • \n
  • The profile is taken from the server only when this device's is\nempty; otherwise this device's is sent. Two profiles edited apart is a\ncase we do not pretend to solve.
  • \n
  • Off by default; a toggle per device; stopping keeps what is local.\nThe container is iCloud.dev.hugopretorius.Timbre, shared by the Mac,\nthe iPhone and the Watch regardless of bundle identifier.
  • \n
\n

Consequences

\n
    \n
  • The mapping to and from CKRecord is pure and tested offline; the engine\nis not, and the two-device proof the product map asks for — teach on one,\nsee it on the other, nettop empty — is a manual test on hardware.
  • \n
  • Adding a field is a change to a record type and a default on read; a\nrename is forbidden and the code says so where the fields are named.
  • \n
  • The iCloud entitlements join all three apps. A build without a team runs\nwith the toggle off; with a team, Xcode's automatic signing registers the\ncontainer the first time.
  • \n
  • Strength is still never stored, here or anywhere: a device that fetches\na year of events recomputes it.
  • \n
\n

Alternatives considered

\n
    \n
  • SwiftData with CloudKit mirroring: rejected (ADR-0013); it cannot\nexpress a union of histories.
  • \n
  • One record per entry holding its history as a list: rejected; two\ndevices appending to one list is the conflict the event design avoids.
  • \n
  • The key-value store, as GDR-0010 first chose: rejected; too small,\nwhole-value replacement, no per-record merge.
  • \n
\n" + }, { "id": "ADR-0016", "kind": "ADR", diff --git a/web/src/lib/design/design.json b/web/src/lib/design/design.json index 526ea10..b6b7f96 100644 --- a/web/src/lib/design/design.json +++ b/web/src/lib/design/design.json @@ -11,6 +11,6 @@ "path": "docs/design/product-map.md", "title": "Timbre, the whole product", "status": "2026-10-11 · Status: the map; its five decisions were taken on 2026-10-14 and are recorded in GDR-0023; GDR-0026 to GDR-0028 (2026-10-18) extend it to the Watch, the word of the day's bar and the bundled words. Constraining records: GDR-0001, GDR-0006, GDR-0010, GDR-0013. This document is where the product is planned as one thing; each piece that lands still gets its own record.", - "html": "

2026-10-11 · Status: the map; its five decisions were taken on 2026-10-14\nand are recorded in GDR-0023; GDR-0026 to GDR-0028 (2026-10-18) extend it\nto the Watch, the word of the day's bar and the bundled words. Constraining\nrecords: GDR-0001, GDR-0006, GDR-0010, GDR-0013. This document is where the product is planned as one\nthing; each piece that lands still gets its own record.

\n

Timbre started as a key you hold to talk. It is about to become the place\nwhere the words you use get better: spelling, vocabulary, phrases,\nacronyms. This map lays out every piece, which surface owns it, where its\ndata lives, and what crosses the one boundary the product is built on —\nso nothing is built twice and nothing falls between two surfaces.

\n

1. Three pillars

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PillarWhat it isToday
SpeakDictate, fix/shorten/plain/explain, read aloud, say a to-do. Everything on-device, nothing leaves the Mac.Shipped (0.3.x, 0.4.0 forming)
LearnThe Library — words, acronyms, phrases, spellings you are collecting — plus a profile that makes "explain" right for your industry, a word of the day, and practice that uses your own voice.Vocabulary, corrections and definitions exist as three Settings lists. Nothing else yet.
RunBackstage: releases, decisions, plans, evals, launch, architecture, brand. The business, in one place.Shipped
\n

Speak is the wedge and stays the wedge: dictation that never leaves your\nMac. Learn is the reason to open the app when you are not dictating, and\nthe reason to want it on your phone. Run is ours.

\n

2. Surfaces

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SurfaceOwnsDoes not do
Mac appEverything in Speak. The Library's capture side: a word you taught, a correction, a definition, a word you had explained — each lands in the Library. The Mac is where words are met.Nothing cloud-side. Zero network requests (GDR-0001/0013).
iPhone app (planned)The Library in your pocket: review, practise, word of the day, add a word you heard. The phone is where words are kept.No dictation into other apps (iOS cannot), no commands. It is a companion, not a port.
Apple Watch app (planned, GDR-0026)The to-do key on the wrist; the word of the day on the face; Spell it; a word you heard. Deterministic, phone-optional.Anything that needs the model: that is the phone's or the Mac's, and arrives by sync.
WebsiteThe claim, the download, the network list.Accounts, sync, anything personal.
BackstageRunning the product.Anything a user sees.
Cross-platform client (open)A Library on Android or the web, if ever.Only exists if there is an account to sign into — see §4.
\n

3. The Library

\n

One store replaces three lists. Every entry has a kind, and the kinds\nare exactly the four things Hugo wants to get better at, plus the one the\napp already keeps:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
KindExampleWhere it comes fromWhat uses it
wordephemeral — short-livedexplain on a word; added by hand; word of the daythe polisher's vocabulary; practice
acronymADR — Architecture Decision Recorddefine in Settings; explain's answer, keptexplain answers from it first (GDR-0012)
phrasea canary in the coal mine — an early warningexplain on a selection; added by handpractice; the polisher leaves it intact
spellingdefinately → definitelyfix corrected it; the spell checker fallbackpractice ("spell it"); a correction if it keeps happening
correctiontimber kit → TimbreKittaught in Settings; one day from your own editsCorrectionTable, before the model (GDR-0011)
\n

An entry carries: the term, its meaning, an example sentence, where it\ncame from (which app, dictated or typed, on which day), tags including the\nindustry it belongs to, a strength that practice raises and time lowers\n(the spaced-repetition number), and its history. Nothing in an entry is\never transmitted by the Mac app; the entry is the user's.

\n

Profile. Industry (one or more), role, first language. Used in two\nplaces: explain's prompt ("the reader works in healthcare, so PA means\nphysician assistant before it means personal assistant"), and the word of\nthe day's choice of lists. Lives in the Library, syncs with it.

\n

4. The one boundary, and the sync decision

\n

The Mac binary makes no network requests. That is the claim on the\nheadline, falsifiable with a network monitor, and the entire competitive\nwedge (GDR-0001, GDR-0006, GDR-0013). The Library wants to be on the phone.\nThere are two ways to get it there, and they are not equal:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
A. iCloudB. A Timbre account
HowCloudKit, private database, the user's own iCloud. Apple's daemons do the syncing; the binary still makes zero requests. GDR-0010 already accepted this shape for the dictionary.Supabase (already running Backstage's auth), the apps call it.
ReachesMac and iPhone (and iPad, Watch).Anything with a browser.
NeedsThe iCloud capability on the App ID (done), a CloudKit container, an entitlement. No server, no cost, no account to create.Sign-up, sessions, a schema with row-level security, a privacy policy, GDPR handling, uptime.
Costs the claimNothing. /network stays at one entry.The headline needs a second asterisk: the app phones home when you sign in. Amends GDR-0013; /network gains entries.
When it is rightNow.When a non-Apple client is real and wanted — not before.
\n

Recommendation: A first, and design the Library's store so that B is a\nsecond backend behind the same interface, not a rewrite. "Apple pure" is\nnot a lite tier; it is the product. B becomes a decision the day there is\nan Android or web Library to sign into, and it gets its own record then.

\n

5. Learning, using what Timbre already does

\n

Practice should use Timbre's own abilities rather than becoming a flash-card\napp with a microphone bolted on:

\n
    \n
  • Spell it. The phone shows the meaning; you say the word, then spell it aloud. The on-device recogniser hears both.
  • \n
  • Say it. The phone shows a word or phrase; you use it in a sentence, out loud. The polisher's model judges whether it was used in its meaning — a guardrail-scored check, like every command (GDR-0012's rule for new verbs applies to new drills).
  • \n
  • Explain it. The card you saw on the Mac, now a question: what does ADR stand for?
  • \n
  • Word of the day. Chosen from the Library's weakest entries first, then from a bundled list for the profile's industry. Delivered as a local notification on Mac and iPhone. Saying it in a dictation that day marks it met. The bar it must clear is GDR-0027; where its facts come from is GDR-0028.
  • \n
  • Gamification, the private kind. Strength bars, streaks, a weekly review. No leaderboard, no sharing, nothing that needs a server.
  • \n
\n

6. No gaps, no duplication

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CapabilityOwnerStoreSync
Dictation, commands, read aloud, to-dosMac app——
Capturing a Library entry from useMac appLibrary (local)iCloud
Browsing, editing the LibraryMac Settings → Library; iPhoneLibraryiCloud
ProfileiPhone and Mac, same screenLibraryiCloud
Practice, word of the dayiPhone and Mac (a Practice window); the Watch for Spell itLibrary (strength, history)iCloud
Usage countMac appUserDefaultsnever (GDR: this Mac only)
Dictation capture for tuningMac appjsonl on disknever (GDR-0004)
Releases, decisions, plans, evals, launch, architecture, brandBackstagethe repoCI
\n

Shared logic lives in one place: the Library's model, parsing and practice\nscoring move into a platform-neutral TimbreCore package that the Mac app\nand the iPhone app both import; TimbreKit keeps the macOS-only pipeline.\nThat split is a record of its own (an ADR) before the first iOS line.

\n

7. Phases

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PhaseDeliversProof
00.4.0: icon, disk image, to-dos, plain, live typing. Launch.Public.
1The Library on Mac. One store with kinds; Settings › Dictionary becomes Library; explain, fix and define write to it; export to a file.Every existing list migrates; nothing lost.
2TimbreCore + iCloud sync. The split, the CloudKit container, sync on two Macs.Teach on one Mac, see it on the other, nettop still empty.
3Timbre for iPhone. The Library, profile, word of the day, Spell it.On the phone a minute after teaching on the Mac.
4Practice that listens. Say it, Explain it, strength and streaks.A drill corpus the harness can score.
5Profile-aware explain on the Mac.A commands corpus case per industry.
—Account and cross-platform.Decided when phase 3 has users asking.
\n

GDR-0026 adds the Watch after phase 3: the to-do key first, because it\nneeds nothing new; then the complication and Spell it.

\n

8. Decisions this map asked for — taken 2026-10-14 (GDR-0023)

\n
    \n
  1. Sync by iCloud first, account later or never (§4). Taken: iCloud first; the store built behind a sync interface.
  2. \n
  3. The iPhone app is a companion — the Library and practice — not a dictation app (§2). Taken: companion; no keyboard extension planned.
  4. \n
  5. One Library, five kinds, replacing the three Settings lists (§3). Taken: five kinds, migrated on the Mac first.
  6. \n
  7. TimbreCore split before the first iOS file (§6). Taken: created with the Library.
  8. \n
  9. Practice uses the voice — drills are scored like commands, with a corpus (§5). Taken: Spell it first.
  10. \n
\n" + "html": "

2026-10-11 · Status: the map; its five decisions were taken on 2026-10-14\nand are recorded in GDR-0023; GDR-0026 to GDR-0028 (2026-10-18) extend it\nto the Watch, the word of the day's bar and the bundled words. Constraining\nrecords: GDR-0001, GDR-0006, GDR-0010, GDR-0013. This document is where the product is planned as one\nthing; each piece that lands still gets its own record.

\n

Timbre started as a key you hold to talk. It is about to become the place\nwhere the words you use get better: spelling, vocabulary, phrases,\nacronyms. This map lays out every piece, which surface owns it, where its\ndata lives, and what crosses the one boundary the product is built on —\nso nothing is built twice and nothing falls between two surfaces.

\n

1. Three pillars

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PillarWhat it isToday
SpeakDictate, fix/shorten/plain/explain, read aloud, say a to-do. Everything on-device, nothing leaves the Mac.Shipped (0.3.x, 0.4.0 forming)
LearnThe Library — words, acronyms, phrases, spellings you are collecting — plus a profile that makes "explain" right for your industry, a word of the day, and practice that uses your own voice.Vocabulary, corrections and definitions exist as three Settings lists. Nothing else yet.
RunBackstage: releases, decisions, plans, evals, launch, architecture, brand. The business, in one place.Shipped
\n

Speak is the wedge and stays the wedge: dictation that never leaves your\nMac. Learn is the reason to open the app when you are not dictating, and\nthe reason to want it on your phone. Run is ours.

\n

2. Surfaces

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
SurfaceOwnsDoes not do
Mac appEverything in Speak. The Library's capture side: a word you taught, a correction, a definition, a word you had explained — each lands in the Library. The Mac is where words are met.Nothing cloud-side. Zero network requests (GDR-0001/0013).
iPhone app (planned)The Library in your pocket: review, practise, word of the day, add a word you heard. The phone is where words are kept.No dictation into other apps (iOS cannot), no commands. It is a companion, not a port.
Apple Watch app (planned, GDR-0026)The to-do key on the wrist; the word of the day on the face; Spell it; a word you heard. Deterministic, phone-optional.Anything that needs the model: that is the phone's or the Mac's, and arrives by sync.
WebsiteThe claim, the download, the network list.Accounts, sync, anything personal.
BackstageRunning the product.Anything a user sees.
Cross-platform client (open)A Library on Android or the web, if ever.Only exists if there is an account to sign into — see §4.
\n

3. The Library

\n

One store replaces three lists. Every entry has a kind, and the kinds\nare exactly the four things Hugo wants to get better at, plus the one the\napp already keeps:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
KindExampleWhere it comes fromWhat uses it
wordephemeral — short-livedexplain on a word; added by hand; word of the daythe polisher's vocabulary; practice
acronymADR — Architecture Decision Recorddefine in Settings; explain's answer, keptexplain answers from it first (GDR-0012)
phrasea canary in the coal mine — an early warningexplain on a selection; added by handpractice; the polisher leaves it intact
spellingdefinately → definitelyfix corrected it; the spell checker fallbackpractice ("spell it"); a correction if it keeps happening
correctiontimber kit → TimbreKittaught in Settings; one day from your own editsCorrectionTable, before the model (GDR-0011)
\n

An entry carries: the term, its meaning, an example sentence, where it\ncame from (which app, dictated or typed, on which day), tags including the\nindustry it belongs to, a strength that practice raises and time lowers\n(the spaced-repetition number), and its history. Nothing in an entry is\never transmitted by the Mac app; the entry is the user's.

\n

Profile. Industry (one or more), role, first language. Used in two\nplaces: explain's prompt ("the reader works in healthcare, so PA means\nphysician assistant before it means personal assistant"), and the word of\nthe day's choice of lists. Lives in the Library, syncs with it.

\n

4. The one boundary, and the sync decision

\n

The Mac binary makes no network requests. That is the claim on the\nheadline, falsifiable with a network monitor, and the entire competitive\nwedge (GDR-0001, GDR-0006, GDR-0013). The Library wants to be on the phone.\nThere are two ways to get it there, and they are not equal:

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
A. iCloudB. A Timbre account
HowCloudKit, private database, the user's own iCloud. Apple's daemons do the syncing; the binary still makes zero requests. GDR-0010 already accepted this shape for the dictionary.Supabase (already running Backstage's auth), the apps call it.
ReachesMac and iPhone (and iPad, Watch).Anything with a browser.
NeedsThe iCloud capability on the App ID (done), a CloudKit container, an entitlement. No server, no cost, no account to create.Sign-up, sessions, a schema with row-level security, a privacy policy, GDPR handling, uptime.
Costs the claimNothing. /network stays at one entry.The headline needs a second asterisk: the app phones home when you sign in. Amends GDR-0013; /network gains entries.
When it is rightNow.When a non-Apple client is real and wanted — not before.
\n

Recommendation: A first, and design the Library's store so that B is a\nsecond backend behind the same interface, not a rewrite. "Apple pure" is\nnot a lite tier; it is the product. B becomes a decision the day there is\nan Android or web Library to sign into, and it gets its own record then.

\n

5. Learning, using what Timbre already does

\n

Practice should use Timbre's own abilities rather than becoming a flash-card\napp with a microphone bolted on:

\n
    \n
  • Spell it. The phone shows the meaning; you say the word, then spell it aloud. The on-device recogniser hears both.
  • \n
  • Say it. The phone shows a word or phrase; you use it in a sentence, out loud. The polisher's model judges whether it was used in its meaning — a guardrail-scored check, like every command (GDR-0012's rule for new verbs applies to new drills).
  • \n
  • Explain it. The card you saw on the Mac, now a question: what does ADR stand for?
  • \n
  • Word of the day. Chosen from the Library's weakest entries first, then from a bundled list for the profile's industry. Delivered as a local notification on Mac and iPhone. Saying it in a dictation that day marks it met. The bar it must clear is GDR-0027; where its facts come from is GDR-0028.
  • \n
  • Gamification, the private kind. Strength bars, streaks, a weekly review. No leaderboard, no sharing, nothing that needs a server.
  • \n
\n

6. No gaps, no duplication

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
CapabilityOwnerStoreSync
Dictation, commands, read aloud, to-dosMac app——
Capturing a Library entry from useMac appLibrary (local)iCloud
Browsing, editing the LibraryMac Settings → Library; iPhoneLibraryiCloud
ProfileiPhone and Mac, same screenLibraryiCloud
Practice, word of the dayiPhone and Mac (a Practice window); the Watch for Spell itLibrary (strength, history)iCloud
Usage countMac appUserDefaultsnever (GDR: this Mac only)
Dictation capture for tuningMac appjsonl on disknever (GDR-0004)
Releases, decisions, plans, evals, launch, architecture, brandBackstagethe repoCI
\n

Shared logic lives in one place: the Library's model, parsing and practice\nscoring move into a platform-neutral TimbreCore package that the Mac app\nand the iPhone app both import; TimbreKit keeps the macOS-only pipeline.\nThat split is a record of its own (an ADR) before the first iOS line.

\n

7. Phases

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
PhaseDeliversProof
00.4.0: icon, disk image, to-dos, plain, live typing. Launch.Public.
1The Library on Mac. One store with kinds; Settings › Dictionary becomes Library; explain, fix and define write to it; export to a file.Every existing list migrates; nothing lost.
2TimbreCore + iCloud sync. The split (ADR-0013, ADR-0015), the CloudKit container and schema (ADR-0017), sync on two Macs.Teach on one Mac, see it on the other, nettop still empty.
3Timbre for iPhone. The Library, profile, word of the day, Spell it.On the phone a minute after teaching on the Mac.
4Practice that listens. Say it, Explain it, strength and streaks.A drill corpus the harness can score.
5Profile-aware explain on the Mac.A commands corpus case per industry.
—Account and cross-platform.Decided when phase 3 has users asking.
\n

GDR-0026 adds the Watch after phase 3: the to-do key first, because it\nneeds nothing new; then the complication and Spell it.

\n

8. Decisions this map asked for — taken 2026-10-14 (GDR-0023)

\n
    \n
  1. Sync by iCloud first, account later or never (§4). Taken: iCloud first; the store built behind a sync interface.
  2. \n
  3. The iPhone app is a companion — the Library and practice — not a dictation app (§2). Taken: companion; no keyboard extension planned.
  4. \n
  5. One Library, five kinds, replacing the three Settings lists (§3). Taken: five kinds, migrated on the Mac first.
  6. \n
  7. TimbreCore split before the first iOS file (§6). Taken: created with the Library.
  8. \n
  9. Practice uses the voice — drills are scored like commands, with a corpus (§5). Taken: Spell it first.
  10. \n
\n" } ] diff --git a/web/src/lib/releases/releases.json b/web/src/lib/releases/releases.json index a0e2791..9440a63 100644 --- a/web/src/lib/releases/releases.json +++ b/web/src/lib/releases/releases.json @@ -8,14 +8,14 @@ "sections": [ { "name": "Added", - "html": "
    \n
  • The Library. Settings › Dictionary is now Settings › Library: one store\nfor the words the model should prefer, your acronyms, phrases, spellings\nand corrections, each with a meaning, an example, where it came from and\na strength that practice raises and time lowers. Everything you had taught\nmoved over, in order. Export the whole thing as one file; import merges.
  • \n
  • A word of the day. The word in your Library that has faded furthest,\nchosen at the start of each day: in the menu bar, on a card with its\nfacts labelled by source (your meaning, Apple's dictionary, a sentence\nwritten by the on-device model), read aloud on request. Say it in a\ndictation and it counts as met. A morning notification, off unless you\nturn it on.
  • \n
  • About you. Your field, role and first language, in Settings › Library.\nChooses the word lists; will make explain right for your reader.
  • \n
  • Practice. Spell it, Say it and Explain it, spoken and scored on your\nMac: letters checked exactly, a sentence judged by the on-device model\ninside a guardrail, an explanation checked by its words first. Words come\nup as they fade. A streak, the week's count and the five words most worth\na minute, in Settings › Library. Nothing is ranked or shared.
  • \n
\n" + "html": "
    \n
  • The Library. Settings › Dictionary is now Settings › Library: one store\nfor the words the model should prefer, your acronyms, phrases, spellings\nand corrections, each with a meaning, an example, where it came from and\na strength that practice raises and time lowers. Everything you had taught\nmoved over, in order. Export the whole thing as one file; import merges.

    \n
  • \n
  • A word of the day. The word in your Library that has faded furthest,\nchosen at the start of each day: in the menu bar, on a card with its\nfacts labelled by source (your meaning, Apple's dictionary, a sentence\nwritten by the on-device model), read aloud on request. Say it in a\ndictation and it counts as met. A morning notification, off unless you\nturn it on.

    \n
  • \n
  • About you. Your field, role and first language, in Settings › Library.\nChooses the word lists; will make explain right for your reader.

    \n
  • \n
  • Practice. Spell it, Say it and Explain it, spoken and scored on your\nMac: letters checked exactly, a sentence judged by the on-device model\ninside a guardrail, an explanation checked by its words first. Words come\nup as they fade. A streak, the week's count and the five words most worth\na minute, in Settings › Library. Nothing is ranked or shared.

    \n
  • \n
  • iCloud, if you want it. Settings › Library › Sync my Library through\niCloud: your words, meanings and practice history in your own iCloud\naccount, so your iPhone, Watch and other Macs have the same Library. Off\nby default. Timbre still sends nothing itself; macOS's iCloud daemon does\nthe syncing. Never a dictation, a capture or a usage count.

    \n
  • \n
\n" }, { "name": "Changed", "html": "
    \n
  • The Library lives in its own package, TimbreCore, which the iPhone and\nWatch apps will share. Your words are in\n~/Library/Application Support/Timbre/library.json; the old lists in\npreferences are left in place.
  • \n
  • The menu bar shows the mark. The four bars from the icon, without\nthe tile, in the menu bar's own colour — and they carry the state: dimmed\nwhile setting up, in the retro colours while the mic is open, fading\nwhile the cleanup runs, all coral when something needs you. The\nmicrophone symbol is gone.
  • \n
\n" } ], - "items": 6 + "items": 7 }, { "version": "0.4.0", diff --git a/web/src/lib/university/icloud-and-cloudkit.html b/web/src/lib/university/icloud-and-cloudkit.html index 8ce89ed..e69cdcc 100644 --- a/web/src/lib/university/icloud-and-cloudkit.html +++ b/web/src/lib/university/icloud-and-cloudkit.html @@ -78,5 +78,5 @@

Testing without a server

In Timbre

-

LibrarySync is a protocol in TimbreCore; the CloudKit implementation lives where the framework is. Events are immutable records in a custom zone of the private database; merge is a union; edits take the latest; deletions are tombstones. Sync is off by default, described plainly, and syncs the Library and the profile — never a dictation, a capture or a usage count.

+

TimbreCloud (ADR-0017) is the implementation: CKSyncEngine over one custom zone, three record types — Event named by the event’s id and saved once, Entry replaced whole on edit with the later edited winning, one Profile — and the Library’s own merge doing the merging. A server-changed conflict takes the server’s copy, merges, and sends again over its system fields. The mapping to and from CKRecord is pure and tested offline; the two-device proof is a manual test with nettop open. Sync is off by default, described plainly, and carries the Library and the profile — never a dictation, a capture or a usage count.

diff --git a/web/src/routes/(public)/+page.svelte b/web/src/routes/(public)/+page.svelte index b71b396..22ca83b 100644 --- a/web/src/routes/(public)/+page.svelte +++ b/web/src/routes/(public)/+page.svelte @@ -17,7 +17,7 @@ const faqs = [ { q: 'Does Timbre send my voice anywhere?', - a: 'No. Timbre sends nothing about you anywhere. Out of the box it makes no network requests at all; if you turn on update checks, it fetches one small version file from this website, carrying nothing. Speech recognition, text cleanup, read-aloud and the text commands all run on your Mac using Apple’s on-device models. You can verify this yourself with a network monitor such as Little Snitch.' + a: 'No. Timbre sends nothing about you anywhere. Out of the box it makes no network requests at all; if you turn on update checks, it fetches one small version file from this website, carrying nothing. Speech recognition, text cleanup, read-aloud and the text commands all run on your Mac using Apple’s on-device models. If you turn on iCloud sync for your Library — the words you teach it, never what you dictate — macOS’s own iCloud daemon moves it between your devices under your account; Timbre still makes no request. You can verify this yourself with a network monitor such as Little Snitch.' }, { q: 'Does it work offline?', @@ -124,7 +124,7 @@ name="description" content="Hold a key, speak, release. Cleaned-up text appears wherever you type. Tap another key to hear text read back; hold a third to fix, shorten or explain a selection, or to drop a to-do into Reminders. Speech recognition and AI run entirely on your Mac — zero network requests, no account, free." /> - + {@html `