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:
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()