commit b3ab69d38db124e8eb2927d02a03316216fbe7b1
parent b65bba508ef1e12f7f634ed0e63e94517e193d0b
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 16:11:53 +0900
Reannounce unaccepted friend inbox shares on launch
This commit adds a launch-time heal for half-established friendships.
The .friend bootstrap Ping that carries a friend's inbox share URL is
delivered exactly once, via the carrier game zone's delta fetch; if the
recipient's accept fails at that moment nothing ever re-sends it —
establish is marker-gated and its adopt paths assume a sibling already
delivered. The pair is then stuck: the friend never gains write access
to the inbox, and every attempt to invite this user fails with
friendshipNotReady ('This Crossmate is still syncing') no matter how
long they wait. Device logs show every existing pair wedged this way.
Now healPendingBootstraps runs on each cold launch, after the mailbox
migration and only once the engine is up with sync enabled. It reads the
inbox share's participant list — server truth for whether the friend got
in — and for each unblocked friend still not accepted it re-enqueues the
bootstrap Ping through the friend's most recently updated shared game,
restoring the participant first if the share has lost them. Existing
recipient builds already handle .friend Pings, so the other side heals
without updating.
Healthy pairs cost one share fetch and are left untouched. Because a
re-announcement is a fresh broadcast Ping record that is never consumed,
a per-pair 24-hour cooldown bounds the accrual while a friend's devices
stay dormant, and each pass leaves a 'friendship heal' summary in the
diagnostics log.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
3 files changed, 164 insertions(+), 0 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1081,6 +1081,15 @@ final class AppServices {
localAuthorID: localAuthorID,
localDisplayName: preferences.name
)
+ // Re-announce any inbox share a friend has still not accepted.
+ // The bootstrap Ping is otherwise one-shot: a friend whose
+ // accept failed at delivery is stuck at `friendshipNotReady`
+ // with no other retry path. Sequenced after the migration so
+ // the two never mutate the same pair's share concurrently.
+ await friendController.healPendingBootstraps(
+ localAuthorID: localAuthorID,
+ localDisplayName: preferences.name
+ )
}
}
// The scene-active phase that fires alongside cold launch runs the
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -265,6 +265,142 @@ final class FriendController {
}
}
+ // MARK: - Bootstrap heal
+
+ /// Launch-time repair for half-established friendships. The `.friend`
+ /// bootstrap Ping is delivered exactly once, via the carrier game zone's
+ /// delta fetch; if the recipient's accept fails at that moment nothing
+ /// ever re-sends it — `establish` is marker-gated and its adopt paths
+ /// assume a sibling already delivered — so the pair is stuck: the friend
+ /// never gains write access to our inbox and their `inviteFriend` throws
+ /// `friendshipNotReady` forever.
+ ///
+ /// The inbox share's participant list is server truth for whether the
+ /// friend got in. For each unblocked friend still not `.accepted` there,
+ /// re-enqueue the bootstrap Ping through a game they collaborate on
+ /// (re-adding the participant first if the share lost them). Healthy
+ /// pairs cost one share fetch; a per-pair cooldown bounds Ping accrual
+ /// while a friend's devices stay dormant. This heals the *friend's*
+ /// missing acceptance — our own missing acceptance of their inbox is
+ /// healed by the same pass running on their devices.
+ func healPendingBootstraps(localAuthorID: String, localDisplayName: String?) async {
+ guard !localAuthorID.isEmpty else { return }
+
+ struct PendingPair {
+ let friendAuthorID: String
+ let pairKey: String
+ let viaGameID: UUID?
+ }
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "isBlocked == NO")
+ let pairs: [PendingPair] = ((try? ctx.fetch(req)) ?? []).compactMap { friend in
+ guard let authorID = friend.authorID, !authorID.isEmpty,
+ let pairKey = friend.pairKey
+ else { return nil }
+ return PendingPair(
+ friendAuthorID: authorID,
+ pairKey: pairKey,
+ viaGameID: Self.bootstrapCarrierGameID(friendAuthorID: authorID, in: ctx)
+ )
+ }
+ guard !pairs.isEmpty else { return }
+
+ var healthy = 0, announced = 0, skipped = 0
+ for pair in pairs {
+ do {
+ guard var share = try await existingZoneWideShare(
+ zoneID: FriendZone.inboxZoneID(pairKey: pair.pairKey)
+ ) else {
+ // Nothing to announce: the inbox share was never created.
+ // `establish`/`migrateToMailboxes` own that repair.
+ skipped += 1
+ continue
+ }
+ let participant = share.participants.first {
+ $0.userIdentity.userRecordID?.recordName == pair.friendAuthorID
+ }
+ if participant?.acceptanceStatus == .accepted {
+ healthy += 1
+ continue
+ }
+ guard FriendZone.canAttemptBootstrapHeal(pairKey: pair.pairKey) else {
+ skipped += 1
+ continue
+ }
+ guard let viaGameID = pair.viaGameID else {
+ // No live carrier zone reaches this friend; a future
+ // shared game re-runs `establish` via reconcile anyway.
+ skipped += 1
+ continue
+ }
+ if participant == nil || participant?.acceptanceStatus == .removed {
+ // The friend can only accept a zone-wide share they are
+ // invited to (publicPermission is .none), so restore the
+ // participant before re-announcing.
+ let restored = try await fetchParticipant(
+ forUserRecordName: pair.friendAuthorID
+ )
+ restored.permission = .readWrite
+ share.addParticipant(restored)
+ guard let saved = try await container.privateCloudDatabase
+ .save(share) as? CKShare
+ else { throw FriendError.invalidShareRecord }
+ share = saved
+ }
+ guard let url = share.url else { throw FriendError.missingShareURL }
+ FriendZone.markBootstrapHealAttempted(pairKey: pair.pairKey)
+ let payload = FriendZone.BootstrapPayload(
+ friendShareURL: url.absoluteString,
+ pairKey: pair.pairKey,
+ ownerAuthorID: localAuthorID
+ )
+ await syncEngine.enqueuePing(
+ kind: .friend,
+ gameID: viaGameID,
+ authorID: localAuthorID,
+ playerName: localDisplayName ?? "",
+ payload: payload.encodedString()
+ )
+ announced += 1
+ syncMonitor?.note(
+ "friendship heal: re-announced inbox share to " +
+ "\(pair.friendAuthorID.prefix(8))… via game " +
+ "\(viaGameID.uuidString.prefix(8))"
+ )
+ } catch {
+ skipped += 1
+ syncMonitor?.note(
+ "friendship heal: check failed for " +
+ "\(pair.friendAuthorID.prefix(8))… — \(error.localizedDescription)"
+ )
+ }
+ }
+ syncMonitor?.note(
+ "friendship heal: pairs=\(pairs.count) healthy=\(healthy) " +
+ "announced=\(announced) skipped=\(skipped)"
+ )
+ }
+
+ /// A collaborative game the friend is known to play, used as the carrier
+ /// zone for a bootstrap re-announcement — their devices sync it, so the
+ /// Ping reaches them. The most recently updated game is the liveliest
+ /// channel.
+ private static func bootstrapCarrierGameID(
+ friendAuthorID: String,
+ in ctx: NSManagedObjectContext
+ ) -> UUID? {
+ let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
+ req.predicate = NSPredicate(
+ format: "authorID == %@ AND game.isAccessRevoked == NO AND " +
+ "(game.databaseScope == 1 OR game.ckShareRecordName != nil)",
+ friendAuthorID
+ )
+ req.sortDescriptors = [NSSortDescriptor(key: "game.updatedAt", ascending: false)]
+ req.fetchLimit = 1
+ return (try? ctx.fetch(req).first)?.game?.id
+ }
+
// MARK: - Migration
/// One-shot migration of pre-mailbox friendships (a single elected-owner
diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift
@@ -78,6 +78,25 @@ enum FriendZone {
UserDefaults.standard.set(true, forKey: outboxAcceptedPrefix + pairKey)
}
+ private static let bootstrapHealAtPrefix = "friend.bootstrapHealAt."
+
+ /// Minimum spacing between bootstrap re-announcements for one pair. Each
+ /// re-announcement is a fresh broadcast Ping record that is never
+ /// consumed, so a friend whose devices stay dormant must not accrue one
+ /// per launch.
+ static let bootstrapHealCooldown: TimeInterval = 24 * 60 * 60
+
+ static func canAttemptBootstrapHeal(pairKey: String, now: Date = Date()) -> Bool {
+ guard let last = UserDefaults.standard.object(
+ forKey: bootstrapHealAtPrefix + pairKey
+ ) as? Date else { return true }
+ return now.timeIntervalSince(last) >= bootstrapHealCooldown
+ }
+
+ static func markBootstrapHealAttempted(pairKey: String, now: Date = Date()) {
+ UserDefaults.standard.set(now, forKey: bootstrapHealAtPrefix + pairKey)
+ }
+
/// Owner election: the user whose record name sorts first creates and
/// owns the zone; the other accepts the share. Deterministic on both
/// devices. Equal IDs (same user) can never be friends — returns false.