commit 89bbb3aeff60751f8d7684db6009dd5a1b2f4924
parent c1d3dd7b1f832b36ffe718fd2a387755dddad39a
Author: Michael Camilleri <[email protected]>
Date: Tue, 30 Jun 2026 02:57:10 +0900
Move friend invite key publication to friendship paths
Friend invite encryption keys were being minted and re-published by
broad push-registration sweeps, including paths reached from shared-game
and puzzle-open state. That made registration reconciliation harder to
reason about and could produce noisy already-present Decision saves for
keys whose lifecycle belongs to the friendship channel, not the open
game.
This commit separates worker registration from friend invite key
publication. FriendController now asks AccountPushCoordinator to ensure
the pair key when a friendship is recorded, and invite sending repairs
or publishes the sender's pair key before encrypting the push payload.
Registration refreshes still keep existing invite bindings registered
with the worker, but they no longer mint missing friend keys or publish
encryptionKey Decisions.
Puzzle open now uses a narrower refreshPushRegistration path after
stamping the current game's Player row, so it avoids the full Player
repair sweep while still keeping the worker address set current.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
6 files changed, 143 insertions(+), 24 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -1103,9 +1103,9 @@ private struct PuzzleDisplayView: View {
if let burstScope {
await syncEngine.endPlayerSendBurst(scope: burstScope)
}
- // Register this device under the (now-minted) address set so the
- // just-opened game can deliver pushes here immediately.
- await services.accountPush.reconcilePushRegistration()
+ // Register this device under the current address set. The open burst
+ // already stamped this game's Player row, so avoid a full repair sweep.
+ await services.accountPush.refreshPushRegistration()
await services.engagement.startEngagementIfPossible(gameID: gameID)
let services = self.services
let eventGameID = gameID
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -1898,7 +1898,8 @@ final class GameStore {
/// with no local row still contributes its binding to the registration set.
func reconcileLocalPushAddresses(
authorID: String,
- secret: String
+ secret: String,
+ republishPlayerRows: Bool = true
) -> (bindings: [PushAddressBinding], republishGameIDs: [UUID]) {
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(
@@ -1931,7 +1932,8 @@ final class GameStore {
PushAddressBinding(gameID: gameID, address: address, credentials: credentials)
)
// Update an existing row in place; never create one here.
- guard let player = fetchPlayerEntity(gameID: gameID, authorID: authorID),
+ guard republishPlayerRows,
+ let player = fetchPlayerEntity(gameID: gameID, authorID: authorID),
player.pushAddress != address
else { continue }
player.pushAddress = address
diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift
@@ -14,6 +14,8 @@ final class AccountPushCoordinator {
private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
private static let accountPushSecretDefaultsPrefix = "push.accountSecret."
private static let friendEncryptionKeyDefaultsPrefix = "push.friendEncryptionKey."
+ private static let publishedFriendEncryptionKeyDefaultsPrefix =
+ "push.friendEncryptionKeyPublished."
/// Generation of the locally-held push secret. Bumped on a deliberate
/// rotation and tracked so a stale inbound copy can't supersede the current
/// value (see `RecordSerializer.decisionVersion`).
@@ -146,20 +148,33 @@ final class AccountPushCoordinator {
await reconcilePushRegistration()
}
- /// Ensures this device is registered with the push worker under the
- /// account's per-game push address for every shared game, minting and
- /// publishing any address that doesn't exist yet so peers learn where to
- /// reach this account. Deduped inside `PushClient`, so it's cheap to call
- /// repeatedly — on launch (sync ready), when a shared game appears
- /// (inbound Player records), and on account switch. This is what makes a
- /// push reach *all* of the account's devices, not just the one a game was
- /// opened on, and survive an APNs token rotation.
+ /// Full reconciliation for lifecycle events that can add/remove games,
+ /// change the account credential, or require peer-visible address repair.
+ /// This stamps stale local Player rows with the derived per-game address,
+ /// then refreshes the worker registration. Friend-invite encryption keys
+ /// are minted only from friendship or invite-send paths.
func reconcilePushRegistration() async {
+ await updatePushRegistration(republishPlayerRows: true)
+ }
+
+ /// Refreshes the worker's address set without repairing every Player row or
+ /// minting friend-invite keys. Use this after a narrower caller has
+ /// already made the specific local mutation it needs, such as puzzle open
+ /// stamping the current game's Player row inside its send burst.
+ func refreshPushRegistration() async {
+ await updatePushRegistration(republishPlayerRows: false)
+ }
+
+ private func updatePushRegistration(republishPlayerRows: Bool) async {
guard let pushClient, preferences.isICloudSyncEnabled else { return }
guard let authorID = identity.currentID, !authorID.isEmpty else { return }
let accountAddress = ensureAccountPushAddress(authorID: authorID)
let secret = ensureAccountPushSecret(authorID: authorID)
- let result = store.reconcileLocalPushAddresses(authorID: authorID, secret: secret)
+ let result = store.reconcileLocalPushAddresses(
+ authorID: authorID,
+ secret: secret,
+ republishPlayerRows: republishPlayerRows
+ )
for gameID in result.republishGameIDs {
await syncEngine.enqueuePlayer(
gameID: gameID,
@@ -170,26 +185,40 @@ final class AccountPushCoordinator {
// The account-scoped sibling address carries no game credential.
var bindings = Set(result.bindings)
bindings.insert(PushAddressBinding(address: accountAddress))
- bindings.formUnion(await reconcileFriendEncryptionKeys(authorID: authorID))
+ bindings.formUnion(friendEncryptionKeyBindings(localAuthorID: authorID))
pushClient.setAddresses(bindings)
}
- private func reconcileFriendEncryptionKeys(authorID: String) async -> Set<PushAddressBinding> {
- let friends = friendEncryptionKeyTargets(localAuthorID: authorID)
+ private func friendEncryptionKeyBindings(localAuthorID: String) -> Set<PushAddressBinding> {
+ let friends = friendEncryptionKeyTargets(localAuthorID: localAuthorID)
var bindings = Set<PushAddressBinding>()
for friend in friends {
- let payload = ensureFriendEncryptionKey(pairKey: friend.pairKey)
+ guard let payload = existingFriendEncryptionKey(pairKey: friend.pairKey) else { continue }
bindings.insert(PushAddressBinding(address: payload.address))
- guard let encoded = payload.encodedString() else { continue }
+ }
+ return bindings
+ }
+
+ func ensureFriendInvitationKeyPublished(
+ pairKey: String,
+ friendZoneID: CKRecordZone.ID,
+ friendZoneScope: Int16
+ ) async {
+ guard preferences.isICloudSyncEnabled else { return }
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let payload = ensureFriendEncryptionKey(pairKey: pairKey)
+ guard let encoded = payload.encodedString() else { return }
+ if shouldPublishFriendEncryptionKey(encoded, pairKey: pairKey) {
await syncEngine.enqueueFriendDecision(
kind: RecordSerializer.encryptionKeyDecisionKind,
key: authorID,
payload: encoded,
- friendZoneID: friend.zoneID,
- friendZoneScope: friend.scope
+ friendZoneID: friendZoneID,
+ friendZoneScope: friendZoneScope
)
+ markFriendEncryptionKeyPublished(encoded, pairKey: pairKey)
}
- return bindings
+ await refreshPushRegistration()
}
private func friendEncryptionKeyTargets(
@@ -215,6 +244,15 @@ final class AccountPushCoordinator {
}
}
+ private func friendEncryptionKeyTarget(
+ pairKey: String,
+ localAuthorID: String
+ ) -> (zoneID: CKRecordZone.ID, scope: Int16)? {
+ friendEncryptionKeyTargets(localAuthorID: localAuthorID)
+ .first { $0.pairKey == pairKey }
+ .map { ($0.zoneID, $0.scope) }
+ }
+
private func ensureFriendEncryptionKey(pairKey: String) -> FriendEncryptionKeyPayload {
let key = Self.friendEncryptionKeyDefaultsPrefix + pairKey
if let existing = FriendEncryptionKeyPayload.decode(UserDefaults.standard.string(forKey: key)) {
@@ -227,6 +265,25 @@ final class AccountPushCoordinator {
return fresh
}
+ private func existingFriendEncryptionKey(pairKey: String) -> FriendEncryptionKeyPayload? {
+ FriendEncryptionKeyPayload.decode(
+ UserDefaults.standard.string(forKey: Self.friendEncryptionKeyDefaultsPrefix + pairKey)
+ )
+ }
+
+ private func shouldPublishFriendEncryptionKey(_ encoded: String, pairKey: String) -> Bool {
+ UserDefaults.standard.string(
+ forKey: Self.publishedFriendEncryptionKeyDefaultsPrefix + pairKey
+ ) != encoded
+ }
+
+ private func markFriendEncryptionKeyPublished(_ encoded: String, pairKey: String) {
+ UserDefaults.standard.set(
+ encoded,
+ forKey: Self.publishedFriendEncryptionKeyDefaultsPrefix + pairKey
+ )
+ }
+
/// Stamps `gameID`'s local Player row with the derived push address inside
/// the puzzle-open send burst, so it ships on the same Player-record write
/// as the read-cursor lease and display name.
@@ -411,8 +468,16 @@ final class AccountPushCoordinator {
syncMonitor.note("push(invite): skipped (no encryption key for \(friendAuthorID))")
return
}
+ let pairKey = FriendZone.pairKey(authorID, friendAuthorID)
+ if let target = friendEncryptionKeyTarget(pairKey: pairKey, localAuthorID: authorID) {
+ await ensureFriendInvitationKeyPublished(
+ pairKey: pairKey,
+ friendZoneID: target.zoneID,
+ friendZoneScope: target.scope
+ )
+ }
guard let key = ensureFriendEncryptionKey(
- pairKey: FriendZone.pairKey(authorID, friendAuthorID)
+ pairKey: pairKey
).symmetricKey else {
syncMonitor.note("push(invite): skipped (no local encryption key for \(friendAuthorID))")
return
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -604,7 +604,14 @@ final class AppServices {
persistence: persistence,
syncEngine: syncEngine,
syncMonitor: self.syncMonitor,
- eventLog: eventLog
+ eventLog: eventLog,
+ publishFriendInvitationKey: { [accountPush] pairKey, zoneID, scope in
+ await accountPush.ensureFriendInvitationKeyPublished(
+ pairKey: pairKey,
+ friendZoneID: zoneID,
+ friendZoneScope: scope
+ )
+ }
)
self.gameArchiver = GameArchiver(
container: self.ckContainer,
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -21,6 +21,8 @@ final class FriendController {
private let syncMonitor: SyncMonitor?
private let eventLog: EventLog?
private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord
+ private let publishFriendInvitationKey:
+ ((String, CKRecordZone.ID, Int16) async -> Void)?
init(
container: CKContainer,
@@ -28,6 +30,8 @@ final class FriendController {
syncEngine: SyncEngine,
syncMonitor: SyncMonitor? = nil,
eventLog: EventLog? = nil,
+ publishFriendInvitationKey:
+ ((String, CKRecordZone.ID, Int16) async -> Void)? = nil,
fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil
) {
self.container = container
@@ -35,6 +39,7 @@ final class FriendController {
self.syncEngine = syncEngine
self.syncMonitor = syncMonitor
self.eventLog = eventLog
+ self.publishFriendInvitationKey = publishFriendInvitationKey
self.fetchAccountDecisionRecord = fetchAccountDecisionRecord
?? { try await container.privateCloudDatabase.record(for: $0) }
}
@@ -604,6 +609,11 @@ final class FriendController {
do {
try ctx.save()
Task { [weak self] in
+ await self?.publishFriendInvitationKey?(
+ pairKey,
+ CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName),
+ databaseScope
+ )
await self?.applyAccountNicknameDecisionIfPresent(for: authorID)
}
} catch {
diff --git a/Tests/Unit/GameStorePushAddressTests.swift b/Tests/Unit/GameStorePushAddressTests.swift
@@ -74,6 +74,17 @@ struct GameStorePushAddressTests {
try ctx.count(for: NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity"))
}
+ private func playerRow(gameID: UUID, in ctx: NSManagedObjectContext) throws -> PlayerEntity {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(
+ format: "game.id == %@ AND authorID == %@",
+ gameID as CVarArg,
+ Self.authorID
+ )
+ req.fetchLimit = 1
+ return try #require(try ctx.fetch(req).first)
+ }
+
// MARK: - Derivation
@Test("deriveGameAddress is deterministic, URL-safe, and per-game/per-secret distinct")
@@ -214,6 +225,30 @@ struct GameStorePushAddressTests {
#expect(firstCred.secret == secondCred.secret)
}
+ @Test("reconcile can refresh bindings without republishing stale Player rows")
+ func reconcileCanSkipPlayerRepublish() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let gameID = try makeGame(scope: 1, in: persistence.viewContext)
+ try makeStalePlayerRow(
+ gameID: gameID,
+ address: "stale-minted-token",
+ in: persistence.viewContext
+ )
+
+ let derived = RecordSerializer.deriveGameAddress(secret: Self.secret, gameID: gameID)
+ let result = store.reconcileLocalPushAddresses(
+ authorID: Self.authorID,
+ secret: Self.secret,
+ republishPlayerRows: false
+ )
+
+ #expect(result.bindings.map(\.address) == [derived])
+ #expect(result.republishGameIDs.isEmpty)
+ #expect(try playerRow(gameID: gameID, in: persistence.viewContext).pushAddress == "stale-minted-token")
+ #expect(GamePushCredentials.decode(store.notification(for: gameID)) != nil)
+ }
+
// MARK: - ensurePushCredentials (minting)
@Test("ensurePushCredentials mints once for a shared game and is stable")