crossmate

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

commit c79ed4b81c9003a9eb97455360a6d8ead651e685
parent 7a8818904fad958c04b730a74cd46ac63b6c93d1
Author: Michael Camilleri <[email protected]>
Date:   Thu, 23 Jul 2026 20:44:37 +0900

Keep Chronicles out of live game synchronisation

Opening a materialised Chronicle could start the live-game lifecycle,
writing clock, presence and cursor Player records into the compact
archive zone.

This commit marks archived projections explicitly and prevents them from
starting grid sync, engagement or other live session work while
retaining their participant attribution and replay. A segregated
one-shot migration cancels pending writes and removes stray Player
records already stored in the Chronicle zone without touching the
archived roster.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 22+++++++++++++++++++---
MCrossmate/Persistence/GameMutator.swift | 7+++++++
MCrossmate/Persistence/GameStore.swift | 1+
MCrossmate/Services/AppServices.swift | 11+++++++++++
MCrossmate/Sync/SyncEngine.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
MShared/NotificationState.swift | 12++++++++++++
MTests/Unit/ArchiveTests.swift | 23+++++++++++++++++++++++
7 files changed, 121 insertions(+), 3 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -802,12 +802,17 @@ private extension UIApplication { /// Loads a game when navigated to. private struct PuzzleDisplayView: View { private var syncedID: UUID? { - preferences.isICloudSyncEnabled && session != nil ? gameID : nil + guard preferences.isICloudSyncEnabled, + let mutator = session?.mutator, + !mutator.isArchived + else { return nil } + return gameID } private var syncedScope: CKDatabase.Scope? { guard preferences.isICloudSyncEnabled, - let mutator = session?.mutator + let mutator = session?.mutator, + !mutator.isArchived else { return nil } return mutator.isOwned ? .private : .shared } @@ -833,6 +838,7 @@ private struct PuzzleDisplayView: View { shareController: shareController, roster: roster, onComplete: { notifyPeers in + guard !session.mutator.isArchived else { return } do { let changed = try notifyPeers ? store.markCompleted(id: gameID) @@ -939,7 +945,6 @@ private struct PuzzleDisplayView: View { roster = nil loadError = nil loadingMessage = "Loading puzzle…" - noteSessionPhase(scenePhase) Task { await services.badge.dismissDeliveredNotifications(for: gameID) } do { @@ -970,6 +975,7 @@ private struct PuzzleDisplayView: View { guard !Task.isCancelled else { return } roster = newRoster session = newSession + noteSessionPhase(scenePhase) openPuzzleFollowUpTask = Task { @MainActor in await finishOpeningPuzzle( session: newSession, @@ -1006,6 +1012,7 @@ private struct PuzzleDisplayView: View { Task { await activateSharing(for: session) } } .onChange(of: scenePhase) { _, newPhase in + guard session?.mutator.isArchived == false else { return } noteSessionPhase(newPhase) // Only act on settled transitions. `.inactive` is transient (lock // animation, app switcher, Control Center, banners), so a write @@ -1049,6 +1056,7 @@ private struct PuzzleDisplayView: View { .onDisappear { openPuzzleFollowUpTask?.cancel() openPuzzleFollowUpTask = nil + guard session?.mutator.isArchived == false else { return } let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater let id = gameID @@ -1095,6 +1103,13 @@ private struct PuzzleDisplayView: View { for announcement in OpenPuzzleBanner.announcements(for: openState) { services.announcements.post(announcement) } + if loadedSession.mutator.isArchived { + services.syncMonitor.note( + "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " + + "(live lifecycle disabled)" + ) + return + } if isShared && preferences.isICloudSyncEnabled { services.syncMonitor.note( "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster" @@ -1114,6 +1129,7 @@ private struct PuzzleDisplayView: View { /// the begin/end/grace choreography (active-puzzle ID, deferred play and /// pause pushes, catch-up banner). private func noteSessionPhase(_ phase: ScenePhase) { + guard session?.mutator.isArchived == false else { return } switch phase { case .active: services.sessions.notePuzzleActive(gameID: gameID) diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -48,6 +48,11 @@ final class GameMutator { } private let hasArchivedPlayerAttribution: Bool + /// `true` for the local read-only projection of a Chronicle. It retains + /// participant display and replay data, but must never enter live-game + /// clock, presence, engagement, cursor, or CloudKit write paths. + let isArchived: Bool + /// Set to `true` when the owner has revoked the current user's access to /// a shared game. Revocation makes the game read-only: every mutating /// entry point below is a no-op (via `isEditable`) and `PuzzleView` shows @@ -92,6 +97,7 @@ final class GameMutator { isOwned: Bool = true, isShared: Bool = false, showsPlayerAttribution: Bool? = nil, + isArchived: Bool = false, isAccessRevoked: Bool = false, isSyncSupported: Bool = true, syncVersion: Int64 = GameSyncVersion.current, @@ -108,6 +114,7 @@ final class GameMutator { self.isOwned = isOwned self.isShared = isShared self.hasArchivedPlayerAttribution = showsPlayerAttribution ?? isShared + self.isArchived = isArchived self.isAccessRevoked = isAccessRevoked self.isSyncSupported = isSyncSupported self.usesLogicalTicks = GameSyncVersion.normalized(syncVersion) >= GameSyncVersion.logicalTicks diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -3048,6 +3048,7 @@ final class GameStore { showsPlayerAttribution: entity.ckShareRecordName != nil || entity.databaseScope == 1 || isArchivedSharedGame(entity), + isArchived: isMaterializedArchive(entity), isAccessRevoked: entity.isAccessRevoked, isSyncSupported: GameSyncVersion.supports(entity.syncVersion), syncVersion: entity.syncVersion, diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1351,6 +1351,17 @@ final class AppServices { syncMonitor.note("iCloud sync disabled — engine startup skipped") return } + + // TEMPORARY v1.1 MIGRATION: early Chronicle builds opened their local + // projections as live games and consequently wrote Player records into + // the private Chronicle zone. The permanent lifecycle gate lives with + // PuzzleDisplayView; this one-shot repair can later be removed as one + // block together with `purgeChroniclePlayers_v1` and its + // NotificationState flag. + Task { [syncEngine] in + await syncEngine.purgeChroniclePlayers_v1() + } + // One-shot migration of pre-existing single-zone friendships to the // two-mailbox model. Runs only once the engine is up (it enqueues zone // creates, a share, and a bootstrap ping) and only with sync enabled. diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1048,6 +1048,54 @@ actor SyncEngine { } } + /// TEMPORARY v1.1 MIGRATION: removes `Player` records accidentally written + /// into the private Chronicle zone by early Chronicle builds. Remove this + /// method together with its AppServices startup call and NotificationState + /// flag after those builds have aged out. + func purgeChroniclePlayers_v1() async { + guard NotificationState.chroniclePlayerPurgeNeeded() else { return } + + // Cancel unsent writes first; otherwise a queued clock/presence update + // could recreate a record immediately after the server-side deletion. + if let privateEngine { + let pendingSaves = privateEngine.state.pendingRecordZoneChanges.filter { change in + guard case .saveRecord(let recordID) = change else { return false } + return Self.isChroniclePlayerRecordID(recordID) + } + if !pendingSaves.isEmpty { + privateEngine.state.remove(pendingRecordZoneChanges: pendingSaves) + } + } + + do { + let records = try await queryRecords( + type: "Player", + database: container.privateCloudDatabase, + zoneID: Archive.zoneID, + predicate: NSPredicate(value: true), + desiredKeys: [] + ) + try await deleteRecords( + withIDs: records.map(\.recordID), + in: container.privateCloudDatabase + ) + NotificationState.markChroniclePlayersPurged() + await trace( + "Chronicle Player purge: cancelled pending saves and deleted " + + "\(records.count) record(s)" + ) + } catch { + await trace("Chronicle Player purge failed: \(describe(error))") + } + } + + /// Pure classifier shared with tests so the migration cannot remove a + /// Player record from a live game zone. + nonisolated static func isChroniclePlayerRecordID(_ recordID: CKRecord.ID) -> Bool { + recordID.zoneID == Archive.zoneID + && RecordSerializer.parsePlayerRecordName(recordID.recordName) != nil + } + private func purgeLegacyPlayPings( in zoneIDs: [CKRecordZone.ID], database: CKDatabase diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -106,6 +106,7 @@ enum NotificationState { private static let staleHailPurgeKey = "migration.staleHailPurge.v1" private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1" private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1" + private static let chroniclePlayerPurgeKey = "migration.chroniclePlayerPurge.v1" private static let friendMailboxMigrationKey = "migration.friendMailboxes.v1" /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings @@ -172,6 +173,17 @@ enum NotificationState { defaults?.set(true, forKey: legacyPlayPingPurgeKey) } + /// True until this device has successfully removed the `Player` records + /// written into the Chronicle zone by the first v1.1 builds. + static func chroniclePlayerPurgeNeeded() -> Bool { + defaults?.bool(forKey: chroniclePlayerPurgeKey) == false + } + + /// Records that the Chronicle-zone Player cleanup completed successfully. + static func markChroniclePlayersPurged() { + defaults?.set(true, forKey: chroniclePlayerPurgeKey) + } + /// True if the one-shot migration of pre-mailbox friendships (a single /// elected-owner shared zone) to the two-mailbox model has not yet run on /// this device. diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -98,6 +98,28 @@ struct ArchiveTests { private let aliceKey = JournalDeviceKey(authorID: "alice", deviceID: "deviceA") private let bobKey = JournalDeviceKey(authorID: "bob", deviceID: "deviceB") + @Test("Chronicle Player cleanup is confined to Player IDs in the archive zone") + func chroniclePlayerCleanupScope() { + let gameID = UUID() + let playerName = "player-\(gameID.uuidString)-alice" + let archivePlayer = CKRecord.ID(recordName: playerName, zoneID: Archive.zoneID) + let livePlayer = CKRecord.ID( + recordName: playerName, + zoneID: CKRecordZone.ID( + zoneName: gameID.uuidString, + ownerName: CKCurrentUserDefaultName + ) + ) + let chronicle = CKRecord.ID( + recordName: "chronicle-\(gameID.uuidString)", + zoneID: Archive.zoneID + ) + + #expect(SyncEngine.isChroniclePlayerRecordID(archivePlayer)) + #expect(!SyncEngine.isChroniclePlayerRecordID(livePlayer)) + #expect(!SyncEngine.isChroniclePlayerRecordID(chronicle)) + } + private func sampleSnapshot(originalGameID: UUID) -> Archive.Snapshot { Archive.Snapshot( originalGameID: originalGameID, @@ -665,6 +687,7 @@ struct ArchiveTests { ) let (game, mutator) = try store.loadGame(id: entity.id!) #expect(!mutator.isShared) + #expect(mutator.isArchived) #expect(mutator.showsPlayerAttribution) #expect(game.squares[0][0].letterAuthorID == "alice") #expect(game.squares[0][1].letterAuthorID == "bob")