Skip to content

feat: back up the save to iCloud Drive and restore it on another Mac - #268

Open
t1dotdev wants to merge 2 commits into
chattymin:mainfrom
t1dotdev:feat/icloud-save-sync
Open

t1dotdev wants to merge 2 commits into
chattymin:mainfrom
t1dotdev:feat/icloud-save-sync

Conversation

@t1dotdev

@t1dotdev t1dotdev commented Sep 5, 2026

Copy link
Copy Markdown

What

Adds an opt-in iCloud backup to Settings → Backup & Transfer. When it is on, this Mac
mirrors its save into iCloud Drive automatically; another Mac lists the backups it finds
there and restores one on an explicit click. Moving progress between Macs no longer means
carrying an exported file by hand.

Off by default.

Why a plain file and not CloudKit

CloudKit, NSUbiquitousKeyValueStore and FileManager.url(forUbiquityContainerIdentifier:)
all require com.apple.developer.icloud-* entitlements, and macOS validates those against a
provisioning profile issued to a paid developer team. This app is signed with the self-signed
certificate from scripts/create-signing-cert.sh, has no Team ID and no .entitlements file
at all, so those APIs do not fail loudly — they return nil and no-op. The build succeeds and
the feature silently does nothing, which is exactly the kind of thing review does not catch.

The app is not sandboxed, so writing directly into
~/Library/Mobile Documents/com~apple~CloudDocs/ works with zero entitlements and the iCloud
Drive daemon handles the sync. That is the transport here. I have written this up in
docs/reference/defect-log.md so the CloudKit route is not attempted again before the signing
setup changes.

Why restore is manual, not automatic

CompanionState has fields that cannot be merged without per-device delta tracking that does
not exist: active, eggTier and pendingHatchID are single-valued, and inventory /
spentTokens are counts, so a naive merge double-spends. Auto-applying a remote save would
reproduce the last-writer-wins loss already recorded in the defect log for duplicate
instances — only across machines, and without even the SingleInstance guard to stop it.

So each Mac writes its own save-<deviceID>.json (a shared filename would make two Macs fight
over one path and produce iCloud conflict copies), and applying one is always a user action
that goes through the existing import path: SaveTransfer.decode → the confirm dialog with
Cancel as the default button → backupStateBeforeImportrebasedForThisDevice. No second
apply path was created.

Write cadence

CompanionStore.save() runs on the 120s refresh tick with no dirty check, so mirroring every
call would be up to 720 uploads a day. Two gates, in this order:

  1. Interval floor (5 min), checked before encoding so the common in-window tick costs
    nothing. No trailing timer is needed — the 120s heartbeat is the trailing timer, so a change
    made inside the window lands on the next tick after it expires.
  2. Content comparison against a .sortedKeys encoding of the state. save()'s own bytes
    cannot be reused: the default JSONEncoder has no stable key order for inventory,
    candyGrantTier or collectedFinals, so identical state yields different bytes. The
    envelope cannot be compared either — its exportedAt differs on every call.

Write failures are deliberately not recorded, so the next tick retries instead of assuming
success and dropping the change.

Not synced

UserDefaults settings, session-key.json (a plaintext credential), and every regenerable
cache (usage-cache.json, base-index.json, sprites/, last-snapshot.json). One file is
the save.

Testing

20 new tests in ICloudSaveMirrorTests. ICloudSaveMirror.swift is added to LOGIC_CORE in
test-gate.sh; it sits at 98.6% line coverage (the only uncovered line is the SwiftUI
Identifiable id, exercised by ForEach), and LOGIC_CORE overall is 91.5% against the 75
floor.

Every gate was injection-checked — disabled one at a time, confirming the matching guard test
goes red rather than merely passing:

Disabled Test that fails
content gate testUnchangedStateIsNotRewrittenEvenAfterTheInterval
interval floor testChangedStateInsideTheIntervalIsHeld
.sortedKeys testCanonicalEncodingIsStableForEqualState
save() call testCompanionStoreSaveMirrorsToICloud

Two-instance simulation

Ran two real bundled instances on one machine with separate PTB_STATE_DIR values and a
shared PTB_ICLOUD_DIR, using distinct bundle identifiers so each gets its own UserDefaults
domain and therefore its own device ID.

save-28DA1F2E-…   ver=2.5.3-qaa   dex=2   lifetime=12345678
save-9FC5D54E-…   ver=2.5.3-qab   dex=0   lifetime=0

Two files, no conflict copies. remoteSaves run against that folder: B sees exactly one
remote save (A's, dex=2), A sees exactly one (B's, dex=0); each excludes its own.

