Skip to content

Commit 89c7536

Browse files
authored
feat(ios): support explicit iOS simulator keychain reset (#2345)
* feat(ios): support explicit iOS simulator keychain reset `settings clear-app-state` never touched keychain-backed credentials (e.g. Firebase auth), so a customer's fresh-install reset via the CLI left an app signed in when their in-app reset button did not (#2282). simctl exposes no per-app keychain reset, only a whole-simulator one (`simctl keychain <device> reset`), so this ships as a separate, explicit `settings reset-keychain clear` command rather than folding it into `clear-app-state` — callers opt in knowing the scope is the whole simulator, not just the app under test. Split the pre-existing `apps.test.ts` and `snapshot-handler.test.ts` suites along the `app-settings.ts`/`snapshot-settings.ts` modules they actually mirror, since both were already over the test-file-size tripwire and could not grow further. * fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture settings reset-keychain clear <extra-arg> silently dropped the extra argument in both the CLI reader and the direct-daemon parser, so a caller expecting per-app scoping could get a whole-simulator wipe without any signal something was off. Reject it instead in both places, with tests proving no settings mutation happens. Also add a small keychain-backed "auth" fixture to the test-app's automation lab (expo-secure-store) so the settings reset-keychain guarantee has a real regression surface: authenticate, verify the credential survives clear-app-state and a plain relaunch, then verify reset-keychain actually clears it. Validated live against a disposable iOS simulator. * fix(ci): stop a bare gradle.properties append from corrupting the last line expo prebuild's generated android/gradle.properties has no trailing newline, so `echo "org.gradle.jvmargs=-Xmx4g" >> gradle.properties` appended directly onto its last line instead of a new one, producing expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=-Xmx4g. Gradle's JSON.parse of that property then fails at configure time, before any real compilation runs -- the exact "Process 'command 'node'' finished with non-zero exit value 1" failure this branch hit on Android Release and the Smoke Tests fixture-app fallback build. This was a dormant bug: the Android build-cache job only runs on a fingerprint miss, and no PR had changed the test-app's native dependencies in a while. Adding expo-secure-store (#2282's keychain fixture) was enough to trigger it. Reproduced locally against a clean install with the exact CI script, confirmed the corrupted property, and confirmed the printf-based fix builds cleanly (870/870 tasks).
1 parent a78b6bb commit 89c7536

18 files changed

Lines changed: 883 additions & 568 deletions

File tree

