diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index ca734e54194..768eb0d6902 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -415,6 +415,74 @@ describe('useHostClient', () => { harness.unmount() }) + it('publishes lifecycle updates after cold-start priming races the host lookup', async () => { + let resolveLookup: ((hosts: HostProfile[]) => void) | null = null + const lookup = new Promise((resolve) => { + resolveLookup = resolve + }) + const initial = makeFakeClient('connected') + const fresh = makeFakeClient('connected') + fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} })) + loadHostsMock.mockReturnValue(lookup) + connectMock.mockReturnValueOnce(initial).mockReturnValueOnce(fresh) + + const harness = await renderHarness(HOST.id) + harness.primeHosts([HOST]) + await act(async () => { + resolveLookup?.([HOST]) + await lookup + }) + const relayHostId = 'AbCdEf0123_-xyZ9' + const upgradedHost: HostProfile = { + ...HOST, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST.endpoint }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay.invalid/v1/connect/host' } + ], + relayHostId, + relay: { + v: 1, + directorUrl: 'https://relay.invalid', + cellUrl: 'https://relay.invalid', + assignmentEpoch: 1, + relayHostId, + e2eeFraming: 2 + } + } + const publishHostUpdate = connectMock.mock.calls[0]?.[2] as + | ((host: HostProfile) => void) + | undefined + publishHostUpdate?.(upgradedHost) + + await act(async () => harness.forceReconnect(HOST.id)) + + expect(connectMock.mock.calls[1]?.[0]).toEqual(upgradedHost) + harness.unmount() + }) + + it('ignores a cancelled lookup failure after its replacement connects', async () => { + let rejectLookup: ((error: Error) => void) | null = null + const lookup = new Promise((_resolve, reject) => { + rejectLookup = reject + }) + const fresh = makeFakeClient('connected') + fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} })) + loadHostsMock.mockReturnValue(lookup) + connectMock.mockReturnValue(fresh) + + const harness = await renderHarness(HOST.id) + harness.primeHosts([HOST]) + await act(async () => harness.forceReconnect(HOST.id)) + await act(async () => { + rejectLookup?.(new Error('keychain locked')) + await lookup.catch(() => undefined) + }) + + expect(harness.hook.client).toBe(fresh) + expect(harness.hook.state).toBe('connected') + harness.unmount() + }) + it('coalesces overlapping Force Reconnect calls for one host', async () => { const stale = makeFakeClient('connected') const fresh = makeFakeClient('connecting') diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 5aeec659a36..2226df53f36 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -16,6 +16,7 @@ import { HostForceReconnectCoordinator, type HostReconnectEntry } from './host-f import { HostReconnectProfileCache, type HostOpenProfile } from './host-reconnect-profile-cache' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' +import { loadHostClientOpenProfile } from './host-client-open-profile' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' import { clientActivePath } from './rpc-client-active-path' @@ -85,29 +86,26 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const pendingOpen = pendingOpensRef.current.register(hostId, profileVersion, promise) try { - // Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start. - let host = requestedProfile?.host ?? primedHostsRef.current.get(hostId) - if (!host) { - try { - const hosts = await loadHosts() - host = hosts.find((h) => h.id === hostId) - } catch { - // Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect. - notifyHostState(hostId, 'disconnected') - notifyAllHosts() - return null - } - if (!host) { - // Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance. - notifyHostState(hostId, 'disconnected') - notifyAllHosts() - return null - } - } - - if (pendingOpen.cancelled) { + const cachedHost = requestedProfile?.host ?? primedHostsRef.current.get(hostId) + const profile = cachedHost + ? { host: cachedHost, version: requestedProfile?.version ?? profileVersion } + : await loadHostClientOpenProfile({ + hostId, + cache: primedHostsRef.current, + ticket: pendingOpen, + loadHosts, + onUnavailable: () => { + notifyHostState(hostId, 'disconnected') + notifyAllHosts() + } + }) + if (!profile || pendingOpen.cancelled) { return null } + const resolvedProfile = cachedHost + ? profile + : primedHostsRef.current.resolveLoadedOpenProfile(profile.host) + pendingOpen.profileVersion = resolvedProfile.version // Re-check after any await — another acquire() may have completed. const after = storeRef.current.get(hostId) @@ -119,9 +117,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const onLog: ConnectionLogSink = (entry) => connectionLogStore.append(hostId, entry) try { client = openHostLogicalClient( - host, + resolvedProfile.host, onLog, - primedHostsRef.current.publisher(hostId, profileVersion) + primedHostsRef.current.publisher(hostId, resolvedProfile.version) ) } catch { // Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'. @@ -459,6 +457,5 @@ export function useForceReconnect(): (hostId: string) => Promise { // Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start. export function usePrimeHosts(): (hosts: HostProfile[]) => void { - const ctx = useRpcClientContext() - return ctx.primeHosts + return useRpcClientContext().primeHosts } diff --git a/mobile/src/transport/existing-host-relay-routing.ts b/mobile/src/transport/existing-host-relay-routing.ts index 077d1d82b52..823d4a9a2e4 100644 --- a/mobile/src/transport/existing-host-relay-routing.ts +++ b/mobile/src/transport/existing-host-relay-routing.ts @@ -1,5 +1,5 @@ import { HostProfileSchema, type HostProfile } from './types' -import { loadHosts } from './host-store' +import { loadStoredHostCredentialIdentity } from './host-store' import { serializeHostProfilePublication } from './host-profile-publication' import * as hostListLoads from './host-list-load-sharing' import { saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' @@ -54,7 +54,7 @@ export async function writeExistingHostRelayCredentialBundle( async function requireCurrentHostIdentity( host: HostProfile ): Promise> { - const existing = (await loadHosts({ requireCredentials: true })).find(({ id }) => id === host.id) + const existing = await loadStoredHostCredentialIdentity(host.id) if (!existing || existing.publicKeyB64 !== host.publicKeyB64) { throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') } diff --git a/mobile/src/transport/host-client-open-profile.ts b/mobile/src/transport/host-client-open-profile.ts new file mode 100644 index 00000000000..bd5b1d4a0d3 --- /dev/null +++ b/mobile/src/transport/host-client-open-profile.ts @@ -0,0 +1,34 @@ +import type { HostClientOpenTicket } from './host-client-open-registry' +import { HostReconnectProfileCache } from './host-reconnect-profile-cache' +import type { HostProfile } from './types' + +type HostClientOpenProfileOptions = { + hostId: string + cache: HostReconnectProfileCache + ticket: HostClientOpenTicket + loadHosts: () => Promise + onUnavailable: () => void +} + +export async function loadHostClientOpenProfile( + options: HostClientOpenProfileOptions +): Promise<{ host: HostProfile; version: number } | null> { + const { hostId, cache, ticket } = options + let loadedHost: HostProfile | undefined + try { + loadedHost = (await options.loadHosts()).find(({ id }) => id === hostId) + } catch { + if (!ticket.cancelled) { + options.onUnavailable() + } + return null + } + if (ticket.cancelled) { + return null + } + if (!loadedHost) { + options.onUnavailable() + return null + } + return { host: cache.get(hostId) ?? loadedHost, version: cache.version(hostId) } +} diff --git a/mobile/src/transport/host-reconnect-profile-cache.ts b/mobile/src/transport/host-reconnect-profile-cache.ts index 5c6b50ec517..162915d6099 100644 --- a/mobile/src/transport/host-reconnect-profile-cache.ts +++ b/mobile/src/transport/host-reconnect-profile-cache.ts @@ -45,6 +45,10 @@ export class HostReconnectProfileCache { } } + resolveLoadedOpenProfile(host: HostProfile): { host: HostProfile; version: number } { + return { host: this.get(host.id) ?? host, version: this.version(host.id) } + } + get(hostId: string): HostProfile | undefined { return this.profiles.get(hostId)?.host } diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index f3a7baec083..05637c0a87c 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -282,6 +282,49 @@ describe('host-store list mutations', () => { expect(storedOverlayRaw).toBeNull() }) + it('validates only the target credential during relay publication', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_ONE.id) ? 'token-1' : null + ) + const relayHostId = 'AbCdEf0123_-xyZ9' + + await saveExistingHostRelayRouting({ + ...HOST_ONE, + deviceToken: 'token-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay.onorca.dev/v1/connect/host' } + ], + relayHostId, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay.onorca.dev', + assignmentEpoch: 1, + relayHostId, + e2eeFraming: 2 + } + }) + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledOnce() + expect(secureStoreMock.getItemAsync.mock.calls[0]?.[0]).toContain(HOST_ONE.id) + expect(JSON.parse(storedOverlayRaw ?? '[]')).toHaveLength(1) + }) + + it('does not infer host removal from unreadable identity storage', async () => { + storedHostsRaw = '{' + + const error = await saveExistingHostRelayRouting({ + ...HOST_ONE, + deviceToken: 'token-1' + }).catch((reason: unknown) => reason) + + expect(error).toMatchObject({ message: 'host list storage unreadable' }) + expect(error).not.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + expect(storedOverlayRaw).toBeNull() + expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled() + }) + it('publishes relay routing without overwriting a newer endpoint edit', async () => { const relayHostId = 'AbCdEf0123_-xyZ9' await updateHostNameAndEndpoint(HOST_ONE.id, { endpoint: 'ws://127.0.0.1:9' }) @@ -355,6 +398,7 @@ describe('host-store list mutations', () => { it('rejects relay routing from a lifecycle superseded by same-host re-pairing', async () => { await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + secureStoreMock.getItemAsync.mockResolvedValueOnce('replacement-token') const relayHostId = 'AbCdEf0123_-xyZ9' await expect( diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index e946d4219b7..d22f43c765f 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -64,19 +64,14 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { } } -export async function loadHosts( - options: { requireCredentials?: boolean } = {} -): Promise { +export async function loadHosts(): Promise { // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation - if (options.requireCredentials) { - return doLoadHosts(true) - } // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. - return hostListLoads.shareHostListLoad(() => doLoadHosts(false)) + return hostListLoads.shareHostListLoad(doLoadHosts) } -async function doLoadHosts(requireCredentials: boolean): Promise { +async function doLoadHosts(): Promise { const raw = await AsyncStorage.getItem(STORAGE_KEY) const storedHosts = parseStoredHosts(raw) if (!storedHosts) { @@ -99,17 +94,11 @@ async function doLoadHosts(requireCredentials: boolean): Promise let fetched: string | null try { fetched = await readHostDeviceToken(stored.id) - } catch (error) { - if (requireCredentials) { - throw error - } + } catch { // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. continue } if (!fetched) { - if (requireCredentials) { - throw new Error('host credential unavailable') - } // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. continue } @@ -134,6 +123,21 @@ async function doLoadHosts(requireCredentials: boolean): Promise return out } +export async function loadStoredHostCredentialIdentity( + hostId: string +): Promise | null> { + await hostListMutation + const stored = (await readStoredHostsForMutation()).find(({ id }) => id === hostId) + if (!stored) { + return null + } + const deviceToken = await readHostDeviceToken(hostId) + if (!deviceToken) { + throw new Error('host credential unavailable') + } + return { endpoint: stored.endpoint, publicKeyB64: stored.publicKeyB64, deviceToken } +} + export async function resolvePairingHostIdentity( publicKeyB64: string, newHostId: string diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 2493c9e78af..e2b4d20f845 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' import { deletePairingKeychainItem, + deletePairingKeychainItemIfMatches, readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' @@ -70,6 +71,16 @@ export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Pro await deletePairingKeychainItem(journalKey(hostId)) } +export function deleteMobileRelayDirectUpgradeJournalIfCurrent( + journal: MobileRelayDirectUpgradeJournal +): Promise { + if (Platform.OS === 'web') { + return Promise.resolve(false) + } + const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + return deletePairingKeychainItemIfMatches(journalKey(parsed.hostId), JSON.stringify(parsed)) +} + function encodeBase64Url(value: Uint8Array): string { let binary = '' for (const byte of value) { diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts index d0da2db78f5..46ff1fa20bb 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts @@ -62,8 +62,12 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) { writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => { stored = next }), - clearJournal: vi.fn(async () => { + clearJournal: vi.fn(async (expected: MobileRelayDirectUpgradeJournal) => { + if (stored?.reqId !== expected.reqId) { + return false + } stored = null + return true }), writeBundle: vi.fn(async () => {}), deleteBundle: vi.fn(async () => {}), @@ -121,7 +125,7 @@ describe('existing direct pairing relay upgrade', () => { expect(deps.saveHost).toHaveBeenCalledWith(expect.any(Object), expect.any(Function)) expect(deps.writeBundle).toHaveBeenCalledOnce() expect(result?.host.relay).toEqual(relay) - expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(journal) }) it('does not delete a replacement pairing bundle when the old upgrade is superseded', async () => { @@ -146,7 +150,35 @@ describe('existing direct pairing relay upgrade', () => { ).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError) expect(deps.writeBundle).not.toHaveBeenCalled() expect(deps.deleteBundle).not.toHaveBeenCalled() - expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(journal) + }) + + it('does not clear a replacement lifecycle journal from a superseded upgrade', async () => { + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(6) + ) + const replacement = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(9) + ) + const committed = installed(journal) + const deps = dependencies(journal) + deps.saveHost.mockImplementation(async () => { + await deps.writeJournal(replacement) + throw new MobileRelayUpgradeHostSupersededError('mobile relay upgrade host was re-paired') + }) + const client = clientWith([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed } + }) + ]) + + await expect( + upgradeDirectMobileRelay({ client, host, dependencies: deps }) + ).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError) + + await expect(deps.readJournal()).resolves.toEqual(replacement) }) it('retains recovery state when host identity credentials are temporarily unavailable', async () => { @@ -205,7 +237,7 @@ describe('existing direct pairing relay upgrade', () => { ]) await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() - expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(expect.objectContaining({ hostId: host.id })) expect(deps.writeBundle).not.toHaveBeenCalled() expect(deps.saveHost).not.toHaveBeenCalled() }) @@ -242,6 +274,6 @@ describe('existing direct pairing relay upgrade', () => { upgradeDirectMobileRelay({ client, host, dependencies: deps }) ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) expect(deps.deleteBundle).toHaveBeenCalledWith(host.id) - expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(journal) }) }) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts index aa390aee4cd..67bdcc23b2a 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -19,7 +19,7 @@ import { } from './mobile-relay-credential-bundle' import { createMobileRelayDirectUpgradeJournal, - deleteMobileRelayDirectUpgradeJournal, + deleteMobileRelayDirectUpgradeJournalIfCurrent, readMobileRelayDirectUpgradeJournal, writeMobileRelayDirectUpgradeJournal, type MobileRelayDirectUpgradeJournal @@ -35,7 +35,7 @@ export type MobileRelayDirectUpgradeResult = { type Dependencies = { readJournal: typeof readMobileRelayDirectUpgradeJournal writeJournal: typeof writeMobileRelayDirectUpgradeJournal - clearJournal: typeof deleteMobileRelayDirectUpgradeJournal + clearJournal: typeof deleteMobileRelayDirectUpgradeJournalIfCurrent writeBundle: typeof writeMobileRelayCredentialBundle saveHost: typeof saveExistingHostRelayRouting deleteBundle: typeof deleteMobileRelayCredentialBundle @@ -53,7 +53,7 @@ export async function upgradeDirectMobileRelay(args: { const dependencies: Dependencies = { readJournal: readMobileRelayDirectUpgradeJournal, writeJournal: writeMobileRelayDirectUpgradeJournal, - clearJournal: deleteMobileRelayDirectUpgradeJournal, + clearJournal: deleteMobileRelayDirectUpgradeJournalIfCurrent, writeBundle: writeMobileRelayCredentialBundle, saveHost: saveExistingHostRelayRouting, deleteBundle: deleteMobileRelayCredentialBundle, @@ -69,7 +69,7 @@ export async function upgradeDirectMobileRelay(args: { const initial = await getEndpoints(args.client, journal.reqId) if (initial === 'method-not-found') { - await dependencies.clearJournal(args.host.id) + await dependencies.clearJournal(journal) return null } if (initial.installStatus?.state === 'committed') { @@ -84,7 +84,7 @@ export async function upgradeDirectMobileRelay(args: { newResumeTokenHash: journal.pendingResumeTokenHash }) if (isMethodNotFound(provisionResponse)) { - await dependencies.clearJournal(args.host.id) + await dependencies.clearJournal(journal) return null } const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse)) @@ -127,13 +127,13 @@ async function publishCommitted( } catch (error) { if (error instanceof MobileRelayUpgradeHostRemovedError) { await dependencies.deleteBundle(host.id) - await dependencies.clearJournal(host.id) + await dependencies.clearJournal(journal) } else if (error instanceof MobileRelayUpgradeHostSupersededError) { - await dependencies.clearJournal(host.id) + await dependencies.clearJournal(journal) } throw error } - await dependencies.clearJournal(host.id) + await dependencies.clearJournal(journal) return { host: updatedHost, bundle } } diff --git a/mobile/src/transport/pairing-keychain.test.ts b/mobile/src/transport/pairing-keychain.test.ts index 0ef80ca889c..c6d2b6881ed 100644 --- a/mobile/src/transport/pairing-keychain.test.ts +++ b/mobile/src/transport/pairing-keychain.test.ts @@ -23,6 +23,7 @@ vi.mock('react-native', () => ({ Platform: platformMock })) import { deletePairingKeychainItem, + deletePairingKeychainItemIfMatches, readPairingKeychainItem, resetPairingKeychainForTests, writePairingKeychainItem @@ -338,6 +339,22 @@ describe('pairing keychain', () => { expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY) }) + it('does not delete a keychain item replaced by a newer lifecycle', async () => { + secureStoreMock.getItemAsync.mockResolvedValue('replacement') + + await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'stale')).resolves.toBe(false) + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('deletes a keychain item only while its expected value is current', async () => { + secureStoreMock.getItemAsync.mockResolvedValue('current') + + await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'current')).resolves.toBe(true) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledOnce() + }) + it('reports a partial delete failure after attempting every generation', async () => { generationRecord = '2' const deleteError = new Error('delete failed') diff --git a/mobile/src/transport/pairing-keychain.ts b/mobile/src/transport/pairing-keychain.ts index 078d5614981..daecd8b3675 100644 --- a/mobile/src/transport/pairing-keychain.ts +++ b/mobile/src/transport/pairing-keychain.ts @@ -156,8 +156,7 @@ async function setItemAtGeneration(key: string, value: string, generation: numbe } } -export async function readPairingKeychainItem(key: string): Promise { - await keychainMutation +async function readPairingKeychainItemImpl(key: string): Promise { const presenceGeneration = await loadPresenceGeneration(key) if (presenceGeneration !== null) { const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration)) @@ -182,6 +181,11 @@ export async function readPairingKeychainItem(key: string): Promise { + await keychainMutation + return readPairingKeychainItemImpl(key) +} + async function writePairingKeychainItemImpl(key: string, value: string): Promise { const loaded = await loadGeneration() if (!loaded.reliable) { @@ -255,6 +259,20 @@ export function deletePairingKeychainItem(key: string): Promise { return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key)) } +export function deletePairingKeychainItemIfMatches( + key: string, + expectedValue: string +): Promise { + let deleted = false + return enqueueKeychainMutation(async () => { + if ((await readPairingKeychainItemImpl(key)) !== expectedValue) { + return + } + await deletePairingKeychainItemImpl(key) + deleted = true + }).then(() => deleted) +} + /** Test-only: drop cached generation and mutation state between cases. */ export function resetPairingKeychainForTests(): void { cachedGeneration = null