Applying A's real envelope to a copy of B's live state file:

before: dex=0  lifetime=1245130   claimed=[claude_code: 42173338]
after:  dex=2  lifetime=12345678  claimed=[claude_code: 41000000]
pre-import backups: [companion-state.pre-import-2026-09-05-120303.json]

Progress carried over including the shiny; the device ledger rebased to this machine's number
instead of importing A's, which is the silent-usage-loss trap rebasedForThisDevice exists to
avoid; the pre-import backup was written.

Cadence sampled every 20s for 13 minutes while the instance was actively accruing tokens:
exactly two writes, 12:01:32 and 12:07:32. The 120s ticks in between were suppressed by the
floor, and the write landed on the first heartbeat past it.

Notes for the reviewer

  • The restore list shows sourceDevice, so two Macs sharing a name render identically and
    only the date disambiguates. Left as is; worth revisiting if it bites.
  • PTB_ICLOUD_DIR lives in AppStatePaths.swift on purpose — that file is already on the
    allowlist in UsageEnvironmentTests.testNoProviderReadsUsageLocationEnvDirectly, so reading
    the variable from a new file would have failed that source scan.
  • A screenshot under assets/ is still needed before release; the release gate hard-fails a
    UI feat: commit without a newly added asset. README (en/ko/ja) and the landing page also
    need the feature added.
  • LocalUsageCacheTests.testDateHelpers and
    testEnrichmentScanStartCatchesWeekStraddlingMonthBoundary fail on my machine before this
    branch (en_TH locale resolves 2026 as Buddhist-era 1483). Pre-existing and unrelated; CI on
    macos-15 is unaffected.

Adds an opt-in iCloud backup so moving progress between Macs no longer
means carrying an exported file by hand. Each Mac mirrors its own save
into iCloud Drive; another Mac lists those backups and restores one on
an explicit click.

Transport is a plain file under ~/Library/Mobile Documents/com~apple~CloudDocs.
CloudKit, NSUbiquitousKeyValueStore and ubiquity containers all require
com.apple.developer.icloud-* entitlements that macOS validates against a
provisioning profile from a paid developer team; this app is signed with a
self-signed certificate and has no entitlements file, so those APIs would
silently no-op. The app is not sandboxed, so the iCloud Drive path works
with no entitlements at all.

Restore is deliberately manual. CompanionState holds fields that cannot be
merged without per-device delta tracking that does not exist -- active,
eggTier and pendingHatchID are single-valued, inventory and spentTokens are
counts. Auto-applying a remote save would reproduce the cross-machine
last-writer-wins loss already recorded in the defect log, without even the
SingleInstance guard. Each Mac writes save-<deviceID>.json so iCloud never
has to resolve a conflict, and restore reuses the existing import path:
SaveTransfer.decode, the confirm dialog with Cancel as the default button,
backupStateBeforeImport, and rebasedForThisDevice.

CompanionStore.save() runs on the 120s refresh tick with no dirty check, so
the mirror gates writes twice: an interval floor checked before encoding,
then a content comparison against a sortedKeys encoding of the state. The
default encoder's key order is not stable for inventory, candyGrantTier or
collectedFinals, and the envelope's exportedAt differs on every call, so
neither can be used for change detection. Write failures are not recorded,
leaving the next tick to retry.

Off by default. Coverage on the new file is 98.6%; each gate was verified by
disabling it and confirming its guard test fails.
…le them

A @mainactor XCTestCase's synchronous setUp/tearDown is nonisolated in
release Swift, so touching main-actor stored properties from it is rejected.
ICloudSaveMirrorTests did exactly that and broke the build on CI.

The same diagnostic has different severity per toolchain: locally (6.3 /
6.5-dev) it is a warning and swift test passes, while CI (6.1.2) makes it an
error and compilation dies. Local green was never evidence here -- the only
signal before pushing was one warning buried in build output.

The rule already existed as a comment in UsageStoreTests, and the next test
file added broke it anyway, so prose was not enough. Adds a source scan,
testMainActorTestClassesKeepMutableFixturesNonisolated, that walks Tests/ and
fails on any @mainactor XCTestCase with a synchronous setUp/tearDown holding
a mutable stored property that is not nonisolated(unsafe). Verified by
removing the annotation from ICloudSaveMirrorTests and again from
UsageStoreTests, confirming it flags both rather than one.

Also silences an unused-result warning on makeMirror, and records the
toolchain severity split in the defect log.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant