Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions apps/Companion/Companion.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -136,6 +138,7 @@
BB0000000000000000000113 /* TimbreModel in Frameworks */,
BB0000000000000000000114 /* TimbreReminders in Frameworks */,
BB0000000000000000000117 /* TimbreRelay in Frameworks */,
BB0000000000000000000118 /* TimbreCloud in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -146,6 +149,7 @@
BB0000000000000000000211 /* TimbreCore in Frameworks */,
BB0000000000000000000212 /* TimbreReminders in Frameworks */,
BB0000000000000000000214 /* TimbreRelay in Frameworks */,
BB0000000000000000000215 /* TimbreCloud in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -241,6 +245,7 @@
BB0000000000000000000123 /* TimbreModel */,
BB0000000000000000000124 /* TimbreReminders */,
BB0000000000000000000125 /* TimbreRelay */,
BB0000000000000000000126 /* TimbreCloud */,
);
productName = Timbre;
productReference = BB0000000000000000000102 /* Timbre.app */;
Expand Down Expand Up @@ -268,6 +273,7 @@
BB0000000000000000000221 /* TimbreCore */,
BB0000000000000000000222 /* TimbreReminders */,
BB0000000000000000000223 /* TimbreRelay */,
BB0000000000000000000224 /* TimbreCloud */,
);
productName = "Timbre Watch App";
productReference = BB0000000000000000000202 /* Timbre Watch App.app */;
Expand Down Expand Up @@ -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 */;
Expand Down Expand Up @@ -579,6 +586,10 @@
isa = XCLocalSwiftPackageReference;
relativePath = ../../packages/TimbreRelay;
};
BB0000000000000000000026 /* XCLocalSwiftPackageReference "../../packages/TimbreCloud" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../packages/TimbreCloud;
};
/* End XCLocalSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -602,6 +613,10 @@
isa = XCSwiftPackageProductDependency;
productName = TimbreRelay;
};
BB0000000000000000000126 /* TimbreCloud */ = {
isa = XCSwiftPackageProductDependency;
productName = TimbreCloud;
};
BB0000000000000000000221 /* TimbreCore */ = {
isa = XCSwiftPackageProductDependency;
productName = TimbreCore;
Expand All @@ -614,6 +629,10 @@
isa = XCSwiftPackageProductDependency;
productName = TimbreRelay;
};
BB0000000000000000000224 /* TimbreCloud */ = {
isa = XCSwiftPackageProductDependency;
productName = TimbreCloud;
};
BB0000000000000000000321 /* TimbreCore */ = {
isa = XCSwiftPackageProductDependency;
productName = TimbreCore;
Expand Down
11 changes: 11 additions & 0 deletions apps/Companion/Config/Watch.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,16 @@
<array>
<string>group.dev.hugopretorius.Timbre</string>
</array>
<!-- The Library through the user's own iCloud (GDR-0023 decision 1,
ADR-0017). Off until turned on in Settings; the binary still makes no
request — the system's daemon does the syncing. -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.hugopretorius.Timbre</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>
11 changes: 11 additions & 0 deletions apps/Companion/Config/iOS.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,16 @@
<array>
<string>group.dev.hugopretorius.Timbre</string>
</array>
<!-- The Library through the user's own iCloud (GDR-0023 decision 1,
ADR-0017). Off until turned on in Settings; the binary still makes no
request — the system's daemon does the syncing. -->
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.hugopretorius.Timbre</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>
17 changes: 16 additions & 1 deletion apps/Companion/Watch/Sources/WatchModel.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import Observation
import TimbreCloud
import TimbreCore
import TimbreRelay
import TimbreReminders
Expand All @@ -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?
Expand All @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions apps/Companion/Watch/Sources/WatchRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 18 additions & 1 deletion apps/Companion/iOS/Sources/CompanionModel.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AVFAudio
import Foundation
import Observation
import TimbreCloud
import TimbreCore
import TimbreModel
import TimbreRelay
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -50,6 +55,7 @@ final class CompanionModel {
func start() async {
refresh()
relay.activate()
if syncsThroughICloud { cloud.start() }
reader.prewarm()
do {
try await speech.prepare()
Expand Down Expand Up @@ -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() }
}
}
}
31 changes: 31 additions & 0 deletions apps/Companion/iOS/Sources/YouView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import TimbreCloud
import TimbreCore

/// About you, your file, and what never leaves the phone.
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}
}
63 changes: 63 additions & 0 deletions apps/Timbre/Sources/LibrarySettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ struct LibrarySettings: View {
Divider()
ProfileSection(library: controller.library)
Divider()
CloudSection(controller: controller)
Divider()
ExportSection(library: controller.library)
}
.padding(24)
Expand Down Expand Up @@ -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
}
}
}
Loading
Loading