Skip to content

Commit 01b6dad

Browse files
steipetePeter Steinbergerclaude
authored
Opt-in iCloud sync for settings, providers, and usage snapshots (#2597)
* feat: opt-in iCloud sync for settings, providers, and usage snapshots CloudKit (CKSyncEngine, private DB, zone CodexBarSync) syncs provider configuration, a curated preferences subset, and per-device account/usage snapshots across Macs. Secrets ride E2E-encrypted record fields with their own opt-out; hooks and machine-local paths are structurally excluded from sync payloads. Off by default (Settings -> iCloud Sync). Release packaging now embeds a Developer ID provisioning profile authorizing the iCloud entitlements; config.json gains a file watcher so external CLI edits apply live. Includes fleet menu rows ("via <Mac> - 1h ago") backed by snapshots and a schema deploy script (Scripts/cloudkit/deploy_schema.sh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: harden CloudKit sync engine per adversarial review Verified against Apple's CKSyncEngine sample/docs: secret-free 0600 state persistence, stale-pending drain, auto-sizing batches, error-embedded server record conflicts, zoneNotFound recovery, account-change/sign-in restart, capped quota backoff, unscoped first fetch. Distributed-behavior fixes: enable-intent echo suppression (CLI-less Mac no longer reverts fleet-wide enables), device-scoped snapshot records, apply-side secrets opt-out, newer-schema conflict pause, fleet cache rehydration, pre-enable secrets opt-out UI, Bash 3.2-safe deploy script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Peter Steinberger <steipete@mac-studio-sf2.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent be52dc4 commit 01b6dad

62 files changed

Lines changed: 3406 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.46.1 — Unreleased
44

55
### Added
6+
- Sync: opt-in iCloud sync (Settings → iCloud Sync, default off) syncs provider configuration, a curated preferences subset, and per-device usage snapshots across Macs via CloudKit; API keys/cookies/tokens ride end-to-end-encrypted fields with their own opt-out, hooks and machine-local paths never sync, and menus can show accounts from other Macs with last-known usage ("via <Mac> · 1h ago") when the local fetch is unavailable. The app now also watches `config.json`, so external CLI edits apply live.
67
- z.ai: add 7-day and 30-day model-usage chart ranges with dataset-consistent legends, colors, and daily tooltips (#2524). Thanks @LeoLin990405!
78
- Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000!
89
- CLI: `codexbar hooks watch` continuously polls providers and fires hooks on real quota/status transitions for headless installs, with in-memory baselines, event rate limits, `--interval` (default 300s, minimum 60s), `--provider`, and JSON output (#2536). Thanks @OfficialAbhinavSingh!

Scripts/cloudkit/deploy_schema.sh

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env bash
2+
# Deploys the CodexBar CloudKit schema (Scripts/cloudkit/schema.ckdb).
3+
#
4+
# Requires a CloudKit management token (create one at
5+
# https://icloud.developer.apple.com/dashboard → account menu → Tokens,
6+
# or via `xcrun cktool save-token`). Pass it via CLOUDKIT_MANAGEMENT_TOKEN
7+
# or save it first with `xcrun cktool save-token`.
8+
#
9+
# Usage: Scripts/cloudkit/deploy_schema.sh [development|production]
10+
set -euo pipefail
11+
12+
ENVIRONMENT="${1:-development}"
13+
case "$ENVIRONMENT" in
14+
development|production) ;;
15+
*) echo "ERROR: environment must be development or production" >&2; exit 1 ;;
16+
esac
17+
18+
TEAM_ID="Y5PE65HELJ"
19+
CONTAINER_ID="iCloud.com.steipete.codexbar"
20+
SCHEMA_FILE="$(cd "$(dirname "$0")" && pwd)/schema.ckdb"
21+
22+
TOKEN_ARGS=()
23+
if [[ -n "${CLOUDKIT_MANAGEMENT_TOKEN:-}" ]]; then
24+
TOKEN_ARGS=(--token "$CLOUDKIT_MANAGEMENT_TOKEN")
25+
fi
26+
27+
echo "Validating schema against $ENVIRONMENT..."
28+
xcrun cktool validate-schema ${TOKEN_ARGS[@]+"${TOKEN_ARGS[@]}"} \
29+
--team-id "$TEAM_ID" --container-id "$CONTAINER_ID" \
30+
--environment "$ENVIRONMENT" --file "$SCHEMA_FILE"
31+
32+
echo "Importing schema into $ENVIRONMENT..."
33+
xcrun cktool import-schema ${TOKEN_ARGS[@]+"${TOKEN_ARGS[@]}"} \
34+
--team-id "$TEAM_ID" --container-id "$CONTAINER_ID" \
35+
--environment "$ENVIRONMENT" --file "$SCHEMA_FILE"
36+
37+
echo "Done. Current $ENVIRONMENT schema:"
38+
xcrun cktool export-schema ${TOKEN_ARGS[@]+"${TOKEN_ARGS[@]}"} \
39+
--team-id "$TEAM_ID" --container-id "$CONTAINER_ID" \
40+
--environment "$ENVIRONMENT"

Scripts/cloudkit/schema.ckdb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
DEFINE SCHEMA
2+
3+
RECORD TYPE AccountSnapshot (
4+
accountKey STRING,
5+
deviceID STRING,
6+
fetchedAt TIMESTAMP,
7+
provider STRING,
8+
schemaVersion INT64,
9+
displayLabel ENCRYPTED STRING,
10+
usagePayload ENCRYPTED STRING,
11+
GRANT WRITE TO "_creator",
12+
GRANT CREATE TO "_icloud",
13+
GRANT READ TO "_creator"
14+
);
15+
16+
RECORD TYPE Device (
17+
appVersion STRING,
18+
deviceID STRING,
19+
hostName STRING,
20+
lastSeen TIMESTAMP,
21+
model STRING,
22+
schemaVersion INT64,
23+
GRANT WRITE TO "_creator",
24+
GRANT CREATE TO "_icloud",
25+
GRANT READ TO "_creator"
26+
);
27+
28+
RECORD TYPE Preferences (
29+
editCount INT64,
30+
modifiedAt TIMESTAMP,
31+
payload STRING,
32+
schemaVersion INT64,
33+
GRANT WRITE TO "_creator",
34+
GRANT CREATE TO "_icloud",
35+
GRANT READ TO "_creator"
36+
);
37+
38+
RECORD TYPE ProviderIntent (
39+
editCount INT64,
40+
modifiedAt TIMESTAMP,
41+
payload STRING,
42+
provider STRING,
43+
schemaVersion INT64,
44+
apiKey ENCRYPTED STRING,
45+
cookieHeader ENCRYPTED STRING,
46+
secretKey ENCRYPTED STRING,
47+
tokenAccounts ENCRYPTED STRING,
48+
GRANT WRITE TO "_creator",
49+
GRANT CREATE TO "_icloud",
50+
GRANT READ TO "_creator"
51+
);

Scripts/package_app.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,36 @@ if [[ "$ALLOW_LLDB" == "1" && "$LOWER_CONF" != "debug" ]]; then
242242
echo "ERROR: CODEXBAR_ALLOW_LLDB requires debug configuration" >&2
243243
exit 1
244244
fi
245+
# iCloud sync (CloudKit) requires restricted entitlements authorized by an embedded
246+
# Developer ID provisioning profile. Only identity-signed release builds of the primary
247+
# bundle ID carry them; adhoc/debug builds run with sync unavailable.
248+
PROVISIONING_PROFILE_SOURCE="$ROOT/Scripts/profiles/CodexBar-DeveloperID.provisionprofile"
249+
EMBED_PROVISIONING_PROFILE=0
250+
ICLOUD_ENTITLEMENT_KEYS=""
251+
if [[ "$SIGNING_MODE" == "identity" && "$LOWER_CONF" == "release" && "$BUNDLE_ID" == "com.steipete.codexbar" ]]; then
252+
if [[ ! -f "$PROVISIONING_PROFILE_SOURCE" ]]; then
253+
echo "ERROR: Missing $PROVISIONING_PROFILE_SOURCE (required for iCloud entitlements in release builds)" >&2
254+
exit 1
255+
fi
256+
EMBED_PROVISIONING_PROFILE=1
257+
ICLOUD_ENTITLEMENT_KEYS=$(cat <<ICLOUD
258+
<key>com.apple.application-identifier</key>
259+
<string>${APP_TEAM_ID}.${BUNDLE_ID}</string>
260+
<key>com.apple.developer.team-identifier</key>
261+
<string>${APP_TEAM_ID}</string>
262+
<key>com.apple.developer.icloud-services</key>
263+
<array>
264+
<string>CloudKit</string>
265+
</array>
266+
<key>com.apple.developer.icloud-container-identifiers</key>
267+
<array>
268+
<string>iCloud.${BUNDLE_ID}</string>
269+
</array>
270+
<key>com.apple.developer.icloud-container-environment</key>
271+
<string>Production</string>
272+
ICLOUD
273+
)
274+
fi
245275
cat > "$APP_ENTITLEMENTS" <<PLIST
246276
<?xml version="1.0" encoding="UTF-8"?>
247277
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -251,6 +281,7 @@ cat > "$APP_ENTITLEMENTS" <<PLIST
251281
<array>
252282
<string>${APP_GROUP_ID}</string>
253283
</array>
284+
${ICLOUD_ENTITLEMENT_KEYS}
254285
$(if [[ "$ALLOW_LLDB" == "1" ]]; then echo " <key>com.apple.security.get-task-allow</key><true/>"; fi)
255286
</dict>
256287
</plist>
@@ -562,6 +593,12 @@ if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then
562593
"$APP/Contents/PlugIns/CodexBarWidget.appex"
563594
fi
564595

596+
# Embed the Developer ID provisioning profile (authorizes the iCloud entitlements;
597+
# Gatekeeper re-validates it at every launch, so it must be sealed into the signature).
598+
if [[ "$EMBED_PROVISIONING_PROFILE" == "1" ]]; then
599+
cp "$PROVISIONING_PROFILE_SOURCE" "$APP/Contents/embedded.provisionprofile"
600+
fi
601+
565602
# Finally sign the app bundle itself
566603
codesign "${CODESIGN_ARGS[@]}" \
567604
--entitlements "$APP_ENTITLEMENTS" \
19 KB
Binary file not shown.

Sources/CodexBar/CodexbarApp.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ struct CodexBarApp: App {
112112
PreferencesView(
113113
settings: self.settings,
114114
store: self.store,
115+
cloudSyncState: self.appDelegate.cloudSyncState,
115116
updater: self.appDelegate.updaterController,
116117
selection: self.preferencesSelection,
117118
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
@@ -377,6 +378,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
377378
}
378379

379380
let updaterController: UpdaterProviding = makeUpdaterController()
381+
let cloudSyncState = CloudSyncState()
380382
private let confettiOverlayController = ScreenConfettiOverlayController()
381383
private let confettiLogger = CodexBarLog.logger(LogCategories.confetti)
382384
private lazy var memoryPressureMonitor = MemoryPressureMonitor(trimAppCaches: { [weak self] in
@@ -390,6 +392,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
390392
private var preferencesSelection: PreferencesSelection?
391393
private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?
392394
private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?
395+
private var cloudSyncCoordinator: CloudSyncCoordinator?
393396
private var hasInstalledLimitResetObservers = false
394397
#if DEBUG
395398
private var debugMemoryPressureObserver: NSObjectProtocol?
@@ -405,6 +408,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
405408
self.preferencesSelection = dependencies.selection
406409
self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator
407410
self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator
411+
self.cloudSyncCoordinator = CloudSyncCoordinator(settings: dependencies.settings, state: self.cloudSyncState)
408412
}
409413

410414
func applicationWillFinishLaunching(_ notification: Notification) {
@@ -417,6 +421,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
417421
self.installDebugMemoryPressureObserverIfNeeded()
418422
#endif
419423
self.ensureStatusController()
424+
self.cloudSyncCoordinator?.start()
420425
Task { @MainActor [weak self] in
421426
await Task.yield()
422427
guard let settings = self?.settings else { return }
@@ -452,6 +457,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
452457
}
453458

454459
func applicationWillTerminate(_ notification: Notification) {
460+
self.cloudSyncCoordinator?.stop()
455461
self.memoryPressureMonitor.stop()
456462
#if DEBUG
457463
self.removeDebugMemoryPressureObserver()
@@ -462,6 +468,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
462468
self.terminateActiveProcessesForAppShutdown()
463469
}
464470

471+
func applicationDidBecomeActive(_ notification: Notification) {
472+
self.cloudSyncCoordinator?.applicationDidBecomeActive()
473+
}
474+
465475
func runProviderLoginFlow(_ provider: UsageProvider) async {
466476
self.ensureStatusController()
467477
guard let statusController else { return }
@@ -557,6 +567,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
557567
managedCodexAccountCoordinator,
558568
codexAccountPromotionCoordinator)
559569
if let statusController = self.statusController as? StatusItemController {
570+
statusController.cloudSyncState = self.cloudSyncState
560571
MenuSwitchFlickerProbe.startIfRequested(controller: statusController)
561572
}
562573
return
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import CodexBarCore
2+
import Foundation
3+
import SwiftUI
4+
5+
struct FleetAccountMenuProjection {
6+
let fallback: AccountSnapshotSyncPayload?
7+
let additionalAccounts: [AccountSnapshotSyncPayload]
8+
}
9+
10+
enum FleetAccountMenuPlanner {
11+
static func projection(
12+
provider: UsageProvider,
13+
snapshots: some Sequence<AccountSnapshotSyncPayload>,
14+
currentDeviceID: String,
15+
localAccountKeys: Set<String>,
16+
hasLocalUsage: Bool) -> FleetAccountMenuProjection
17+
{
18+
let remote = snapshots
19+
.filter { $0.provider == provider && $0.deviceID != currentDeviceID }
20+
let freshestByAccount = Dictionary(grouping: remote, by: \.accountKey)
21+
.compactMap { _, candidates in candidates.max(by: self.isOlder) }
22+
.sorted(by: self.isNewer)
23+
let fallback = hasLocalUsage ? nil : freshestByAccount.first
24+
let additionalAccounts = freshestByAccount.filter { candidate in
25+
!localAccountKeys.contains(candidate.accountKey) && candidate.accountKey != fallback?.accountKey
26+
}
27+
return FleetAccountMenuProjection(fallback: fallback, additionalAccounts: additionalAccounts)
28+
}
29+
30+
static func badge(deviceName: String, fetchedAt: Date, now: Date = .now) -> String {
31+
"via \(deviceName) · \(self.staleness(fetchedAt: fetchedAt, now: now))"
32+
}
33+
34+
static func staleness(fetchedAt: Date, now: Date = .now) -> String {
35+
let seconds = max(0, Int(now.timeIntervalSince(fetchedAt)))
36+
if seconds < 60 {
37+
return L("just now")
38+
}
39+
if seconds < 3600 {
40+
return String(format: L("%dm ago"), max(1, seconds / 60))
41+
}
42+
if seconds < 86400 {
43+
return String(format: L("%dh ago"), max(1, seconds / 3600))
44+
}
45+
return String(format: L("%dd ago"), max(1, seconds / 86400))
46+
}
47+
48+
private static func isOlder(_ lhs: AccountSnapshotSyncPayload, _ rhs: AccountSnapshotSyncPayload) -> Bool {
49+
if lhs.fetchedAt != rhs.fetchedAt {
50+
return lhs.fetchedAt < rhs.fetchedAt
51+
}
52+
return lhs.deviceID < rhs.deviceID
53+
}
54+
55+
private static func isNewer(_ lhs: AccountSnapshotSyncPayload, _ rhs: AccountSnapshotSyncPayload) -> Bool {
56+
if lhs.fetchedAt != rhs.fetchedAt {
57+
return lhs.fetchedAt > rhs.fetchedAt
58+
}
59+
if lhs.accountKey != rhs.accountKey {
60+
return lhs.accountKey < rhs.accountKey
61+
}
62+
return lhs.deviceID < rhs.deviceID
63+
}
64+
}
65+
66+
struct FleetAccountMenuCardView: View {
67+
let model: UsageMenuCardView.Model
68+
let width: CGFloat
69+
70+
var body: some View {
71+
UsageMenuCardView(model: self.model, width: self.width)
72+
.opacity(0.78)
73+
}
74+
}

Sources/CodexBar/MenuCardView+ModelInput.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ extension UsageMenuCardView.Model {
3131
let codexSparkUsageVisible: Bool
3232
let copilotBudgetExtrasEnabled: Bool
3333
let sourceLabel: String?
34+
let subtitleOverride: String?
3435
let kiloAutoMode: Bool
3536
let hidePersonalInfo: Bool
3637
let weeklyPace: UsagePace?
@@ -70,6 +71,7 @@ extension UsageMenuCardView.Model {
7071
codexSparkUsageVisible: Bool = true,
7172
copilotBudgetExtrasEnabled: Bool = false,
7273
sourceLabel: String? = nil,
74+
subtitleOverride: String? = nil,
7375
kiloAutoMode: Bool = false,
7476
hidePersonalInfo: Bool,
7577
weeklyPace: UsagePace? = nil,
@@ -108,6 +110,7 @@ extension UsageMenuCardView.Model {
108110
self.codexSparkUsageVisible = codexSparkUsageVisible
109111
self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled
110112
self.sourceLabel = sourceLabel
113+
self.subtitleOverride = subtitleOverride
111114
self.kiloAutoMode = kiloAutoMode
112115
self.hidePersonalInfo = hidePersonalInfo
113116
self.weeklyPace = weeklyPace

Sources/CodexBar/MenuCardView.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -940,11 +940,12 @@ extension UsageMenuCardView.Model {
940940
snapshot: tokenUsageSnapshot,
941941
error: input.tokenError,
942942
preferredCurrencyCode: input.preferredCurrencyCode)
943-
let subtitle = Self.subtitle(
944-
snapshot: input.snapshot,
945-
isRefreshing: input.isRefreshing,
946-
lastError: Self.lastError(input: input),
947-
now: input.now)
943+
let subtitle = input.subtitleOverride.map { (text: $0, style: SubtitleStyle.info) }
944+
?? Self.subtitle(
945+
snapshot: input.snapshot,
946+
isRefreshing: input.isRefreshing,
947+
lastError: Self.lastError(input: input),
948+
now: input.now)
948949
let redacted = Self.redactedText(input: input, subtitle: subtitle)
949950
let placeholder = Self.placeholder(input: input)
950951

Sources/CodexBar/Notifications+CodexBar.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,18 @@ extension Notification.Name {
1111
static let codexbarSessionLimitReset = Notification.Name("codexbarSessionLimitReset")
1212
static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset")
1313
static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange")
14+
static let codexbarUsageSnapshotsDidChange = Notification.Name("codexbarUsageSnapshotsDidChange")
1415
static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost")
1516
}
1617

18+
final class UsageSnapshotsDidChangeEvent: NSObject, @unchecked Sendable {
19+
let snapshots: [AccountSnapshotSyncPayload]
20+
21+
init(snapshots: [AccountSnapshotSyncPayload]) {
22+
self.snapshots = snapshots
23+
}
24+
}
25+
1726
@MainActor
1827
final class SessionLimitResetEvent: NSObject {
1928
let provider: UsageProvider

0 commit comments

Comments
 (0)