crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 6e4889ee40133e1bc37fea3deec36785e52b55cc
parent f53f637f85b8b258bdfc6c1dc8edbe2a0ab2dcbb
Author: Michael Camilleri <[email protected]>
Date:   Tue, 16 Jun 2026 07:11:02 +0900

Purge the local store on an iCloud account switch

When a different iCloud account signed in on a device, the previous
account's games stayed in the local store: they remained visible in the
Game List, and the new author's records were written alongside the old
account's rows. On a handed-down device or a genuine Apple ID change
that meant one user could see another's private puzzles. The
account-change handler only refreshed identity and push registration; it
never cleared the cache the previous account had populated.

This commit drops that cache on a real account switch. The handler now
captures the prior author ID, refreshes identity, and — only when both
the old and the new IDs are known and differ — purges the local store
through a new CloudService.purgeLocalData. The purge is local only: it
clears the Core Data store, the colour map and the badge ledger, then
rebuilds the sync engines so the device resyncs as the new account. It
deliberately does not delete private zones or leave shared zones the way
the diagnostics resetAllData does, because the previous account's games
must remain intact in its own CloudKit — the user still has them on
their other devices. Gating on the author ID changing keeps two
non-switch events harmless: a first sign-in has no prior ID, and a
transient sign-out leaves AuthorIdentity.refresh a no-op so the ID is
unchanged.

GameStore.resetAllData now also clears JournalEntity. Replay journals
are keyed to games, so once every game is deleted they are orphaned, and
on a switch they would otherwise retain the previous account's solve
history; clearing them tightens the diagnostics reset by the same
measure.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 9++++++++-
MCrossmate/Services/AppServices.swift | 30+++++++++++++++++++++++++++++-
MCrossmate/Services/CloudService.swift | 16++++++++++++++++
MTests/Unit/Sync/AuthorIdentityTests.swift | 33+++++++++++++++++++++++++++++++++
4 files changed, 86 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -963,7 +963,8 @@ final class GameStore { // MARK: - Reset /// Deletes every game (and its cascaded moves, snapshots, and cells) plus - /// the sync-state row. Used by the diagnostics reset button. + /// the sync-state row. Used by the diagnostics reset button and by the + /// account-switch purge (`CloudService.purgeLocalData`). func resetAllData() throws { for entity in try context.fetch(NSFetchRequest<GameEntity>(entityName: "GameEntity")) { context.delete(entity) @@ -980,6 +981,12 @@ final class GameStore { for entity in try context.fetch(NSFetchRequest<InviteEntity>(entityName: "InviteEntity")) { context.delete(entity) } + // Replay journals are keyed to games; once every game is gone they are + // orphaned and (on an account switch) hold the previous account's solve + // history, so clear them too. + for entity in try context.fetch(NSFetchRequest<JournalEntity>(entityName: "JournalEntity")) { + context.delete(entity) + } try context.save() currentGame = nil currentMutator = nil diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -174,6 +174,17 @@ final class AppServices { /// not fire for the initial phase. private(set) var isAppForeground = true + /// Whether an account-change event warrants purging this device's local + /// store. True only when both the previously-known and the freshly-resolved + /// author IDs are known and differ — i.e. a real switch to a different + /// iCloud account. A first sign-in has no previous ID, and a transient + /// sign-out leaves `AuthorIdentity.refresh` a no-op (so the ID is + /// unchanged); neither should wipe local data. + static func accountSwitchRequiresPurge(previousID: String?, newID: String?) -> Bool { + guard let previousID, let newID else { return false } + return previousID != newID + } + init() { let preferences = PlayerPreferences() self.preferences = preferences @@ -601,8 +612,25 @@ final class AppServices { await syncEngine.setOnAccountChange { [weak self] in guard let self else { return } + let previousID = self.identity.currentID await self.identity.refresh(using: self.ckContainer) - self.pushClient?.updateAuthorID(self.identity.currentID) + let newID = self.identity.currentID + // A switch to a *different* iCloud account: drop this device's + // cache of the previous account's data so it neither lingers in + // the library nor mixes with the new author's rows. Local only — + // the previous account keeps its games in its own CloudKit; this + // device just resyncs as the new account. Gated on the author ID + // actually changing so a first sign-in (no previous) or a + // transient sign-out (`refresh` no-ops, ID unchanged) doesn't + // purge. + if Self.accountSwitchRequiresPurge(previousID: previousID, newID: newID) { + do { + try await self.cloudService.purgeLocalData() + } catch { + self.syncMonitor.note("account-switch purge failed — \(error)") + } + } + self.pushClient?.updateAuthorID(newID) // Recompute the address set for the new account; addresses that // belonged to the old account drop out and are unregistered. await self.accountPush.reconcilePushRegistration() diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -115,6 +115,22 @@ final class CloudService { syncMonitor.note("Database reset — all games and sync state cleared") } + /// Local-only counterpart to `resetAllData`, used on an iCloud account + /// switch. Drops this device's cached store, badge ledger, colour map, and + /// sync-engine tokens, then rebuilds the engines so they resync as the new + /// account. Critically it does **not** delete private zones or leave shared + /// zones: the previous account's games stay intact in *its* CloudKit (the + /// user still has them on other devices) — only this device's stale cache + /// of them is discarded. The store is wiped before `resetSyncState` so the + /// latter's unconfirmed-move recovery finds nothing to re-enqueue. + func purgeLocalData() async throws { + try store.resetAllData() + UserDefaults.standard.removeObject(forKey: "gamePlayerColors") + BadgeState.reset() + await syncEngine.resetSyncState() + syncMonitor.note("Local store purged for account switch — previous account untouched in CloudKit") + } + private func deleteAllPrivateZones() async { do { let zones = try await ckContainer.privateCloudDatabase.allRecordZones() diff --git a/Tests/Unit/Sync/AuthorIdentityTests.swift b/Tests/Unit/Sync/AuthorIdentityTests.swift @@ -44,3 +44,36 @@ struct AuthorIdentityTests { #expect(identity.currentID == "_persisted") } } + +/// `AppServices.accountSwitchRequiresPurge` gates the local-store wipe on an +/// account-change event. Only a switch between two *known, different* accounts +/// should purge. +@Suite("Account switch purge gate") +@MainActor +struct AccountSwitchPurgeTests { + + @Test("Switch to a different account purges") + func differentAccountPurges() { + #expect(AppServices.accountSwitchRequiresPurge(previousID: "_alice", newID: "_bob")) + } + + @Test("Same account does not purge") + func sameAccountKeeps() { + #expect(!AppServices.accountSwitchRequiresPurge(previousID: "_alice", newID: "_alice")) + } + + @Test("First sign-in (no previous ID) does not purge") + func firstSignInKeeps() { + #expect(!AppServices.accountSwitchRequiresPurge(previousID: nil, newID: "_bob")) + } + + @Test("Transient sign-out (no new ID) does not purge") + func signOutKeeps() { + #expect(!AppServices.accountSwitchRequiresPurge(previousID: "_alice", newID: nil)) + } + + @Test("Both unknown does not purge") + func bothUnknownKeeps() { + #expect(!AppServices.accountSwitchRequiresPurge(previousID: nil, newID: nil)) + } +}