.github/actions/setup-fixture-app/action.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ runs:
172172
run: |
173173
set -euo pipefail
174174
pnpm --dir examples/test-app exec expo prebuild --platform android --no-install
175-
echo "org.gradle.jvmargs=-Xmx4g" >> examples/test-app/android/gradle.properties
175+
# expo prebuild's generated gradle.properties has no trailing newline, so a bare
176+
# `echo >>` corrupts its last line (e.g. `expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=...`),
177+
# which fails Gradle's JSON.parse of that property at configure time. Lead with a newline.
178+
printf '\n%s\n' 'org.gradle.jvmargs=-Xmx4g' >> examples/test-app/android/gradle.properties
176179
(
177180
cd examples/test-app/android
178181
./gradlew :app:assembleRelease

.github/workflows/test-app-build-cache.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ jobs:
188188
run: |
189189
set -euo pipefail
190190
pnpm --dir examples/test-app exec expo prebuild --platform android --no-install
191-
echo "org.gradle.jvmargs=-Xmx4g" >> examples/test-app/android/gradle.properties
191+
# expo prebuild's generated gradle.properties has no trailing newline, so a bare
192+
# `echo >>` corrupts its last line (e.g. `expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=...`),
193+
# which fails Gradle's JSON.parse of that property at configure time. Lead with a newline.
194+
printf '\n%s\n' 'org.gradle.jvmargs=-Xmx4g' >> examples/test-app/android/gradle.properties
192195
(
193196
cd examples/test-app/android
194197
./gradlew :app:assembleRelease

examples/test-app/app.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ module.exports = {
2020
buildCacheProvider: { plugin: 'expo-build-disk-cache' },
2121
plugins: [
2222
'expo-router',
23+
'expo-secure-store',
2324
[
2425
'expo-audio',
2526
{

examples/test-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"expo-linking": "56.0.14",
2121
"expo-modules-core": "56.0.17",
2222
"expo-router": "~56.2.11",
23+
"expo-secure-store": "~56.0.4",
2324
"expo-status-bar": "~56.0.4",
2425
"react": "19.2.3",
2526
"react-dom": "19.2.3",

examples/test-app/pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/test-app/src/screens/AutomationLabScreen.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ import {
1414
} from 'react-native';
1515
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
1616
import { requireOptionalNativeModule } from 'expo-modules-core';
17+
import * as SecureStore from 'expo-secure-store';
1718

1819
import { ActionButton, ScreenTitle, SectionCard } from '../components';
1920
import { useAppColors, type AppColors } from '../theme';
2021

22+
// A fixed key/value pair standing in for a real login token: this screen only
23+
// needs to prove keychain-backed state survives `clear-app-state` but not
24+
// `reset-keychain`, not to model an actual auth flow.
25+
const KEYCHAIN_AUTH_KEY = 'automation-keychain-auth-token';
26+
const KEYCHAIN_AUTH_VALUE = 'demo-auth-token';
27+
2128
type PushBroadcastLabModule = {
2229
lastPushBroadcast(): string;
2330
};
@@ -45,6 +52,7 @@ export function AutomationLabScreen(props: {
4552
const [microphonePermission, setMicrophonePermission] = useState('checking');
4653
const [lastPushBroadcast, setLastPushBroadcast] = useState('none');
4754
const [sheetVisible, setSheetVisible] = useState(false);
55+
const [keychainAuthStatus, setKeychainAuthStatus] = useState('checking');
4856
const permissionReadGeneration = useRef(0);
4957
const windowMode = dimensions.width > dimensions.height ? 'landscape' : 'portrait';
5058

@@ -125,6 +133,26 @@ export function AutomationLabScreen(props: {
125133
setLastPushBroadcast(pushBroadcastLab?.lastPushBroadcast() ?? 'unavailable');
126134
}
127135

136+
useEffect(() => {
137+
let mounted = true;
138+
void SecureStore.getItemAsync(KEYCHAIN_AUTH_KEY)
139+
.then((value) => {
140+
if (mounted)
141+
setKeychainAuthStatus(value === KEYCHAIN_AUTH_VALUE ? 'signed-in' : 'signed-out');
142+
})
143+
.catch(() => {
144+
if (mounted) setKeychainAuthStatus('error');
145+
});
146+
return () => {
147+
mounted = false;
148+
};
149+
}, []);
150+
151+
async function signInWithKeychain() {
152+
await SecureStore.setItemAsync(KEYCHAIN_AUTH_KEY, KEYCHAIN_AUTH_VALUE);
153+
setKeychainAuthStatus('signed-in');
154+
}
155+
128156
return (
129157
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
130158
<ScreenTitle
@@ -235,6 +263,19 @@ export function AutomationLabScreen(props: {
235263
/>
236264
</SectionCard>
237265

266+
<SectionCard title="Keychain-backed auth">
267+
<ActionButton
268+
label="Sign in (write keychain)"
269+
onPress={() => void signInWithKeychain()}
270+
testID="automation-keychain-signin"
271+
/>
272+
<StateRow
273+
label="Auth status"
274+
testID="automation-keychain-status"
275+
value={keychainAuthStatus}
276+
/>
277+
</SectionCard>
278+
238279
<SectionCard title="Android push broadcast">
239280
<ActionButton
240281
kind="secondary"

packages/contracts/src/client-settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export type SettingsUpdateOptions =
2626
state: 'clear';
2727
app?: string;
2828
})
29+
| (DeviceCommandBaseOptions & {
30+
setting: 'reset-keychain';
31+
state: 'clear';
32+
})
2933
| (DeviceCommandBaseOptions & {
3034
setting: 'wifi' | 'airplane' | 'location';
3135
state: 'on' | 'off';

packages/contracts/src/settings.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const SETTINGS_FACEID_USAGE = 'faceid <match|nonmatch|enroll|unenroll>';
4545
const SETTINGS_TOUCHID_USAGE = 'touchid <match|nonmatch|enroll|unenroll>';
4646
const SETTINGS_FINGERPRINT_USAGE = 'fingerprint <match|nonmatch>';
4747
const SETTINGS_CLEAR_APP_STATE_USAGE = 'clear-app-state [app-id]';
48+
const SETTINGS_RESET_KEYCHAIN_USAGE = 'reset-keychain clear';
4849
const SETTINGS_PERMISSION_USAGE =
4950
'permission <grant|deny|reset> <camera|microphone|photos|contacts|contacts-limited|notifications|calendar|location|location-always|media-library|motion|reminders|siri> [full|limited]';
5051
const SETTINGS_MACOS_PERMISSION_USAGE =
@@ -60,11 +61,12 @@ export const SETTINGS_USAGE_OVERRIDE = [
6061
`settings ${SETTINGS_TOUCHID_USAGE}`,
6162
`settings ${SETTINGS_FINGERPRINT_USAGE}`,
6263
`settings ${SETTINGS_CLEAR_APP_STATE_USAGE}`,
64+
`settings ${SETTINGS_RESET_KEYCHAIN_USAGE}`,
6365
`settings ${SETTINGS_PERMISSION_USAGE}`,
6466
`settings ${SETTINGS_MACOS_PERMISSION_USAGE}`,
6567
].join(' | ');
6668

67-
export const SETTINGS_INVALID_ARGS_MESSAGE = `settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_LOCATION_SET_USAGE}, ${SETTINGS_ANIMATIONS_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, ${SETTINGS_TOUCHID_USAGE}, ${SETTINGS_FINGERPRINT_USAGE}, ${SETTINGS_CLEAR_APP_STATE_USAGE}, ${SETTINGS_PERMISSION_USAGE}, or ${SETTINGS_MACOS_PERMISSION_USAGE}`;
69+
export const SETTINGS_INVALID_ARGS_MESSAGE = `settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_LOCATION_SET_USAGE}, ${SETTINGS_ANIMATIONS_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_FACEID_USAGE}, ${SETTINGS_TOUCHID_USAGE}, ${SETTINGS_FINGERPRINT_USAGE}, ${SETTINGS_CLEAR_APP_STATE_USAGE}, ${SETTINGS_RESET_KEYCHAIN_USAGE}, ${SETTINGS_PERMISSION_USAGE}, or ${SETTINGS_MACOS_PERMISSION_USAGE}`;
6870

6971
export function isMacOsSettingSupported(setting: string): boolean {
7072
const normalized = setting.trim().toLowerCase();

0 commit comments

Comments
 (0)