crossmate

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

commit 3af6dc76e6e28c64f20ca62236af95f467e80878
parent 40592e1aca08b08191ed151b330300133d68271f
Author: Michael Camilleri <[email protected]>
Date:   Fri,  5 Jun 2026 22:20:25 +0900

Adopt a sibling's push secret before minting a competing one

The account push secret (and the account push address before it)
converges across an account's own devices purely through the inbound
fetch path: a fetched decision-account-pushSecret fires
onAccountPushSecret, which caches the value and re-derives every
per-game push address from it. But the first reconcilePushRegistration
at startup ran before that path had a chance to deliver anything.
syncEngine.start() only constructs the engines — it does not fetch — so
the reconcile fired against empty UserDefaults, minted a competing
secret, and enqueued divergent HMAC-derived addresses for every Player
record.

On a freshly-updated device whose sibling had already published the
secret, that meant briefly clobbering the converged per-game addresses
with a set derived from the loser's own secret, then re-correcting them
once a later fetch finally delivered the winner. The scheme self-heals,
but the window left some games temporarily unreachable.

Minting only happens when nothing is cached for the current account, so
the clobber is bounded to a device's first run on this build (and
reinstall / account switch) — not every launch. Gate a single
account-zone-covering fetch on exactly that condition: when no
secret/address is cached, fetch first so the inbound path can adopt a
sibling's published value before reconcile would mint. A database-level
fetch is used (not zone-scoped) so the account zone is discovered even
on a device that has never seen it. On every later launch the values are
cached, the condition is false, and startup is unchanged — no recurring
fetch. If the fetch throws, syncMonitor.run swallows it and reconcile
still runs (no worse than before). This runs once per launch at most,
guarded by syncStartTask.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 28++++++++++++++++++++++++++++
1 file changed, 28 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -818,6 +818,17 @@ final class AppServices { Self.accountPushSecretDefaultsPrefix + authorID } + /// True once both the account push secret and address are cached for this + /// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would + /// hit their early returns rather than mint. Used at startup to decide + /// whether a pre-reconcile fetch is needed to adopt a sibling's value first. + private func hasCachedAccountPushCredentials(authorID: String) -> Bool { + let defaults = UserDefaults.standard + let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID)) + let address = defaults.string(forKey: accountPushAddressDefaultsKey(authorID: authorID)) + return !(secret ?? "").isEmpty && !(address ?? "").isEmpty + } + private func publishAccountPushSecretDecision(_ secret: String) { Task.detached { [syncEngine] in await syncEngine.enqueueDecision( @@ -2251,6 +2262,23 @@ final class AppServices { } isReadyForShareAcceptance = true await processPendingShareAcceptances() + // Only when this device has nothing cached to derive from is + // `reconcilePushRegistration` about to *mint* a fresh secret/address + // — and minting before a sibling's already-published value has been + // fetched is what enqueues divergent per-game addresses that briefly + // clobber the converged set. `start()` only constructs the engines, + // so fetch the account zone first in exactly that case, letting the + // inbound path (`onAccountPushSecret`) adopt and cache the winner so + // reconcile derives from it instead of minting. On every later + // launch the value is already cached, so this is skipped and startup + // is unchanged. If the fetch throws, `run` swallows it and reconcile + // still runs (no worse than before). + if let authorID = identity.currentID, !authorID.isEmpty, + !hasCachedAccountPushCredentials(authorID: authorID) { + await syncMonitor.run("startup account sync") { + try await syncEngine.fetchChanges(source: "startup") + } + } await reconcilePushRegistration() return true }