Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/cloud/CloudAuthProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ vi.mock("../../lib/runtime", () => ({
},
}));

vi.mock("../../lib/protectedData", () => ({
whenProtectedDataAvailable: vi.fn(async () => undefined),
}));

vi.mock("../../connection/catalog", () => ({
environmentCatalog: {
removeRelayEnvironments: {},
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/src/features/cloud/CloudAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -211,7 +224,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) {
}

return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<ClerkProvider publishableKey={publishableKey} tokenCache={protectedTokenCache}>
<CloudAuthBridge>{props.children}</CloudAuthBridge>
</ClerkProvider>
);
Expand Down
63 changes: 63 additions & 0 deletions apps/mobile/src/lib/protectedData.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>>(),
}));

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<boolean>();
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();
});
});
40 changes: 40 additions & 0 deletions apps/mobile/src/lib/protectedData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { requireOptionalNativeModule } from "expo";
import { AppState } from "react-native";

const NativeControls = requireOptionalNativeModule<{
readonly isProtectedDataAvailable?: () => Promise<boolean>;
}>("T3NativeControls");

let protectedDataAvailable: Promise<void> | 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<void> {
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;
}
5 changes: 5 additions & 0 deletions apps/mobile/src/lib/runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -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<
Expand Down
7 changes: 7 additions & 0 deletions docs/internals/mobile-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading