crossmate

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

commit 0d73711b3faa610fc7df096438f05ee91fb7d086
parent 4f381e432524d00429aa31b1ccbd95e6df5a973b
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 10:21:26 +0900

Scope player name publish to open game

Opening a shared puzzle should only publish the local player record for that
game. Broadcasting across every shared zone can batch valid player saves with
stale shared-zone failures and prevent the current game from syncing promptly.

This commit also marks invalid shared-zone owner failures as orphaned so they
stop poisoning future shared sends.

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

Diffstat:
MCrossmate/CrossmateApp.swift | 10+++++-----
MCrossmate/Services/AppServices.swift | 6+++---
MCrossmate/Services/PlayerNamePublisher.swift | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MCrossmate/Sync/SyncEngine.swift | 11+++++++++++
MTests/Unit/PlayerNamePublisherTests.swift | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 184 insertions(+), 29 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -450,11 +450,11 @@ private struct PuzzleDisplayView: View { if refreshRoster { await activeRoster.refresh() } - // Fan out the local user's name to every shared/joined game's zone - // before any selection publish — otherwise the partner sees "Player" - // until we happen to rename ourselves and trigger PlayerNamePublisher's - // observer. Idempotent if the name has already been broadcast. - await services.playerNamePublisher?.broadcastName() + // Publish the local user's name to this game's zone before any + // selection publish — otherwise the partner sees "Player" until we + // happen to rename ourselves. Keep this scoped to the open game so a + // stale shared zone elsewhere cannot hold up this collaboration. + await services.playerNamePublisher?.publishName(for: gameID) guard let authorID = services.identity.currentID else { return } let selectionPublisher = services.playerSelectionPublisher await selectionPublisher.begin( diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -185,9 +185,9 @@ final class AppServices { ) } - // PlayerNamePublisher fans out name changes to all shared/joined games. - // PuzzleDisplayView also calls `broadcastName()` when a shared puzzle - // is opened, which covers first-sync-after-share-create / accept. + // PlayerNamePublisher fans out name changes to active shared/joined + // games. PuzzleDisplayView publishes the open game's name directly, + // which covers first-sync-after-share-create / accept. playerNamePublisher = PlayerNamePublisher( preferences: preferences, persistence: persistence, diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -67,13 +67,31 @@ final class PlayerNamePublisher { } /// Writes the local user's name into the `PlayerEntity` row for every - /// shared or joined game and asks the sync engine to push each one. Called - /// directly on game-share creation/accept so the partner sees a name on - /// the very first sync. + /// active shared or joined game and asks the sync engine to push each one. + /// Used for actual name changes, where every active collaboration should + /// see the new display name. func broadcastName() async { await fanOut(newName: preferences.name) } + /// Writes the local user's name into one game's `PlayerEntity` row. Used + /// when opening a shared puzzle so stale shared zones elsewhere cannot + /// hold up this game's first Player record. + func publishName(for gameID: UUID) async { + guard let authorID = authorIdentity.currentID else { return } + + let ctx = persistence.container.newBackgroundContext() + ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + guard Self.upsertPlayerRecord( + for: gameID, + in: ctx, + authorID: authorID, + name: preferences.name + ) else { return } + + await enqueuePlayerRecord(gameID, authorID) + } + private func fanOut(newName: String) async { guard let authorID = authorIdentity.currentID else { return } @@ -101,33 +119,71 @@ final class PlayerNamePublisher { ctx.performAndWait { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate( - format: "ckShareRecordName != nil OR databaseScope == 1" + format: "(ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO AND completedAt == nil AND puzzleSource != nil AND puzzleSource != %@", + "" ) let games = (try? ctx.fetch(req)) ?? [] var ids: [UUID] = [] let now = Date() for game in games { guard let gameID = game.id else { continue } - let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) - let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") - lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName) - lookup.fetchLimit = 1 - - let entity: PlayerEntity - if let existing = try? ctx.fetch(lookup).first { - entity = existing - } else { - entity = PlayerEntity(context: ctx) - entity.game = game - entity.ckRecordName = recordName - entity.authorID = authorID - } - entity.name = name - entity.updatedAt = now + upsertPlayerEntity(for: game, authorID: authorID, name: name, now: now, in: ctx) ids.append(gameID) } if ctx.hasChanges { try? ctx.save() } return ids } } + + private nonisolated static func upsertPlayerRecord( + for gameID: UUID, + in ctx: NSManagedObjectContext, + authorID: String, + name: String + ) -> Bool { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "id == %@ AND (ckShareRecordName != nil OR databaseScope == 1) AND isAccessRevoked == NO", + gameID as CVarArg + ) + req.fetchLimit = 1 + guard let game = try? ctx.fetch(req).first else { return false } + upsertPlayerEntity( + for: game, + authorID: authorID, + name: name, + now: Date(), + in: ctx + ) + if ctx.hasChanges { try? ctx.save() } + return true + } + } + + private nonisolated static func upsertPlayerEntity( + for game: GameEntity, + authorID: String, + name: String, + now: Date, + in ctx: NSManagedObjectContext + ) { + guard let gameID = game.id else { return } + let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) + let lookup = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + lookup.predicate = NSPredicate(format: "ckRecordName == %@", recordName) + lookup.fetchLimit = 1 + + let entity: PlayerEntity + if let existing = try? ctx.fetch(lookup).first { + entity = existing + } else { + entity = PlayerEntity(context: ctx) + entity.game = game + entity.ckRecordName = recordName + entity.authorID = authorID + } + entity.name = name + entity.updatedAt = now + } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1952,6 +1952,9 @@ actor SyncEngine { if err.domain == CKErrorDomain, err.code == CKError.zoneNotFound.rawValue { orphaned.insert(failure.record.recordID.zoneID) + } else if !isPrivate, + self.isInvalidSharedZoneOwnerError(err) { + orphaned.insert(failure.record.recordID.zoneID) } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) { messages.append( "send: recovered stale system fields for \(name) from CloudKit server record" @@ -1984,6 +1987,14 @@ actor SyncEngine { } } + private nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool { + let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" } + return values.contains { + $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") || + $0.localizedCaseInsensitiveContains("invalid userId") + } + } + /// Reacts to per-record `.zoneNotFound` failures discovered during a push /// by reflecting the missing-zone reality locally. The framework reports /// the same failure on every retry without ever clearing the queued change, diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -19,7 +19,7 @@ struct PlayerNamePublisherTests { let entity = GameEntity(context: ctx) entity.id = gameID entity.title = "Shared Test" - entity.puzzleSource = "" + entity.puzzleSource = "## Metadata\nTitle: Shared Test\n" entity.createdAt = Date() entity.updatedAt = Date() entity.ckRecordName = RecordSerializer.recordName(forGameID: gameID) @@ -54,6 +54,16 @@ struct PlayerNamePublisherTests { } } + private func fetchPlayerNames(authorID: String, in persistence: PersistenceController) -> [String] { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + req.predicate = NSPredicate(format: "authorID == %@", authorID) + let entities = (try? ctx.fetch(req)) ?? [] + return entities.compactMap(\.name) + } + } + private func makeBroadcaster( preferences: PlayerPreferences, persistence: PersistenceController, @@ -105,6 +115,84 @@ struct PlayerNamePublisherTests { #expect(fetchPlayerName(authorID: "_local", in: persistence) == nil) } + @Test("broadcastName skips revoked completed and placeholder shared games") + func broadcastNameSkipsInactiveSharedGames() async throws { + let p = makeTestPersistence() + let ctx = p.viewContext + let activeID = UUID() + let revokedID = UUID() + let completedID = UUID() + let placeholderID = UUID() + for (id, title, source) in [ + (activeID, "Active", "## Metadata\nTitle: Active\n"), + (revokedID, "Revoked", "## Metadata\nTitle: Revoked\n"), + (completedID, "Completed", "## Metadata\nTitle: Completed\n"), + (placeholderID, "Joining...", "") + ] { + let entity = GameEntity(context: ctx) + entity.id = id + entity.title = title + entity.puzzleSource = source + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: id) + entity.ckShareRecordName = "share-\(id.uuidString)" + if id == revokedID { entity.isAccessRevoked = true } + if id == completedID { entity.completedAt = Date() } + } + try ctx.save() + + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + let broadcaster = makeBroadcaster( + preferences: prefs, + persistence: p, + authorID: "_local" + ) + + await broadcaster.broadcastName() + + #expect(fetchPlayerNames(authorID: "_local", in: p) == ["Alice"]) + } + + @Test("publishName writes only the requested shared game") + func publishNameWritesOnlyRequestedGame() async throws { + let p = makeTestPersistence() + let ctx = p.viewContext + let firstID = UUID() + let secondID = UUID() + for id in [firstID, secondID] { + let entity = GameEntity(context: ctx) + entity.id = id + entity.title = "Shared" + entity.puzzleSource = "## Metadata\nTitle: Shared\n" + entity.createdAt = Date() + entity.updatedAt = Date() + entity.ckRecordName = RecordSerializer.recordName(forGameID: id) + entity.ckShareRecordName = "share-\(id.uuidString)" + } + try ctx.save() + + let prefs = PlayerPreferences( + local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! + ) + prefs.name = "Alice" + var enqueued: [UUID] = [] + let broadcaster = PlayerNamePublisher( + preferences: prefs, + persistence: p, + authorIdentity: AuthorIdentity(testing: "_local"), + enqueuePlayerRecord: { gameID, _ in enqueued.append(gameID) } + ) + + await broadcaster.publishName(for: secondID) + + #expect(enqueued == [secondID]) + #expect(fetchPlayerNames(authorID: "_local", in: p) == ["Alice"]) + } + @Test("Debounce coalesces two rapid name changes into one fan-out with the final name") func debounceCoalescesPair() async throws { let (persistence, _) = try makeSharedGame()