diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index a5813c8a0e8b..ea43655970ae 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -105,6 +105,14 @@ public final class T3NativeControlsModule: Module { return bounds.width > bounds.height ? "landscape" : "portrait" } + // False while the device is locked: the keychain and data-protected files + // cannot be read, which matters when iOS launches the app in the background. + @JS + @MainActor + func isProtectedDataAvailable() async -> Bool { + UIApplication.shared.isProtectedDataAvailable + } + private func launchArgument(_ flag: String) -> String? { let arguments = ProcessInfo.processInfo.arguments guard diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts index 5fe74f673141..b8b586a88230 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.test.ts @@ -20,6 +20,10 @@ vi.mock("../../lib/runtime", () => ({ }, })); +vi.mock("../../lib/protectedData", () => ({ + whenProtectedDataAvailable: vi.fn(async () => undefined), +})); + vi.mock("../../connection/catalog", () => ({ environmentCatalog: { removeRelayEnvironments: {}, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index fffdd2343044..4f77ec1fb2d5 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,4 +1,4 @@ -import { ClerkProvider, useAuth } from "@clerk/expo"; +import { ClerkProvider, type TokenCache, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; import { ManagedRelay, setManagedRelaySession } from "@t3tools/client-runtime/relay"; import { @@ -10,6 +10,7 @@ import { import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; +import { whenProtectedDataAvailable } from "../../lib/protectedData"; import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,6 +27,18 @@ import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./conne import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; import { removeCloudEnvironments } from "./cloud-drafts"; +// Clerk reads its client token from the keychain before every request. +function waitForProtectedData(cache: TokenCache): TokenCache { + return { + ...cache, + getToken: async (key) => { + await whenProtectedDataAvailable(); + return cache.getToken(key); + }, + }; +} +const protectedTokenCache = tokenCache && waitForProtectedData(tokenCache); + function resetManagedRelayTokenCache() { return settleAsyncResult(() => runtime.runPromiseExit( @@ -211,7 +224,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { } return ( - + {props.children} ); diff --git a/apps/mobile/src/lib/protectedData.test.ts b/apps/mobile/src/lib/protectedData.test.ts new file mode 100644 index 000000000000..82c2a3c4ba56 --- /dev/null +++ b/apps/mobile/src/lib/protectedData.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + appState: "background", + listeners: [] as Array<(state: string) => void>, + isProtectedDataAvailable: vi.fn<() => Promise>(), +})); + +vi.mock("expo", () => ({ + requireOptionalNativeModule: () => ({ isProtectedDataAvailable: mocks.isProtectedDataAvailable }), +})); +vi.mock("react-native", () => ({ + AppState: { + get currentState() { + return mocks.appState; + }, + addEventListener: (_event: string, listener: (state: string) => void) => { + mocks.listeners.push(listener); + return { remove: () => mocks.listeners.splice(mocks.listeners.indexOf(listener), 1) }; + }, + }, +})); + +let protectedData: typeof import("./protectedData"); + +beforeEach(async () => { + vi.resetModules(); + mocks.appState = "background"; + mocks.listeners.length = 0; + mocks.isProtectedDataAvailable.mockReset(); + protectedData = await import("./protectedData"); +}); + +describe("whenProtectedDataAvailable", () => { + it("waits for the foreground after a background launch on a locked device", async () => { + const check = Promise.withResolvers(); + mocks.isProtectedDataAvailable.mockReturnValue(check.promise); + const onAvailable = vi.fn(); + const available = protectedData.whenProtectedDataAvailable().then(onAvailable); + + check.resolve(false); + await check.promise; + expect(onAvailable).not.toHaveBeenCalled(); + + expect(mocks.listeners).toHaveLength(1); + mocks.listeners[0]?.("active"); + await available; + expect(onAvailable).toHaveBeenCalledOnce(); + expect(mocks.listeners).toHaveLength(0); + }); + + it("continues a background launch on an unlocked device", async () => { + mocks.isProtectedDataAvailable.mockResolvedValue(true); + await protectedData.whenProtectedDataAvailable(); + expect(mocks.listeners).toHaveLength(0); + }); + + it.each(["active", "inactive"])("does not wait for a foreground launch (%s)", async (state) => { + mocks.appState = state; + await protectedData.whenProtectedDataAvailable(); + expect(mocks.isProtectedDataAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/protectedData.ts b/apps/mobile/src/lib/protectedData.ts new file mode 100644 index 000000000000..8b6f427e84ff --- /dev/null +++ b/apps/mobile/src/lib/protectedData.ts @@ -0,0 +1,40 @@ +import { requireOptionalNativeModule } from "expo"; +import { AppState } from "react-native"; + +const NativeControls = requireOptionalNativeModule<{ + readonly isProtectedDataAvailable?: () => Promise; +}>("T3NativeControls"); + +let protectedDataAvailable: Promise | undefined; + +/** + * Waits until the keychain and the app's database files can be read. iOS can + * launch the app in the background while the device is locked, for example for + * a Live Activity after a restart, and those reads fail until it is unlocked. + * A launch like that waits until the app comes to the foreground. + * + * Only the launch is checked: once this resolves it stays resolved, even if the + * device locks again, so it does not protect reads made later. + */ +export function whenProtectedDataAvailable(): Promise { + protectedDataAvailable ??= new Promise((resolve) => { + const isAvailable = NativeControls?.isProtectedDataAvailable; + // Only an unlocked device can bring the app to the foreground, so only a + // background launch needs to ask. + if (isAvailable === undefined || AppState.currentState !== "background") { + resolve(); + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") done(); + }); + function done() { + subscription.remove(); + resolve(); + } + isAvailable().then((available) => { + if (available) done(); + }, done); + }); + return protectedDataAvailable; +} diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index a7f9a5dab1bd..5dd8cbcc2194 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Socket from "effect/unstable/socket/Socket"; @@ -10,6 +11,7 @@ import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import { tracingLayer } from "../features/observability/tracing"; import * as Persistence from "../persistence/layer"; import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh"; +import { whenProtectedDataAvailable } from "./protectedData"; declare const module: { readonly hot?: FoundationHotModule } | undefined; @@ -35,6 +37,9 @@ const runtimeLayer = Layer.merge( Layer.provideMerge(httpClientLayer), Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), Layer.provideMerge(Persistence.layer), + // These layers read the keychain and the database while they build, and a + // failed build is kept for the life of the process. + Layer.provide(Layer.effectDiscard(Effect.promise(whenProtectedDataAvailable))), ); export const runtime: ManagedRuntime.ManagedRuntime< diff --git a/docs/internals/mobile-development.md b/docs/internals/mobile-development.md index d1d375584bb0..e5399002ccbb 100644 --- a/docs/internals/mobile-development.md +++ b/docs/internals/mobile-development.md @@ -17,6 +17,13 @@ shutdown, but a supervisor created after its cleanup runs would escape it. A clo parent scope also closes late arrivals, preventing interrupted startup or runtime replacement from leaving a WebSocket alive outside the new registry. +iOS can launch the app in the background while the device is locked, for example for a +Live Activity after a restart. The keychain and the app database cannot be read until the +device is unlocked, and a failed layer build lasts for the life of the process. The +[shared runtime](../../apps/mobile/src/lib/runtime.ts) therefore waits for +[`whenProtectedDataAvailable`](../../apps/mobile/src/lib/protectedData.ts) before it builds. +Startup reads that bypass it, such as Clerk's token cache, must wait the same way. + Uniwind compiles CSS on Metro updates so newly used classes are discovered. It skips global style invalidation only when the generated stylesheet and theme list are unchanged. Skipping compilation would lose new classes; invalidating every