commit 62b669051b89c5197485697d3bf8c8523e7fbd22 parent 770f87759da7c4499080e987436cf34af7fe637c Author: Michael Camilleri <[email protected]> Date: Sun, 5 Jul 2026 22:25:50 +0900 Cut over to a fresh v4 CloudKit container CloudKit's Production schema is append-only, so the breaking schema changes that had accumulated — a misleading field name, a handful of dead or redundant fields, and a value stored under the wrong type — could only land in a new container. This commit points the synced game store at iCloud.net.inqk.crossmate.v4 and gives it a revised schema, captured in cloudkit.ckdb. A true cross-container migration is not possible, since CKShares and the friend graph cannot be copied, so existing data does not carry across. On first launch against v4, migrateOffLegacyContainerIfNeeded detects data left behind by an earlier build — either in the local store or by probing the retained v3 container — clears this device's cache through purgeLocalData, and shows a one-time 'Data Deleted' notice from the Game List. A fresh install starts clean and sees nothing. The wipe is local only; the dormant v3 data is left untouched in its own container. Two supporting fixes ride along. acceptShare now throws CloudServiceError.containerVersionMismatch when a tapped share belongs to a different container generation, so a player on an incompatible app version sees an 'Update Needed' notice rather than a generic failure. Co-Authored-By: Claude Opus 4.8 <[email protected]> Diffstat:
36 files changed, 558 insertions(+), 182 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ 609364CB79E0C7517298B404 /* ContentKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAFA466405AABA1C06272795 /* ContentKeyDirectory.swift */; }; 61F8B38587EE49D376B53544 /* ReplayCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */; }; 648EA338E7718079EA354D7D /* EngagementMessageAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3E0C62CBF5ADF7C01C1D54 /* EngagementMessageAuthenticator.swift */; }; + 659F7E48357E164144A19789 /* CloudContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E68FD021EF8ED50BC706EE /* CloudContainer.swift */; }; 6850EAE474E589CE1EA2DF68 /* NicknameDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92168360625ECDD36FF50EE8 /* NicknameDirectoryTests.swift */; }; 689DAEC70934027E76E8116E /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; @@ -100,6 +101,7 @@ 6D2AF361587E43D807BA212F /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1747D4DCB4BCC831069BBE07 /* NYTLoginView.swift */; }; 6E36ED34ACF047BABB3E2D69 /* RecentChangesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B377D237AC14B9856579E1 /* RecentChangesTests.swift */; }; 6E67C0DCB0416F382EA065B7 /* JournalUploadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD9C72266D1BAC43C8976C0 /* JournalUploadTests.swift */; }; + 701BD90172F500B89E32E8E6 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61768D423564A9080414A38 /* NoticeView.swift */; }; 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; }; 7714B1C2FBCBBAD9BE8FEAF8 /* GameSummaryThumbnailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5EEBF169823C172000FC45B /* GameSummaryThumbnailTests.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; @@ -389,6 +391,7 @@ 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; }; + 98E68FD021EF8ED50BC706EE /* CloudContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudContainer.swift; sourceTree = "<group>"; }; 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; }; @@ -399,6 +402,7 @@ A24FD313982C8094CD020106 /* EngagementMessageAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngagementMessageAuthenticatorTests.swift; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; + A61768D423564A9080414A38 /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = "<group>"; }; A8C18E9B47668E008BE4CF86 /* Archive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Archive.swift; sourceTree = "<group>"; }; A8CA0FC750259EB1D762B0EE /* OpenPuzzleBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenPuzzleBannerTests.swift; sourceTree = "<group>"; }; A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneOrphaningTests.swift; sourceTree = "<group>"; }; @@ -742,6 +746,7 @@ 065CD67A1D9F7B63AE6B42D6 /* FriendAvatarView.swift */, 6B1F07B5DDE2A8B49B28392A /* GridThumbnailView.swift */, 836B8D4B351C9225162A82C0 /* Layouts.swift */, + A61768D423564A9080414A38 /* NoticeView.swift */, ); path = Components; sourceTree = "<group>"; @@ -829,6 +834,7 @@ 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */, CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */, + 98E68FD021EF8ED50BC706EE /* CloudContainer.swift */, 56BC76178319D0D669CD50FF /* CloudService.swift */, 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */, 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, @@ -1086,6 +1092,7 @@ 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, E9A65F3548DD062FD36A19E1 /* CellPatterns.swift in Sources */, 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */, + 659F7E48357E164144A19789 /* CloudContainer.swift in Sources */, E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, @@ -1154,6 +1161,7 @@ B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, 5E89D1F8FDFE56395997281A /* NewGameSheet.swift in Sources */, 36E2AAF1EE1314E13477EE85 /* NicknameDirectory.swift in Sources */, + 701BD90172F500B89E32E8E6 /* NoticeView.swift in Sources */, 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */, CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */, E6A13F8736ABF41F6346E301 /* ParticipantSummaries.swift in Sources */, diff --git a/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme b/Crossmate.xcodeproj/xcshareddata/xcschemes/Crossmate.xcscheme @@ -4,8 +4,7 @@ version = "1.7"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> + buildImplicitDependencies = "YES"> <PreActions> <ExecutionAction ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> @@ -45,8 +44,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" @@ -70,8 +68,6 @@ </BuildableReference> </TestableReference> </Testables> - <CommandLineArguments> - </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" @@ -93,8 +89,6 @@ ReferencedContainer = "container:Crossmate.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -112,8 +106,6 @@ ReferencedContainer = "container:Crossmate.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -8,6 +8,7 @@ <string>$(APP_ATTEST_ENVIRONMENT)</string> <key>com.apple.developer.icloud-container-identifiers</key> <array> + <string>iCloud.net.inqk.crossmate.v4</string> <string>iCloud.net.inqk.crossmate.v3</string> <string>iCloud.net.inqk.crossmate</string> </array> @@ -18,6 +19,7 @@ </array> <key>com.apple.developer.ubiquity-container-identifiers</key> <array> + <string>iCloud.net.inqk.crossmate.v4</string> <string>iCloud.net.inqk.crossmate.v3</string> <string>iCloud.net.inqk.crossmate</string> </array> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -589,15 +589,18 @@ struct RootView: View { withAnimation { pendingJoin = nil } let code = (error as? CKError)?.code let gone = code == .unknownItem || code == .zoneNotFound + let versionMismatch = (error as? CloudServiceError) == .containerVersionMismatch services.eventLog.note( "share link join failed: \(error.localizedDescription)", - level: gone ? "info" : "error" + level: (gone || versionMismatch) ? "info" : "error" ) services.announcements.post(Announcement( id: "share-link-join-failed", scope: .global, - severity: gone ? .warning : .error, - title: gone ? "Puzzle Removed" : "Accepting Failed", + severity: (gone || versionMismatch) ? .warning : .error, + title: versionMismatch + ? "Update Needed" + : (gone ? "Puzzle Removed" : "Accepting Failed"), body: gone ? "This puzzle was removed." : error.localizedDescription, diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -60,7 +60,7 @@ <attribute name="selCol" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selDir" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> <attribute name="selRow" optional="YES" attributeType="Integer 64" usesScalarValueType="NO"/> - <attribute name="sessionSnapshot" optional="YES" attributeType="Binary"/> + <attribute name="viewedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="timeLog" optional="YES" attributeType="Binary"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="players" inverseEntity="GameEntity"/> diff --git a/Crossmate/Models/GameViewedStore.swift b/Crossmate/Models/GameViewedStore.swift @@ -59,13 +59,3 @@ final class GameViewedStore { set { defaults.set(newValue, forKey: defaultsKey) } } } - -/// The device-local "last viewed" cutoff, shipped across the account's own -/// devices on `Player.sessionSnapshot` so a sibling converges on the latest -/// view time rather than recomputing from its own (possibly stale) view. Adopted -/// monotonically via `GameViewedStore.advance`. Encoded with the default -/// `JSONEncoder`, so an older per-peer-snapshot payload from a not-yet-upgraded -/// sibling simply fails to decode and is ignored (per-device fallback). -struct SeenBaseline: Codable, Equatable { - let viewedAt: Date -} diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -26,7 +26,7 @@ final class PlayerPreferences { } private let local: UserDefaults - private let cloud: NSUbiquitousKeyValueStore + private let cloud: NSUbiquitousKeyValueStore? var colorID: String { didSet { write(Keys.colorID, colorID) } @@ -81,14 +81,14 @@ final class PlayerPreferences { init( local: UserDefaults = .standard, - cloud: NSUbiquitousKeyValueStore = .default + cloud: NSUbiquitousKeyValueStore? = .default ) { self.local = local self.cloud = cloud - self.colorID = cloud.string(forKey: Keys.colorID) + self.colorID = cloud?.string(forKey: Keys.colorID) ?? local.string(forKey: Keys.colorID) ?? PlayerColor.blue.id - let storedName = cloud.string(forKey: Keys.name) + let storedName = cloud?.string(forKey: Keys.name) ?? local.string(forKey: Keys.name) self.name = storedName ?? "Player" self.hasName = storedName.map(Self.isUsableName) ?? false @@ -98,6 +98,7 @@ final class PlayerPreferences { self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true + guard let cloud else { return } cloud.synchronize() NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, @@ -110,12 +111,25 @@ final class PlayerPreferences { } } + /// A throwaway instance that touches neither iCloud nor the standard + /// defaults, so a demo/marketing seed's scratch name and colour can't leak + /// into a real launch. The persistent path shares one account-wide + /// `NSUbiquitousKeyValueStore`, so a plain instance used for the demo would + /// sync "You" to every device on the Apple ID. + static func ephemeral() -> PlayerPreferences { + let domain = "net.inqk.crossmate.demo" + let suite = UserDefaults(suiteName: domain) + suite?.removePersistentDomain(forName: domain) + return PlayerPreferences(local: suite ?? .standard, cloud: nil) + } + private func write(_ key: String, _ value: String) { local.set(value, forKey: key) - cloud.set(value, forKey: key) + cloud?.set(value, forKey: key) } private func pullFromCloud() { + guard let cloud else { return } if let id = cloud.string(forKey: Keys.colorID), id != colorID { colorID = id } diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -173,7 +173,7 @@ final class PlayerRoster { self.authorIdentity = AuthorIdentity(testing: "marketing-local") self.preferences = PlayerPreferences() self.persistence = PersistenceController(inMemory: true) - self.container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + self.container = CloudContainer.container self.engagementStore = EngagementStore() self.tracer = nil self.isStaticPreview = true diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -1667,6 +1667,14 @@ final class GameStore { return fresh } + /// Whether any game rows remain in the local store — the cheap, offline + /// signal that this build carried data over from a previous CloudKit + /// generation (used by the v3→v4 transition ahead of any network probe). + func hasAnyGames() -> Bool { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + return ((try? context.count(for: request)) ?? 0) > 0 + } + // MARK: - Reset /// Deletes every game (and its cascaded moves, snapshots, and cells) plus @@ -1770,10 +1778,12 @@ final class GameStore { /// /// This is *not* the read watermark — see `advanceReadThrough`. The two were /// historically the same field, which is why `lastReadOtherMoveAt` ships on - /// the wire as `Player.readAt`. - /// TODO(v4): rename the `readAt` CloudKit field (and `lastReadOtherMoveAt`) - /// to something like `presenceLeaseUntil` — it is a presence horizon, not a - /// read position, now that `readThrough` carries the latter. + /// the wire as the `presenceUntil` CKRecord field (renamed from `readAt` in + /// the v4 schema). + /// TODO: the local `lastReadOtherMoveAt` Core Data attribute still carries + /// the misleading old name — an internal-only rename to match `presenceUntil` + /// was deferred (it collides with unrelated identifiers and the store is + /// wiped on the v4 transition anyway, so it has no external effect). @discardableResult func setReadCursor( gameID: UUID, @@ -1920,13 +1930,12 @@ final class GameStore { ) } - /// Persists `data` (an encoded `SeenBaseline`) onto this account's own - /// `Player.sessionSnapshot` for `gameID` — the "last viewed" cutoff, - /// shipped on the Player record so sibling devices adopt it rather than - /// recomputing from their own view. Creates a stub PlayerEntity if none - /// exists yet, keyed by the deterministic `ckRecordName`. No-op if the - /// GameEntity is missing. - func setSessionSnapshot(_ data: Data?, gameID: UUID, authorID: String) { + /// Persists `viewedAt` onto this account's own `Player.viewedAt` for + /// `gameID` — the "last viewed" cutoff, shipped on the Player record so + /// sibling devices adopt it rather than recomputing from their own view. + /// Creates a stub PlayerEntity if none exists yet, keyed by the + /// deterministic `ckRecordName`. No-op if the GameEntity is missing. + func setViewedAt(_ viewedAt: Date?, gameID: UUID, authorID: String) { let entity: PlayerEntity if let existing = fetchPlayerEntity(gameID: gameID, authorID: authorID) { entity = existing @@ -1944,8 +1953,8 @@ final class GameStore { ) entity.updatedAt = Date() } - entity.sessionSnapshot = data - saveContext("setSessionSnapshot") + entity.viewedAt = viewedAt + saveContext("setViewedAt") } // MARK: - Solve-time clock diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -320,7 +320,7 @@ final class AppServices { let preferences: PlayerPreferences - private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + private let ckContainer = CloudContainer.container private var started = false private var syncStarted = false /// In-flight `ensureICloudSyncStarted()` work, shared by concurrent @@ -394,8 +394,6 @@ final class AppServices { } init() { - let preferences = PlayerPreferences() - self.preferences = preferences let eventLog = EventLog() self.eventLog = eventLog // `--crossmate-seed-demo` (set in the Run scheme's arguments) brings the @@ -404,10 +402,22 @@ final class AppServices { // strips and the friends list can be eyeballed in the Simulator without // iCloud. It never touches the real on-disk store. let isDemoSeed = ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo") + // The demo seed writes a scratch profile name; give it a throwaway + // preferences store so that write can't sync into a real launch via the + // shared iCloud key-value store. + let preferences = isDemoSeed ? PlayerPreferences.ephemeral() : PlayerPreferences() + self.preferences = preferences let persistence = PersistenceController(inMemory: isDemoSeed, eventLog: eventLog) self.persistence = persistence if isDemoSeed { DemoSeed.populate(persistence: persistence, preferences: preferences) + // Preview the one-time v4 reset notice on demand + // (`run-demo.sh --v4-notice`); set explicitly so a normal demo run + // clears any leftover flag on the reused demo simulator. + UserDefaults.standard.set( + ProcessInfo.processInfo.arguments.contains("--crossmate-show-v4-notice"), + forKey: Self.showV4NoticeDefaultsKey + ) } let syncEngine = SyncEngine(container: self.ckContainer, persistence: persistence) self.syncEngine = syncEngine @@ -648,10 +658,68 @@ final class AppServices { } } + private static let cloudGenerationKey = "cloudGeneration" + private static let currentCloudGeneration = 4 + /// UserDefaults flag that drives the one-time v4 reset notice; read by + /// `GameListView` via `@AppStorage`. See `NoticeView`. + static let showV4NoticeDefaultsKey = "showV4Notice" + + /// Detects a device carrying data from the pre-v4 CloudKit container, wipes + /// the local cache of it, and flags the one-time explanatory notice. + /// Idempotent via a stored generation marker, so it runs at most once per + /// device across the v3→v4 boundary. The wipe is *local only* + /// (`purgeLocalData`): the v3 container is abandoned, not deleted — we can't + /// reach co-owners' shared zones anyway, and dormant v3 data is harmless. + private func migrateOffLegacyContainerIfNeeded() async { + // The demo seed brings up a throwaway in-memory store that is not a real + // v3→v4 transition — never purge it. (The notice can still be previewed + // via the flag set at seed time.) + guard !ProcessInfo.processInfo.arguments.contains("--crossmate-seed-demo") + else { return } + let defaults = UserDefaults.standard + guard defaults.integer(forKey: Self.cloudGenerationKey) < Self.currentCloudGeneration + else { return } + + if await deviceHasLegacyData() { + do { + try await cloudService.purgeLocalData() + } catch { + syncMonitor.note("v4 transition purge failed — \(error)") + } + defaults.set(true, forKey: Self.showV4NoticeDefaultsKey) + syncMonitor.note("Migrated device off legacy CloudKit container — local cache cleared for v4") + } + defaults.set(Self.currentCloudGeneration, forKey: Self.cloudGenerationKey) + } + + /// Pre-v4 data signal: the local store first (offline, instant), then — for + /// a reinstalled device with an empty store — a probe of the legacy v3 + /// container for any custom zone (an `account` or per-game zone). A probe + /// failure (signed out / offline) reports `false`, so no spurious notice + /// shows. + private func deviceHasLegacyData() async -> Bool { + if store.hasAnyGames() { return true } + do { + let zones = try await CloudContainer.legacyContainer + .privateCloudDatabase.allRecordZones() + let defaultZoneName = CKRecordZone.default().zoneID.zoneName + return zones.contains { $0.zoneID.zoneName != defaultZoneName } + } catch { + return false + } + } + func start(appDelegate: AppDelegate) async { guard !started else { return } started = true + // One-time transition off the pre-v4 CloudKit container. The v4 build + // points at a brand-new, empty container, so any games this device + // cached under v3 are orphans that can never sync again — wipe them and + // flag the explanatory notice. Runs before sync starts so the library + // never shows the stale rows. + await migrateOffLegacyContainerIfNeeded() + // Surface one onboarding tip per cold launch. The in-memory // AnnouncementCenter is empty on a fresh process, so this re-posts the // next undismissed tip on each cold start; a warm resume doesn't re-run @@ -822,7 +890,7 @@ final class AppServices { // horizon bump and leaves delivered notifications untouched. await syncEngine.setOnIncomingReadCursor { [weak self, store, gameViewedStore] pairs in let now = Date() - for (gameID, readAt, seenBaselineData) in pairs { + for (gameID, readAt, viewedAt) in pairs { let (previous, adopted) = store.noteIncomingReadCursor(gameID: gameID, readAt: readAt) self?.syncMonitor.note( "cursor ADOPT[\(gameID.uuidString.prefix(8))] src=sync " + @@ -830,14 +898,12 @@ final class AppServices { "was=\(previous?.ISO8601Format() ?? "—")" + (adopted ? "" : " (no-op)") ) - // A sibling device shipped its "last viewed" baseline on its own - // `Player.sessionSnapshot`; fold it in monotonically so we - // converge on the latest view time across the account rather than - // recomputing from this device's (possibly stale) local view. An - // older or old-format payload simply fails to advance / decode. - if let data = seenBaselineData, - let baseline = try? JSONDecoder().decode(SeenBaseline.self, from: data) { - gameViewedStore.advance(baseline.viewedAt, forGame: gameID) + // A sibling device shipped its "last viewed" cutoff on its own + // `Player.viewedAt`; fold it in monotonically so we converge on + // the latest view time across the account rather than + // recomputing from this device's (possibly stale) local view. + if let viewedAt { + gameViewedStore.advance(viewedAt, forGame: gameID) } if readAt > now { await self?.badge.dismissDeliveredNotifications( @@ -1769,7 +1835,7 @@ final class AppServices { (adopted ? "" : " (no-op)") ) // The catch-up baseline is no longer recomputed here — it arrives, - // accurate, on the sibling's `Player.sessionSnapshot` via the + // accurate, on the sibling's `Player.viewedAt` via the // record sync this push's companion DB change triggers. This fast // push is now only the cross-device notification-dismissal signal. // A forward-dated readAt is an active presence lease: the sibling is diff --git a/Crossmate/Services/CloudContainer.swift b/Crossmate/Services/CloudContainer.swift @@ -0,0 +1,24 @@ +import CloudKit + +/// Central identifiers for Crossmate's CloudKit containers. +/// +/// Synced game data lives in a *versioned* container. A breaking schema change +/// spins up a fresh container rather than mutating the append-only Production +/// schema in place; the app points at the current generation and abandons the +/// previous one's data. `legacyIdentifier` is retained only so a build can +/// still *read* the prior container to detect a user carrying pre-v4 data (the +/// v3→v4 reset flow), and can be dropped a release after launch. +/// +/// Bump `identifier` and add the new id to `Crossmate.entitlements` together on +/// the next breaking schema change. +enum CloudContainer { + /// The current synced-game container. + static let identifier = "iCloud.net.inqk.crossmate.v4" + + /// The immediately-previous generation, kept readable one release for the + /// pre-v4 data probe. Remove once all users have moved off it. + static let legacyIdentifier = "iCloud.net.inqk.crossmate.v3" + + static var container: CKContainer { CKContainer(identifier: identifier) } + static var legacyContainer: CKContainer { CKContainer(identifier: legacyIdentifier) } +} diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -5,6 +5,22 @@ extension Notification.Name { static let cloudShareAcceptanceCompleted = Notification.Name("cloudShareAcceptanceCompleted") } +/// Errors from `CloudService` that need distinct user-facing handling. +enum CloudServiceError: Error, LocalizedError, Equatable { + /// A tapped share belongs to a different CloudKit container generation than + /// this build uses — the sharer and joiner are on incompatible app versions + /// (e.g. a not-yet-updated peer taps a link to a v4 game, or vice versa). + case containerVersionMismatch + + var errorDescription: String? { + switch self { + case .containerVersionMismatch: + return "This game is from a different version of Crossmate. " + + "Make sure you and the other player are both on the latest version." + } + } +} + @MainActor final class CloudService { private let ckContainer: CKContainer @@ -99,7 +115,7 @@ final class CloudService { "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " + "expected=\(ckContainer.containerIdentifier ?? "nil")" ) - throw CKError(.permissionFailure) + throw CloudServiceError.containerVersionMismatch } // The share names the game it covers: Crossmate uses zone-wide shares, // so the metadata's zone ("game-<UUID>") identifies the game outright. diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -572,7 +572,7 @@ final class SessionCoordinator { /// Drops a still-pending banner timer and advances the local "last viewed" /// baseline — the user has now seen what's on screen, so the next open diffs /// against this moment — then ships that baseline to sibling devices on this - /// account's own `Player.sessionSnapshot`, so they converge on the latest + /// account's own `Player.viewedAt`, so they converge on the latest /// view time rather than recomputing from their own view. The advance is /// monotonic, so it is harmless that `CrossmateApp`'s leave handler also /// stamps the baseline. @@ -581,18 +581,17 @@ final class SessionCoordinator { sealClockSession(gameID: gameID) gameViewedStore.advance(Date(), forGame: gameID) guard let authorID = identity.currentID, !authorID.isEmpty, - let viewedAt = gameViewedStore.lastViewed(forGame: gameID), - let data = try? JSONEncoder().encode(SeenBaseline(viewedAt: viewedAt)) + let viewedAt = gameViewedStore.lastViewed(forGame: gameID) else { return } // Write it onto our own Player record and enqueue the send. This also // rides the leave's read-cursor Player write, but enqueuing directly // guarantees it ships even when that write is a no-op. - store.setSessionSnapshot(data, gameID: gameID, authorID: authorID) + store.setViewedAt(viewedAt, gameID: gameID, authorID: authorID) let syncEngine = self.syncEngine // Leave-path Player write: enqueue durably but don't force a drain that // would race the suspension budget — siblings adopt the baseline on the // next CKSyncEngine sync. - Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "sessionSnapshot", drain: false) } + Task { await syncEngine.enqueuePlayer(gameID: gameID, authorID: authorID, reason: "viewedAt", drain: false) } } /// Opens (or, on resume, refreshes the heartbeat of) the local device's diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -142,24 +142,31 @@ struct Ping: Sendable { static func parseRecord(_ record: CKRecord, fetchedFrom scope: DatabaseScope?) -> Ping? { let name = record.recordID.recordName + // Identity lives in the record name (like Moves/Player/Journal). The + // zone-name branch is a fallback for a record whose name isn't a ping + // name; it can only recover the gameID, not the author/device. let gameID: UUID? - if name.hasPrefix("ping-") { - let rest = name.dropFirst("ping-".count) - gameID = UUID(uuidString: String(rest.prefix(36))) + let authorID: String? + let deviceID: String + if let (parsedGameID, parsedAuthor, parsedDevice) = + RecordSerializer.parsePingRecordName(name) { + gameID = parsedGameID + authorID = parsedAuthor + deviceID = parsedDevice } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) + authorID = nil + deviceID = "" } else { gameID = nil + authorID = nil + deviceID = "" } guard let gameID, - let authorID = record["authorID"] as? String, + let authorID, let kindRaw = record["kind"] as? String, let kind = PingKind(rawValue: kindRaw) else { return nil } - // Legacy records written before the schema added `deviceID` won't have - // the field. Parse-tolerant: empty string can never equal a real - // localDeviceID, so the self-send filter stays safe. - let deviceID = (record["deviceID"] as? String) ?? "" return Ping( recordName: name, gameID: gameID, diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -23,10 +23,10 @@ struct BatchEffects { var contentKeysChanged = Set<UUID>() var removed = Set<UUID>() /// Per-game incoming read cursor from one of *our own* devices: the - /// `readAt` horizon plus the encoded per-peer "seen" baseline that sibling - /// shipped on its `Player.sessionSnapshot` (nil on older writes). Drives + /// presence-lease time plus the sibling's "last viewed" cutoff that it + /// shipped on its `Player.viewedAt` (nil until it has left a game). Drives /// cross-device baseline adoption. - var readCursors: [(UUID, Date, Data?)] = [] + var readCursors: [(UUID, Date, Date?)] = [] /// Games that just transitioned to completed via an inbound Game record, so /// this device uploads its journal for replay even though it didn't run the /// local completion path. @@ -223,7 +223,7 @@ extension SyncEngine { localAuthorID: String?, onFirstTime: (UUID) -> Void, onPresenceChange: (UUID) -> Void, - onReadCursor: (UUID, Date, Data?) -> Void + onReadCursor: (UUID, Date, Date?) -> Void ) { let ckName = record.recordID.recordName guard let (gameID, authorID) = RecordSerializer.parsePlayerRecordName(ckName) else { @@ -295,12 +295,12 @@ extension SyncEngine { // while C is still here" representable without the dip would need // per-device Player rows, which don't exist (one row per author). let previousReadAt = entity.readAt - let previousSessionSnapshot = entity.sessionSnapshot + let previousViewedAt = entity.viewedAt let incomingReadAt = RecordSerializer.parsePlayerReadAt(from: record) entity.readAt = incomingReadAt let incomingReadThrough = RecordSerializer.parsePlayerReadThrough(from: record) entity.readThrough = incomingReadThrough - entity.sessionSnapshot = RecordSerializer.parsePlayerSessionSnapshot(from: record) + entity.viewedAt = RecordSerializer.parsePlayerViewedAt(from: record) // `timeLog` is the device-keyed solve-time log. Touch it only when the // fetched record actually carries the field. A partial fetch (CloudQuery // restricts `desiredKeys`) or an older record omits it, and because the @@ -332,8 +332,8 @@ extension SyncEngine { // and adopting it would rewind the just-minted lease and trigger a // redundant re-assert save (the open-time double mint). if authorID == localAuthorID, let readAt = incomingReadAt, - readAt != previousReadAt || entity.sessionSnapshot != previousSessionSnapshot { - onReadCursor(gameID, readAt, entity.sessionSnapshot) + readAt != previousReadAt || entity.viewedAt != previousViewedAt { + onReadCursor(gameID, readAt, entity.viewedAt) } // Our own record coming back from another of this account's devices // carries that device's read watermark. Adopt it monotonically onto the diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift @@ -135,7 +135,7 @@ extension SyncEngine { selection: selection, readAt: entity.game?.lastReadOtherMoveAt, readThrough: entity.game?.readThroughAt, - sessionSnapshot: entity.sessionSnapshot, + viewedAt: entity.viewedAt, timeLog: entity.timeLog, pushAddress: entity.pushAddress, zone: zoneID, diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -14,35 +14,34 @@ enum RecordSerializer { "completedAt", "completedBy", "shareRecordName", - "engagement", - "notification", + "roomCredential", + "pushCredential", "puzzleSource", ] + // authorID/deviceID are not stored as fields — they're recovered from the + // record name (`moves-<gameID>-<authorID>-<deviceID>`). static let movesDesiredKeys: [CKRecord.FieldKey] = [ - "authorID", - "deviceID", "cells", "updatedAt", ] + // authorID is recovered from the record name (`player-<gameID>-<authorID>`). static let playerDesiredKeys: [CKRecord.FieldKey] = [ - "authorID", "name", "updatedAt", "selRow", "selCol", "selDir", - "readAt", + "presenceUntil", "readThrough", - "sessionSnapshot", + "viewedAt", "timeLog", "pushAddress", ] + // authorID/deviceID are recovered from the record name. static let pingDesiredKeys: [CKRecord.FieldKey] = [ - "authorID", - "deviceID", "playerName", "puzzleTitle", "kind", @@ -51,7 +50,6 @@ enum RecordSerializer { ] static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [ - "authorID", "kind", ] @@ -335,8 +333,7 @@ enum RecordSerializer { systemFields: systemFields ) - record["authorID"] = view.authorID as CKRecordValue - record["deviceID"] = view.deviceID as CKRecordValue + // authorID/deviceID live in the record name, not as fields. record["updatedAt"] = view.updatedAt as CKRecordValue record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue @@ -363,8 +360,7 @@ enum RecordSerializer { let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID) let recordID = CKRecord.ID(recordName: name, zoneID: zone) let record = CKRecord(recordType: "Journal", recordID: recordID) - record["authorID"] = authorID as CKRecordValue - record["deviceID"] = deviceID as CKRecordValue + // authorID/deviceID live in the record name, not as fields. record["updatedAt"] = updatedAt as CKRecordValue let data = try JournalCodec.encode(entries) @@ -427,13 +423,15 @@ enum RecordSerializer { // EngagementRoomCredentials). Any present participant may mint these // when the field is empty; convergence is plain record-level LWW, and // peers connect to whatever creds the field currently holds. - record["engagement"] = entity.engagement as CKRecordValue? + // Stored as UTF-8 BYTES (matching cells/timeLog), not STRING — these + // are opaque JSON blobs, not queryable text. + record["roomCredential"] = entity.engagement?.data(using: .utf8) as CKRecordValue? // The shared per-game notification credentials (encoded // GamePushCredentials: the push auth secret + credID, plus the // worker-blind content key the payload is encrypted under). Synced to // participants like `engagement`; any participant may mint it when // empty, and record-level LWW converges concurrent mints. - record["notification"] = entity.notification as CKRecordValue? + record["pushCredential"] = entity.notification?.data(using: .utf8) as CKRecordValue? guard includePuzzleSource, let source = entity.puzzleSource else { return } let url = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) @@ -470,8 +468,7 @@ enum RecordSerializer { ) let recordID = CKRecord.ID(recordName: name, zoneID: zone) let record = CKRecord(recordType: "Ping", recordID: recordID) - record["authorID"] = authorID as CKRecordValue - record["deviceID"] = deviceID as CKRecordValue + // authorID/deviceID live in the record name, not as fields. record["playerName"] = playerName as CKRecordValue record["puzzleTitle"] = puzzleTitle as CKRecordValue record["kind"] = kind.rawValue as CKRecordValue @@ -512,7 +509,6 @@ enum RecordSerializer { ) record["kind"] = kind as CKRecordValue record["payload"] = payload.map { $0 as CKRecordValue } - record["createdAt"] = Date() as CKRecordValue record["version"] = version.map { $0 as CKRecordValue } return record } @@ -525,7 +521,7 @@ enum RecordSerializer { selection: PlayerSelection?, readAt: Date? = nil, readThrough: Date? = nil, - sessionSnapshot: Data? = nil, + viewedAt: Date? = nil, timeLog: Data? = nil, pushAddress: String? = nil, zone: CKRecordZone.ID, @@ -539,7 +535,7 @@ enum RecordSerializer { systemFields: systemFields ) - record["authorID"] = authorID as CKRecordValue + // authorID lives in the record name, not as a field. record["name"] = name as CKRecordValue record["updatedAt"] = updatedAt as CKRecordValue if let selection { @@ -551,12 +547,14 @@ enum RecordSerializer { record["selCol"] = nil record["selDir"] = nil } - // `readAt` is the forward-dated presence lease (see `GameStore`'s - // TODO(v4): this field should be renamed to a presence name). + // The forward-dated presence lease. Shipped as the `presenceUntil` + // CKRecord field (renamed from `readAt` in the v4 schema); the local + // `readAt` parameter and Core Data attributes still carry the old name + // — an internal rename deliberately left for later. if let readAt { - record["readAt"] = readAt as CKRecordValue + record["presenceUntil"] = readAt as CKRecordValue } else { - record["readAt"] = nil + record["presenceUntil"] = nil } // `readThrough` is the true read watermark — the latest other-author // move time this account has actually seen. Never forward-dated, so a @@ -566,10 +564,10 @@ enum RecordSerializer { } else { record["readThrough"] = nil } - if let sessionSnapshot { - record["sessionSnapshot"] = sessionSnapshot as CKRecordValue + if let viewedAt { + record["viewedAt"] = viewedAt as CKRecordValue } else { - record["sessionSnapshot"] = nil + record["viewedAt"] = nil } // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`): // each device's active-play intervals plus its open session. Unlike the @@ -598,7 +596,7 @@ enum RecordSerializer { /// Returns `nil` if the field is missing — older records, or a slot that /// has not yet recorded a view. static func parsePlayerReadAt(from record: CKRecord) -> Date? { - record["readAt"] as? Date + record["presenceUntil"] as? Date } /// Reads `readThrough` off an inbound Player record — the per-account read @@ -624,16 +622,13 @@ enum RecordSerializer { return PlayerSelection(row: Int(row), col: Int(col), direction: direction) } - /// Reads `sessionSnapshot` off an inbound Player record — the encoded - /// `SeenBaseline` (this account's "last viewed" cutoff), written on leave. - /// Shared across the author's own devices so a sibling converges on the - /// latest view time rather than recomputing the catch-up baseline from its - /// own (possibly stale) view. Returns `nil` on older records or when the - /// account has not yet left a game with peers. (Pre-unification builds wrote - /// a per-peer Moves-snapshot map here, which simply fails to decode as a - /// `SeenBaseline` and is ignored — a per-device fallback.) - static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? { - record["sessionSnapshot"] as? Data + /// Reads `viewedAt` off an inbound Player record — this account's "last + /// viewed" cutoff, written on leave and shared across the author's own + /// devices so a sibling converges on the latest view time rather than + /// recomputing the catch-up baseline from its own (possibly stale) view. + /// Returns `nil` when the account has not yet left a game with peers. + static func parsePlayerViewedAt(from record: CKRecord) -> Date? { + record["viewedAt"] as? Date } /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog` @@ -739,6 +734,34 @@ enum RecordSerializer { return (gameID, authorID, deviceID) } + /// Parses `ping-<gameUUID>-<authorID>-<deviceID>-<eventTimestampMs>` into + /// its identity components. Splits from the right: the trailing + /// `eventTimestampMs` is dash-free digits and `deviceID` is a dash-free hex + /// id, so both peel off cleanly; `authorID` is the remainder and may itself + /// contain dashes (same tolerance as `parseMovesRecordName`). Returns nil if + /// the name isn't a ping name or the UUID doesn't parse. + static func parsePingRecordName(_ name: String) -> (UUID, String, String)? { + let prefix = "ping-" + guard name.hasPrefix(prefix) else { return nil } + let rest = name.dropFirst(prefix.count) + let uuidLength = 36 + guard rest.count > uuidLength, + rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" + else { return nil } + let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) + guard let gameID = UUID(uuidString: uuidPart) else { return nil } + let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) + // `<authorID>-<deviceID>-<eventTimestampMs>` + let tail = rest[afterUUID...] + guard let tsDash = tail.lastIndex(of: "-") else { return nil } + let authorAndDevice = tail[tail.startIndex..<tsDash] + guard let deviceDash = authorAndDevice.lastIndex(of: "-") else { return nil } + let authorID = String(authorAndDevice[authorAndDevice.startIndex..<deviceDash]) + let deviceID = String(authorAndDevice[authorAndDevice.index(after: deviceDash)...]) + guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } + return (gameID, authorID, deviceID) + } + // MARK: - Applying incoming CKRecords to Core Data /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if @@ -873,7 +896,8 @@ enum RecordSerializer { // still pushing, via the hasPendingSave guard). A change here is the // signal a peer minted/rotated the room, so the receiver reconciles // its live connection toward the new creds. - let incomingEngagement = record["engagement"] as? String + let incomingEngagement = (record["roomCredential"] as? Data) + .flatMap { String(data: $0, encoding: .utf8) } if entity.engagement != incomingEngagement { entity.engagement = incomingEngagement if let id = entity.id { onEngagementChange?(id) } @@ -885,7 +909,8 @@ enum RecordSerializer { // `onContentKeyChange` to re-mirror the App Group key directory the NSE // reads. This is what lets a freshly-joined participant decrypt the // first push it receives, rather than waiting for the next launch heal. - let incomingNotification = record["notification"] as? String + let incomingNotification = (record["pushCredential"] as? Data) + .flatMap { String(data: $0, encoding: .utf8) } if entity.notification != incomingNotification { entity.notification = incomingNotification if let id = entity.id { onContentKeyChange?(id) } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -190,7 +190,7 @@ actor SyncEngine { /// whose authorID matches the local user. A sibling device has recorded /// the account's read horizon; active sessions may move it into the near /// future and later close it with a lower current-time value. - var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)? + var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Date?)]) async -> Void)? var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)? var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)? var onBlockedFriendsChanged: (@MainActor @Sendable (Set<String>) async -> Void)? @@ -279,7 +279,7 @@ actor SyncEngine { onPingDeleted = cb } - func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Data?)]) async -> Void) { + func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Date?)]) async -> Void) { onIncomingReadCursor = cb } diff --git a/Crossmate/Views/Components/NoticeView.swift b/Crossmate/Views/Components/NoticeView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +/// One-time explanatory sheet shown when a device is moved off the pre-v4 +/// CloudKit container and its local cache is wiped for launch. Presented once +/// from the library; the decision to show it is made in +/// `AppServices.migrateOffLegacyContainerIfNeeded` and persisted under +/// `AppServices.showV4NoticeDefaultsKey`. +struct NoticeView: View { + /// Clears the persisted flag so the sheet never shows again. + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 24) { + Spacer(minLength: 0) + + Image(systemName: "trash") + .font(.system(size: 64, weight: .semibold)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + + VStack(spacing: 12) { + Text("All Data Reset") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + + Text( + """ + The production database has been updated in preparation for \ + the public release. This has reset all data (including \ + puzzles). I'm very sorry for the disruption. + """ + ) + .font(.callout) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text( + """ + To share a game, please send the share link to your \ + collaborator(s). After the first game is shared like this, \ + your friendships with these players will be reestablished. + """ + ) + .font(.callout) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + Text( + """ + Again, I am very sorry for the disruption. I'm deeply \ + grateful for all the help developing Crossmate so far. + """ + ) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer(minLength: 0) + + Button(action: onDismiss) { + Text("Got It") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(24) + .padding(.vertical, 16) + .interactiveDismissDisabled() + } +} diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -52,6 +52,7 @@ struct GameListView: View { @State private var showingNewGame = false @State private var showingSettings = false + @AppStorage(AppServices.showV4NoticeDefaultsKey) private var showV4Notice = false @State private var showingFriends = false @State private var newGameInviteTarget: FriendNewGameTarget? @State private var queuedNewGameInviteTarget: FriendNewGameTarget? @@ -133,6 +134,9 @@ struct GameListView: View { .sheet(isPresented: $showingSettings) { SettingsView() } + .sheet(isPresented: $showV4Notice) { + NoticeView { showV4Notice = false } + } .sheet(isPresented: $showingFriends, onDismiss: presentQueuedFriendNewGame) { FriendsView { target in queuedNewGameInviteTarget = target diff --git a/Notes/PresenceTracking.md b/Notes/PresenceTracking.md @@ -84,9 +84,12 @@ self-healing on the next read). Core Data migration is lightweight-additive (two optional `Date` attributes); `PersistenceController.recreateStore` is the fallback if a store can't be opened. -## Follow-up (v4) - -`readAt` is now purely a presence horizon, so its name is misleading. A `TODO(v4)` -note sits at `GameStore.setReadCursor` and `RecordSerializer.playerRecord`: -rename the `readAt` CloudKit field (and `lastReadOtherMoveAt`) to something like -`presenceLeaseUntil` when the schema next breaks. +## Follow-up (v4) — done, partially + +`readAt` was purely a presence horizon, so its name was misleading. The v4 +schema renames the CloudKit field to `presenceUntil` (a fresh container, so a +clean rename — see `cloudkit-v4.ckdb`). The local `lastReadOtherMoveAt` Core +Data attribute and the internal `readAt` parameter/variable names still carry +the old name: that internal-only rename was deferred (it collides with +unrelated identifiers and the store is wiped on the v4 transition, so it has no +external effect). diff --git a/Scripts/run-demo.sh b/Scripts/run-demo.sh @@ -12,6 +12,7 @@ # Usage: bash Scripts/run-demo.sh [iOS-major] (iPhone, default major 26) # bash Scripts/run-demo.sh --ipad [iOS-major] (iPad mini) # bash Scripts/run-demo.sh [--ipad] --delete (remove the demo simulator) +# bash Scripts/run-demo.sh --v4-notice (also show the v4 reset notice) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -23,11 +24,13 @@ source "${SCRIPT_DIR}/select-simulator.sh" DEVICE_KIND="iphone" DELETE=false MAJOR=26 +SHOW_V4_NOTICE=false for arg in "$@"; do case "$arg" in --ipad) DEVICE_KIND="ipad" ;; --iphone) DEVICE_KIND="iphone" ;; --delete) DELETE=true ;; + --v4-notice) SHOW_V4_NOTICE=true ;; [0-9]*) MAJOR="$arg" ;; *) echo "Unknown argument: $arg" >&2; exit 1 ;; esac @@ -120,8 +123,12 @@ if [ ! -d "$APP_PATH" ]; then fi xcrun simctl install "$UDID" "$APP_PATH" +# `--v4-notice` appends the launch flag that pops the one-time v4 reset notice +# over the seeded library so it can be eyeballed. Empty otherwise. +V4_NOTICE_FLAG="" +if [ "$SHOW_V4_NOTICE" = true ]; then V4_NOTICE_FLAG="--crossmate-show-v4-notice"; fi xcrun simctl launch --terminate-running-process "$UDID" "$BUNDLE_ID" \ - --crossmate-seed-demo + --crossmate-seed-demo $V4_NOTICE_FLAG # Bring the Simulator back to the front now the app is up. open -a Simulator diff --git a/Tests/Unit/AccountPushCoordinatorTests.swift b/Tests/Unit/AccountPushCoordinatorTests.swift @@ -52,7 +52,7 @@ struct AccountPushCoordinatorTests { // Never started, so its CKSyncEngines are nil and the decision write is // dropped — the exact case the publish gate must survive. let engine = SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ) let coordinator = makeCoordinator(persistence: persistence, syncEngine: engine) diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -56,7 +56,7 @@ struct ArchiveTests { private func makeSyncEngine(_ persistence: PersistenceController) throws -> SyncEngine { SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ) } diff --git a/Tests/Unit/GameViewedStoreTests.swift b/Tests/Unit/GameViewedStoreTests.swift @@ -49,11 +49,4 @@ struct GameViewedStoreTests { store.advance(base, forGame: gameID) #expect(store.lastViewed(forGame: other) == nil) } - - @Test("A synced SeenBaseline round-trips through JSON") - func seenBaselineRoundTrips() throws { - let encoded = try JSONEncoder().encode(SeenBaseline(viewedAt: base)) - let decoded = try JSONDecoder().decode(SeenBaseline.self, from: encoded) - #expect(decoded.viewedAt == base) - } } diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift @@ -178,8 +178,10 @@ struct RecordSerializerJournalTests { ) #expect(record.recordType == "Journal") - #expect(record["authorID"] as? String == "alice") - #expect(record["deviceID"] as? String == "deadbeef") + // authorID/deviceID live in the record name, not as fields. + let parsed = try #require(RecordSerializer.parseJournalRecordName(record.recordID.recordName)) + #expect(parsed.1 == "alice") + #expect(parsed.2 == "deadbeef") #expect(record["updatedAt"] as? Date == updatedAt) let asset = try #require(record["entries"] as? CKAsset) @@ -196,7 +198,7 @@ struct RecordSerializerJournalTests { struct JournalUploadEngineTests { private func makeEngine(persistence: PersistenceController) async -> SyncEngine { - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return engine diff --git a/Tests/Unit/PlayerRosterTests.swift b/Tests/Unit/PlayerRosterTests.swift @@ -114,7 +114,7 @@ struct PlayerRosterTests { let prefs = preferences ?? PlayerPreferences( local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! ) - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container return PlayerRoster( gameID: gameID, authorIdentity: AuthorIdentity(testing: "_Local"), diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -87,32 +87,27 @@ struct RecordSerializerTests { "completedAt", "completedBy", "shareRecordName", - "engagement", - "notification", + "roomCredential", + "pushCredential", "puzzleSource", ]) #expect(Set(RecordSerializer.movesDesiredKeys) == [ - "authorID", - "deviceID", "cells", "updatedAt", ]) #expect(Set(RecordSerializer.playerDesiredKeys) == [ - "authorID", "name", "updatedAt", "selRow", "selCol", "selDir", - "readAt", + "presenceUntil", "readThrough", - "sessionSnapshot", + "viewedAt", "timeLog", "pushAddress", ]) #expect(Set(RecordSerializer.pingDesiredKeys) == [ - "authorID", - "deviceID", "playerName", "puzzleTitle", "kind", @@ -136,7 +131,7 @@ struct RecordSerializerTests { zone: zone, systemFields: nil ) - #expect(record["readAt"] as? Date == readAt) + #expect(record["presenceUntil"] as? Date == readAt) #expect(RecordSerializer.parsePlayerReadAt(from: record) == readAt) } @@ -153,7 +148,7 @@ struct RecordSerializerTests { zone: zone, systemFields: nil ) - #expect(record["readAt"] == nil) + #expect(record["presenceUntil"] == nil) #expect(RecordSerializer.parsePlayerReadAt(from: record) == nil) } @@ -208,8 +203,8 @@ struct RecordSerializerTests { #expect(name == "ping-12345678-1234-1234-1234-123456789ABC-alice-deviceA-1700000000000") } - @Test("pingRecord writes authorID and deviceID fields") - func pingRecordWritesDeviceID() { + @Test("pingRecord encodes authorID/deviceID in the record name, not fields") + func pingRecordEncodesIdentityInName() { let id = UUID() let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName) let record = RecordSerializer.pingRecord( @@ -222,11 +217,32 @@ struct RecordSerializerTests { kind: .join, zone: zone ) - #expect(record["authorID"] as? String == "alice") - #expect(record["deviceID"] as? String == "deviceA") + #expect(record["authorID"] == nil) + #expect(record["deviceID"] == nil) + let parsed = RecordSerializer.parsePingRecordName(record.recordID.recordName) + #expect(parsed?.0 == id) + #expect(parsed?.1 == "alice") + #expect(parsed?.2 == "deviceA") #expect(record["kind"] as? String == "join") } + @Test("parsePingRecordName round-trips names, including a dashed authorID") + func parsePingRecordNameHandlesDashedAuthor() { + let gameID = UUID() + let name = RecordSerializer.recordName( + forPingInGame: gameID, + authorID: "au-th-or", + deviceID: "cafef00d", + eventTimestampMs: 42 + ) + let parsed = RecordSerializer.parsePingRecordName(name) + #expect(parsed?.0 == gameID) + #expect(parsed?.1 == "au-th-or") + #expect(parsed?.2 == "cafef00d") + // A non-ping name parses to nil. + #expect(RecordSerializer.parsePingRecordName("player-\(gameID.uuidString)-alice") == nil) + } + @Test("pingRecord writes payload when provided and omits it when nil") func pingRecordPayloadRoundTrip() { let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) @@ -428,7 +444,7 @@ struct RecordSerializerTests { #expect(creds.contentKey != nil) let record = CKRecord(recordType: "Game", recordID: recordID) record["title"] = "T" as CKRecordValue - record["notification"] = try creds.encoded() as CKRecordValue + record["pushCredential"] = Data(try creds.encoded().utf8) as CKRecordValue record["puzzleSource"] = asset as CKRecordValue var contentKeyChanges: [UUID] = [] let entity = RecordSerializer.applyGameRecord( @@ -686,7 +702,7 @@ struct RecordSerializerTests { try ctx.save() let record = CKRecord(recordType: "Game", recordID: recordID) - record["notification"] = "fresh-notification" as CKRecordValue + record["pushCredential"] = Data("fresh-notification".utf8) as CKRecordValue let merged = RecordSerializer.applyGameRecord(record, to: ctx) try ctx.save() @@ -721,7 +737,7 @@ struct RecordSerializerTests { #expect(record["completedAt"] as? Date == serverCompletedAt) #expect(record["completedBy"] as? String == "owner-author") - #expect(record["notification"] as? String == "new-notification") + #expect((record["pushCredential"] as? Data).flatMap { String(data: $0, encoding: .utf8) } == "new-notification") } @Test("applyGameRecord does not lower an existing updatedAt") @@ -810,7 +826,6 @@ struct RecordSerializerTests { // `key` is not duplicated as a field; `payload` is unused for block. #expect(record["key"] == nil) #expect(record["payload"] == nil) - #expect(record["createdAt"] as? Date != nil) } @Test("decisionRecord carries an optional payload when provided") diff --git a/Tests/Unit/Sync/AuthorIdentityTests.swift b/Tests/Unit/Sync/AuthorIdentityTests.swift @@ -42,7 +42,7 @@ struct AuthorIdentityTests { // The default CKContainer without a signed-in account fails the fetch. // The contract is: on failure, currentID is unchanged — still the // fallback — so offline writes keep a consistent author. - let containerWithNoAccount = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let containerWithNoAccount = CloudContainer.container await identity.refresh(using: containerWithNoAccount) #expect(identity.currentID == fallback) #expect(identity.isFallback) diff --git a/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift b/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift @@ -31,10 +31,10 @@ struct FriendControllerNicknameReplayTests { ) var requestedRecordID: CKRecord.ID? let controller = FriendController( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence, syncEngine: SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ), fetchAccountDecisionRecord: { recordID in @@ -74,10 +74,10 @@ struct FriendControllerNicknameReplayTests { version: 1 ) let controller = FriendController( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence, syncEngine: SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ), fetchAccountDecisionRecord: { _ in decision } diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -19,7 +19,7 @@ struct PendingChangeReapTests { private func makeEngine( persistence: PersistenceController ) async -> SyncEngine { - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return engine diff --git a/Tests/Unit/Sync/PerGameZoneTests.swift b/Tests/Unit/Sync/PerGameZoneTests.swift @@ -25,7 +25,7 @@ struct PerGameZoneTests { try? ctx.save() let engine = SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ) #expect(engine.zoneInfo(forGameID: id, in: ctx)?.isCloudConfirmed == false) diff --git a/Tests/Unit/Sync/PlayerRecordPresenceTests.swift b/Tests/Unit/Sync/PlayerRecordPresenceTests.swift @@ -22,7 +22,7 @@ struct PlayerRecordPresenceTests { private func makeEngine(persistence: PersistenceController) -> SyncEngine { SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v3"), + container: CloudContainer.container, persistence: persistence ) } @@ -191,7 +191,7 @@ struct PlayerRecordPresenceTests { // A sibling committed the catch-up baseline on leave: it ships the // snapshot + read cursor but with a stale `updatedAt`, because leaving // advances the read cursor and snapshot without bumping `updatedAt`. - let snapshot = Data("baseline".utf8) + let baseline = Date(timeIntervalSince1970: 12) let readAt = Date(timeIntervalSince1970: 15) let record = RecordSerializer.playerRecord( gameID: gameID, @@ -200,12 +200,12 @@ struct PlayerRecordPresenceTests { updatedAt: Date(timeIntervalSince1970: 10), selection: PlayerSelection(row: 5, col: 5, direction: .across), readAt: readAt, - sessionSnapshot: snapshot, + viewedAt: baseline, zone: zoneID, systemFields: nil ) - var readCursors: [(UUID, Date, Data?)] = [] + var readCursors: [(UUID, Date, Date?)] = [] engine.applyPlayerRecord( record, in: ctx, @@ -218,11 +218,11 @@ struct PlayerRecordPresenceTests { // The baseline converges despite the stale `updatedAt`... let fetched = try fetchLocalPlayer(in: ctx) let row = try #require(fetched) - #expect(row.sessionSnapshot == snapshot) + #expect(row.viewedAt == baseline) #expect(readCursors.count == 1) #expect(readCursors.first?.0 == gameID) #expect(readCursors.first?.1 == readAt) - #expect(readCursors.first?.2 == snapshot) + #expect(readCursors.first?.2 == baseline) // ...while the cursor LWW still shields the fresher local selection from // the stale record. #expect(row.selRow == nil) @@ -250,7 +250,7 @@ struct PlayerRecordPresenceTests { recordType: "Player", recordID: CKRecord.ID(recordName: recordName, zoneID: zoneID) ) - record["authorID"] = remoteAuthorID as CKRecordValue + // authorID is recovered from the record name, not a field. record["updatedAt"] = Date(timeIntervalSince1970: 20) as CKRecordValue record["selRow"] = Int64(3) as CKRecordValue record["selCol"] = Int64(4) as CKRecordValue diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift @@ -47,7 +47,7 @@ struct ShareRoutingTests { try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -103,7 +103,7 @@ struct ShareRoutingTests { try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -176,7 +176,7 @@ struct ShareRoutingTests { game.databaseScope = 0 try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -216,7 +216,7 @@ struct ShareRoutingTests { game.databaseScope = 1 try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() @@ -297,7 +297,7 @@ struct ShareRoutingTests { Self.seedMovesEntity(for: game, gameID: gameID, in: ctx) try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() await engine.setLocalAuthorIDProvider { Self.writerAuthorID } @@ -339,7 +339,7 @@ struct ShareRoutingTests { Self.seedMovesEntity(for: gameB, gameID: idB, in: ctx) try ctx.save() - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() await engine.setLocalAuthorIDProvider { Self.writerAuthorID } diff --git a/Tests/Unit/Sync/ZoneOrphaningTests.swift b/Tests/Unit/Sync/ZoneOrphaningTests.swift @@ -16,7 +16,7 @@ struct ZoneOrphaningTests { private func makeEngine( persistence: PersistenceController ) async -> SyncEngine { - let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v3") + let container = CloudContainer.container let engine = SyncEngine(container: container, persistence: persistence) await engine.start() return engine diff --git a/cloudkit.ckdb b/cloudkit.ckdb @@ -0,0 +1,123 @@ +DEFINE SCHEMA + + RECORD TYPE Archive ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE, + archiveGameID STRING, + cells ASSET, + completedAt TIMESTAMP, + completedBy STRING, + journals ASSET, + originalGameID STRING, + puzzleSource ASSET, + solveSeconds INT64, + title STRING, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Decision ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE, + kind STRING, + payload STRING, + version INT64, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Game ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP QUERYABLE, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE, + completedAt TIMESTAMP, + completedBy STRING, + puzzleSource ASSET, + pushCredential BYTES, + roomCredential BYTES, + shareRecordName STRING, + title STRING, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Journal ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE QUERYABLE, + entries ASSET, + updatedAt TIMESTAMP, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Moves ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP QUERYABLE, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE QUERYABLE, + cells BYTES, + updatedAt TIMESTAMP, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Ping ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP QUERYABLE, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE, + addressee STRING, + kind STRING QUERYABLE, + payload STRING, + playerName STRING, + puzzleTitle STRING, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + ); + + RECORD TYPE Player ( + "___createTime" TIMESTAMP, + "___createdBy" REFERENCE, + "___etag" STRING, + "___modTime" TIMESTAMP QUERYABLE, + "___modifiedBy" REFERENCE, + "___recordID" REFERENCE, + name STRING, + presenceUntil TIMESTAMP, + pushAddress STRING, + readThrough TIMESTAMP, + selCol INT64, + selDir INT64, + selRow INT64, + timeLog BYTES, + updatedAt TIMESTAMP, + viewedAt TIMESTAMP, + GRANT WRITE TO "_creator", + GRANT CREATE TO "_icloud", + GRANT READ TO "_world" + );