diff --git a/.changeset/serve-sim-pr-4605.md b/.changeset/serve-sim-pr-4605.md new file mode 100644 index 0000000000..1eea9938a9 --- /dev/null +++ b/.changeset/serve-sim-pr-4605.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': none +--- + +Add shared opt-in serve-sim Simulator startup support. diff --git a/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts new file mode 100644 index 0000000000..5e3e437146 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/iosAppArtifact.test.ts @@ -0,0 +1,63 @@ +import spawn from '@expo/turtle-spawn'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { readIosApplicationIdentifierAsync } from '../iosAppArtifact'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +describe(readIosApplicationIdentifierAsync, () => { + let directory: string; + let artifactPath: string; + + beforeEach(async () => { + jest.mocked(spawn).mockReset(); + directory = await mkdtemp(path.join(os.tmpdir(), 'ios-app-artifact-')); + artifactPath = path.join(directory, 'Example.app'); + await mkdir(artifactPath); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('reads app metadata without installing or launching', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: 'com.example.app\n', stderr: '' } as never); + await expect(readIosApplicationIdentifierAsync({ artifactPath, env: {} })).resolves.toBe( + 'com.example.app' + ); + expect(jest.mocked(spawn).mock.calls).toEqual([ + [ + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(artifactPath, 'Info.plist')], + { stdio: 'pipe', env: {} }, + ], + ]); + }); + + it.each(['missing', 'file', 'wrong extension'])('rejects an invalid artifact: %s', async kind => { + await rm(artifactPath, { recursive: true }); + if (kind === 'file') { + await writeFile(artifactPath, 'not an app directory'); + } else if (kind === 'wrong extension') { + artifactPath = path.join(directory, 'Example.ipa'); + await mkdir(artifactPath); + } + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_INVALID_ARTIFACT' }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('rejects a missing bundle identifier', async () => { + jest.mocked(spawn).mockResolvedValue({ stdout: ' ', stderr: '' } as never); + await expect( + readIosApplicationIdentifierAsync({ artifactPath, env: {} }) + ).rejects.toMatchObject({ errorCode: 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER' }); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts new file mode 100644 index 0000000000..b9e6ad4741 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/iosSimulatorSession.test.ts @@ -0,0 +1,191 @@ +import { BuildRuntimePlatform } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; + +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { IosSimulatorUtils, type IosSimulatorUuid } from '../../../utils/IosSimulatorUtils'; +import { resolveIosSessionStartupAsync } from '../iosSimulatorSession'; +import { readIosApplicationIdentifierAsync } from '../iosAppArtifact'; +import { + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, +} from '../localEgressGuard'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../../utils/IosSimulatorUtils'); +jest.mock('../iosAppArtifact', () => ({ readIosApplicationIdentifierAsync: jest.fn() })); +jest.mock('../localEgressGuard', () => ({ + resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(), + startLocalEgressGuardRelayAsync: jest.fn(), +})); + +const mockedSpawn = jest.mocked(spawn); +const mockedUtils = jest.mocked(IosSimulatorUtils); + +describe('serve-sim Simulator preparation', () => { + const hostOptions = { + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: {}, + logger: createMockLogger(), + }; + beforeEach(() => { + jest.clearAllMocks(); + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { + udid: 'selected-udid', + name: 'iPhone 17', + isAvailable: true, + deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-17', + }, + ] as never); + mockedUtils.resolveUdidAsync.mockResolvedValue('selected-udid' as IosSimulatorUuid); + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(null); + mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as never); + jest.mocked(readIosApplicationIdentifierAsync).mockResolvedValue('dev.example.app'); + }); + it.each([ + ['argument count', Array(257).fill('a')], + ['argument length', ['a'.repeat(9000)]], + ['NUL argument', ['a\u0000b']], + ['total UTF-8 bytes', Array(8).fill('😃'.repeat(4096))], + ])('rejects invalid launch %s before Simulator preparation', async (_, launchArgs) => { + await expect( + resolveIosSessionStartupAsync({ + ...hostOptions, + bootSimulator: true, + launchAppIdentifier: 'dev.example.app', + launchArgs, + }) + ).rejects.toThrow('iOS launch_args'); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + }); + + it.each([ + ['an oversized URL', `https://example.test/${'a'.repeat(8192)}`], + ['NUL in a URL', 'exp://example.test/a\0b'], + ])('rejects %s before Simulator preparation', async (_, openUrl) => { + await expect( + resolveIosSessionStartupAsync({ + ...hostOptions, + bootSimulator: true, + launchAppIdentifier: 'dev.example.app', + openUrl, + }) + ).rejects.toThrow('iOS open_url'); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + }); + + it('keeps an explicit launch identifier independent of the installed app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + installAppPath: '/tmp/App.app', + launchAppIdentifier: 'another.installed.app', + }); + expect(startup.launch.launchAppIdentifier).toBe('another.installed.app'); + expect(readIosApplicationIdentifierAsync).not.toHaveBeenCalled(); + }); + + it('infers the launch identifier from the downloaded app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + installAppPath: '/tmp/App.app', + }); + expect(startup.launch.launchAppIdentifier).toBe('dev.example.app'); + expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({ + artifactPath: '/tmp/App.app', + env: {}, + }); + }); + + it('does no Simulator work on Android and rejects iOS installation inputs', async () => { + const options = { ...hostOptions, runtimePlatform: BuildRuntimePlatform.LINUX }; + await expect(resolveIosSessionStartupAsync(options)).resolves.toEqual({ + launch: { launchArgs: [], launchAppIdentifier: undefined, openUrl: undefined }, + }); + await expect( + resolveIosSessionStartupAsync({ ...options, installAppPath: '/tmp/App.app' }) + ).rejects.toThrow('only supported for iOS'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + }); + + it('preserves older workflows that already prepared the Simulator and app', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + launchAppIdentifier: 'previously.installed.app', + }); + expect(startup.iosStartup).toBeUndefined(); + expect(startup.launch.launchAppIdentifier).toBe('previously.installed.app'); + expect(mockedUtils.resolveUdidAsync).not.toHaveBeenCalled(); + expect(resolveLocalEgressServeSimBootEnvironmentAsync).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('prepares a session without an app when the workflow requests owned startup', async () => { + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); + expect(startup.launch.launchAppIdentifier).toBeUndefined(); + }); + + it.each(['Booted', 'Booting', 'Shutdown'])( + 'prepares guarded startup on a %s device', + async state => { + const bootEnv = { SERVE_SIM_ADDITIONAL_DYLIBS: '/guard.dylib' }; + jest.mocked(resolveLocalEgressServeSimBootEnvironmentAsync).mockResolvedValue(bootEnv); + mockedUtils.getDeviceAsync.mockResolvedValue({ state } as never); + const startup = await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(startup.iosStartup).toEqual({ + iosSimulatorUdid: 'SELECTED-UDID', + bootEnv, + installAppPath: undefined, + }); + if (state === 'Shutdown') { + expect(spawn).not.toHaveBeenCalled(); + } else { + expect(spawn).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'shutdown', 'SELECTED-UDID'], + expect.anything() + ); + } + expect(startLocalEgressGuardRelayAsync).toHaveBeenCalled(); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + } + ); + + it('resolves an explicit Simulator without booting or configuring it', async () => { + const startup = await resolveIosSessionStartupAsync({ + ...hostOptions, + deviceIdentifier: 'iPhone 17', + }); + expect(startup.iosStartup?.iosSimulatorUdid).toBe('SELECTED-UDID'); + expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({ + deviceIdentifier: 'iPhone 17', + env: {}, + }); + expect(mockedUtils.getAvailableDevicesAsync).not.toHaveBeenCalled(); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + expect(mockedUtils.startAsync).not.toHaveBeenCalled(); + expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled(); + }); + + it('selects the default iPhone without booting', async () => { + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([ + { name: 'iPad', udid: 'ipad' }, + { name: 'iPhone 17 Pro', udid: 'pro' }, + { name: 'iPhone 17', udid: 'base' }, + ] as never); + await resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }); + expect(mockedUtils.resolveUdidAsync).toHaveBeenCalledWith({ + deviceIdentifier: 'base', + env: {}, + }); + expect(mockedUtils.bootAsync).not.toHaveBeenCalled(); + mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]); + await expect( + resolveIosSessionStartupAsync({ ...hostOptions, bootSimulator: true }) + ).rejects.toThrow('Could not find an iPhone'); + }); +}); diff --git a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts index 112f5965a8..1578208be1 100644 --- a/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/localEgressGuard.test.ts @@ -21,6 +21,8 @@ import { rebindLocalEgressGuardRelays, resolveEgressGuardLibraryAsync, resolveLocalEgressBootEnvironmentAsync, + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, stopLocalEgressGuardRelaysAsync, verifyLocalEgressGuardAsync, } from '../localEgressGuard'; @@ -401,6 +403,38 @@ describe(installLocalEgressGuardAsync, () => { expect(sessionLogger.lines.at(-1)?.msg).toContain('refused 1 connection attempt(s)'); }); + it('still installs the guard when its event log cannot be created', async () => { + const handoffPath = path.join(dir, 'handoff.json'); + await writeLocalEgressHandoffAsync(handoff, handoffPath); + const libraryPath = path.join(dir, 'egress-guard.dylib'); + const blockedDirectory = path.join(dir, 'not-a-directory'); + await fs.promises.writeFile(blockedDirectory, 'file'); + await expect( + installLocalEgressGuardAsync({ + udid: 'u' as any, + env: process.env, + logger, + handoffPath, + libraryPath, + logPath: path.join(blockedDirectory, 'guard.log'), + }) + ).resolves.toBe(true); + expect(mockedSetEnv).toHaveBeenCalledWith({ + udid: 'u', + env: process.env, + variables: buildGuardLaunchdEnvironment({ + libraryPath, + logPath: path.join(blockedDirectory, 'guard.log'), + mode: 'block', + }), + }); + expect( + logger.lines.some( + line => line.level === 'warn' && line.msg.includes('They are still refused.') + ) + ).toBe(true); + }); + it('fails the session when launchctl fails', async () => { const handoffPath = path.join(dir, 'handoff.json'); await writeLocalEgressHandoffAsync(handoff, handoffPath); @@ -469,6 +503,101 @@ describe(resolveLocalEgressBootEnvironmentAsync, () => { }); }); +describe(resolveLocalEgressServeSimBootEnvironmentAsync, () => { + let dir: string; + let handoffPath: string; + beforeEach(async () => { + dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-serve-sim-')); + handoffPath = path.join(dir, 'handoff.json'); + }); + afterEach(async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }); + + it('does not require the guard or check for an ordinary session', async () => { + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: null, + checkPath: null, + }) + ).resolves.toBeNull(); + }); + + it('requires both packaged files before handing startup to serve-sim', async () => { + await writeLocalEgressHandoffAsync(handoff, handoffPath); + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ handoffPath, libraryPath: null }) + ).rejects.toThrow('missing bin/egress-guard.dylib'); + await expect( + resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: '/w/bin/egress-guard.dylib', + checkPath: null, + }) + ).rejects.toThrow('missing bin/egress-guard-check'); + }); + + it('passes the additional dylib and prefixed child variables without host DYLD injection', async () => { + await writeLocalEgressHandoffAsync(handoff, handoffPath); + const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync({ + handoffPath, + libraryPath: '/w/bin/egress-guard.dylib', + checkPath: '/w/bin/egress-guard-check', + logPath: '/tmp/guard.log', + }); + expect(bootEnv).toEqual({ + SERVE_SIM_ADDITIONAL_DYLIBS: '/w/bin/egress-guard.dylib', + SIMCTL_CHILD_EAS_EGRESS_GUARD_LOG: '/tmp/guard.log', + SIMCTL_CHILD_EAS_EGRESS_GUARD_MODE: 'block', + SIMCTL_CHILD_http_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_https_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_HTTP_PROXY: 'http://127.0.0.1:8899', + SIMCTL_CHILD_HTTPS_PROXY: 'http://127.0.0.1:8899', + SIMCTL_CHILD_grpc_proxy: 'http://127.0.0.1:8899', + SIMCTL_CHILD_no_proxy: 'localhost,127.0.0.1,::1', + SIMCTL_CHILD_NO_PROXY: 'localhost,127.0.0.1,::1', + }); + }); +}); + +describe(startLocalEgressGuardRelayAsync, () => { + it('relays boot events once after repeated starts without changing Simulator state', async () => { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'guard-relay-')); + const logPath = path.join(dir, 'guard.log'); + const logger = createLogger(); + mockedSetEnv.mockClear(); + await fs.promises.writeFile( + logPath, + 'eas-egress-guard\tSpringBoard\t4001\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' + ); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + try { + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 }); + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs: 5 }); + await fs.promises.appendFile( + logPath, + 'eas-egress-guard\tMyApp\t4002\tconnect\tblocked\t1.1.1.1:443\tNetwork\n' + ); + await jest.advanceTimersByTimeAsync(20); + await stopLocalEgressGuardRelaysAsync(logger); + expect(mockedSetEnv).not.toHaveBeenCalled(); + expect( + logger.lines.filter(line => /refused connect from SpringBoard/.test(line.msg)) + ).toHaveLength(1); + expect(logger.lines.filter(line => /refused connect from MyApp/.test(line.msg))).toHaveLength( + 1 + ); + expect(logger.lines.at(-1)?.msg).toContain('refused 2 connection attempt(s)'); + } finally { + await stopLocalEgressGuardRelaysAsync(logger); + jest.clearAllTimers(); + jest.useRealTimers(); + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }); +}); + describe(parseGuardCoverage, () => { const ps = [ ' 1 0 /sbin/launchd', diff --git a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts index f0d833a5c8..3981fc5a9e 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -27,6 +27,7 @@ import { readServeSimServersAsync } from '../serveSimMetricsRecorder'; import { sleepAsync } from '../../../utils/retry'; import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings'; import { + createIosSessionStartupInputProviders, createServeSimLaunchInputProviders, describeServeSimLaunch, ensureFfmpegInstalledOnceAsync, @@ -35,6 +36,7 @@ import { spawnDetached, startNgrokTunnelAsync, turnIceServersToWebPreviewArgs, + validateServeSimLaunchOptions, waitForDeviceRunSessionStoppedAsync, } from '../remoteDeviceRunSession'; @@ -213,6 +215,51 @@ function createEnvMock(): BuildStepEnv { return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv; } +describe(createIosSessionStartupInputProviders, () => { + it('declares optional inputs so existing workflows do not opt into owned startup', () => { + const inputs = createIosSessionStartupInputProviders().map(provider => + provider(createGlobalContextMock(), 'Test step') + ); + expect( + inputs.map(({ id, required, allowedValueTypeName }) => ({ + id, + required, + allowedValueTypeName, + })) + ).toEqual([ + { + id: 'boot_simulator', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }, + { + id: 'device_identifier', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + { + id: 'install_app_path', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }, + ]); + }); +}); + +describe(validateServeSimLaunchOptions, () => { + it('accepts empty options and arguments at the count, character and byte limits', () => { + expect(() => validateServeSimLaunchOptions({})).not.toThrow(); + expect(() => validateServeSimLaunchOptions({ launchArgs: Array(256).fill('a') })).not.toThrow(); + expect(() => validateServeSimLaunchOptions({ launchArgs: ['a'.repeat(8192)] })).not.toThrow(); + expect(() => + validateServeSimLaunchOptions({ launchArgs: Array(16).fill('a'.repeat(8191)) }) + ).not.toThrow(); + expect(() => + validateServeSimLaunchOptions({ openUrl: `https://${'a'.repeat(8184)}` }) + ).not.toThrow(); + }); +}); + describe(createServeSimLaunchInputProviders, () => { it('declares the launch inputs as optional', () => { const globalCtx = createGlobalContextMock(); diff --git a/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts new file mode 100644 index 0000000000..fe7695a3e9 --- /dev/null +++ b/packages/build-tools/src/steps/utils/__tests__/serveSimActions.test.ts @@ -0,0 +1,184 @@ +import { once } from 'node:events'; +import { + access, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + readlink, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { WebSocketServer } from 'ws'; + +import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +let server: WebSocketServer; +let port: number; +const token = 'test-session-token'; +const request = () => ({ + port, + token, + action: 'app.install', + params: { udid: 'device', path: '/tmp/App.app' }, + timeoutMs: 2_000, +}); + +beforeEach(async () => { + server = new WebSocketServer({ host: '127.0.0.1', port: 0 }); + await once(server, 'listening'); + port = (server.address() as { port: number }).port; +}); + +afterEach(async () => { + for (const socket of server.clients) { + socket.terminate(); + } + await new Promise(resolve => server.close(() => resolve())); +}); + +it('authenticates the upgrade, waits for ready, and sends the action once', async () => { + const actions: unknown[] = []; + const upgrades: unknown[] = []; + server.on('connection', (socket, upgrade) => { + upgrades.push({ + url: upgrade.url, + protocol: upgrade.headers['sec-websocket-protocol'], + }); + socket.on('message', raw => { + actions.push(JSON.parse(raw.toString())); + socket.send(JSON.stringify({ id: 1, stdout: '', stderr: '', exitCode: 0 })); + }); + socket.send(JSON.stringify({ ready: true })); + socket.send(JSON.stringify({ ready: true })); + }); + await runServeSimActionAsync(request()); + expect(upgrades).toEqual([{ url: '/exec-ws', protocol: `serve-sim.token.${token}` }]); + expect(actions).toEqual([{ id: 1, action: 'app.install', params: request().params }]); +}); + +it.each([ + [{ id: 1, exitCode: 1, stderr: 'install refused' }, 'install refused'], + [{ id: 1, error: 'unknown action app.install' }, 'unknown action'], + [{ id: 1, stdout: '' }, 'invalid response'], + [{ id: 1, exitCode: 1, stderr: '' }, 'invalid response'], +])('rejects unsuccessful action replies without retrying', async (reply, message) => { + let attempts = 0; + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + attempts++; + socket.send(JSON.stringify(reply)); + }); + }); + await expect(runServeSimActionAsync(request())).rejects.toThrow(message); + expect(attempts).toBe(1); +}); + +it('rejects a connection that closes before its action finishes', async () => { + server.on('connection', socket => socket.close()); + await expect(runServeSimActionAsync(request())).rejects.toThrow('closed before completion'); +}); + +it('rejects an upgrade refused by the server without executing an action', async () => { + const connected = jest.fn(); + server.options.verifyClient = () => false; + server.on('connection', connected); + await expect(runServeSimActionAsync(request())).rejects.toThrow('connection failed'); + expect(connected).not.toHaveBeenCalled(); +}); + +it('rejects malformed replies without resending the action', async () => { + const actions = jest.fn(); + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + actions(); + socket.send('not JSON'); + }); + }); + await expect(runServeSimActionAsync(request())).rejects.toThrow('returned invalid JSON'); + expect(actions).toHaveBeenCalledTimes(1); +}); + +it('times out and closes a socket that never becomes ready', async () => { + const disconnected = new Promise(resolve => + server.on('connection', socket => socket.on('close', resolve)) + ); + await expect(runServeSimActionAsync({ ...request(), timeoutMs: 30 })).rejects.toThrow( + 'timed out' + ); + await disconnected; +}); + +it('aborts an in-flight action without resending it', async () => { + const controller = new AbortController(); + let attempts = 0; + server.on('connection', socket => { + socket.send(JSON.stringify({ ready: true })); + socket.on('message', () => { + attempts++; + controller.abort(new Error('session stopped')); + }); + }); + await expect(runServeSimActionAsync({ ...request(), signal: controller.signal })).rejects.toThrow( + 'session stopped' + ); + expect(attempts).toBe(1); +}); + +it('does not connect when already aborted', async () => { + const connected = jest.fn(); + server.on('connection', connected); + await expect( + runServeSimActionAsync({ + ...request(), + signal: AbortSignal.abort(new Error('already stopped')), + }) + ).rejects.toThrow('already stopped'); + expect(connected).not.toHaveBeenCalled(); +}); + +it("stages a local app without consuming the caller's original", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-')); + try { + const source = path.join(root, 'downloaded', 'App.app'); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, 'marker'), 'app'); + await symlink('marker', path.join(source, 'current')); + const alias = path.join(root, 'Alias.app'); + await symlink(source, alias); + const staged = await stageServeSimAppAsync(alias, root); + expect(staged.path).toMatch(/serve-sim-uploads\/eas-app-[^/]+\/Alias.app$/); + expect((await lstat(staged.path)).isDirectory()).toBe(true); + await access(path.join(staged.path, 'marker')); + expect(await readlink(path.join(staged.path, 'current'))).toBe('marker'); + expect((await stat(path.join(root, 'serve-sim-uploads'))).mode & 0o777).toBe(0o700); + await rm(staged.directory, { recursive: true, force: true }); + expect(await readFile(path.join(source, 'marker'), 'utf8')).toBe('app'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it('removes its staging directory when the source app cannot be read', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-stage-test-')); + try { + await expect(stageServeSimAppAsync(path.join(root, 'Missing.app'), root)).rejects.toThrow( + 'ENOENT' + ); + expect(await readdir(path.join(root, 'serve-sim-uploads'))).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/build-tools/src/steps/utils/iosAppArtifact.ts b/packages/build-tools/src/steps/utils/iosAppArtifact.ts new file mode 100644 index 0000000000..145f0bae0e --- /dev/null +++ b/packages/build-tools/src/steps/utils/iosAppArtifact.ts @@ -0,0 +1,41 @@ +import { UserError } from '@expo/eas-build-job'; +import { type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import fs from 'node:fs'; +import path from 'node:path'; + +export async function readIosApplicationIdentifierAsync({ + artifactPath, + env, +}: { + artifactPath: string; + env: BuildStepEnv; +}): Promise { + const artifactStat = await fs.promises.stat(artifactPath).catch(err => { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + `Build artifact does not exist at ${artifactPath}.`, + { cause: err } + ); + }); + if (path.extname(artifactPath) !== '.app' || !artifactStat.isDirectory()) { + throw new UserError( + 'EAS_INSTALL_BUILD_INVALID_ARTIFACT', + 'iOS Simulator sessions require a .app build artifact.' + ); + } + const infoPlistPath = path.join(artifactPath, 'Info.plist'); + const { stdout } = await spawn( + 'plutil', + ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', infoPlistPath], + { stdio: 'pipe', env } + ); + const applicationIdentifier = stdout.trim(); + if (!applicationIdentifier) { + throw new UserError( + 'EAS_INSTALL_BUILD_MISSING_IDENTIFIER', + `Could not read CFBundleIdentifier from ${infoPlistPath}.` + ); + } + return applicationIdentifier; +} diff --git a/packages/build-tools/src/steps/utils/iosSimulatorSession.ts b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts new file mode 100644 index 0000000000..8948bece00 --- /dev/null +++ b/packages/build-tools/src/steps/utils/iosSimulatorSession.ts @@ -0,0 +1,110 @@ +import { UserError } from '@expo/eas-build-job'; +import { type bunyan } from '@expo/logger'; +import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; +import { minBy } from 'lodash'; + +import { + type IosSimulatorName, + IosSimulatorUtils, + type IosSimulatorUuid, +} from '../../utils/IosSimulatorUtils'; +import { + resolveLocalEgressServeSimBootEnvironmentAsync, + startLocalEgressGuardRelayAsync, +} from './localEgressGuard'; +import { readIosApplicationIdentifierAsync } from './iosAppArtifact'; +import { + type ServeSimLaunchOptions, + parseServeSimLaunchInputs, + validateServeSimLaunchOptions, +} from './remoteDeviceRunSession'; + +export type IosSessionStartup = { + iosSimulatorUdid: string; + installAppPath?: string; + bootEnv?: Record; +}; + +export async function resolveIosSessionStartupAsync({ + runtimePlatform, + bootSimulator, + deviceIdentifier, + installAppPath, + launchAppIdentifier, + launchArgs, + openUrl, + env, + logger, + signal, +}: { + runtimePlatform: BuildRuntimePlatform; + bootSimulator?: boolean; + deviceIdentifier?: string; + installAppPath?: string; + launchAppIdentifier?: unknown; + launchArgs?: unknown; + openUrl?: unknown; + env: BuildStepEnv; + logger: bunyan; + signal?: AbortSignal; +}): Promise<{ launch: ServeSimLaunchOptions; iosStartup?: IosSessionStartup }> { + signal?.throwIfAborted(); + if ((bootSimulator || installAppPath) && runtimePlatform !== BuildRuntimePlatform.DARWIN) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'install_app_path is only supported for iOS Simulator sessions.' + ); + } + const applicationIdentifier = + launchAppIdentifier ?? + (installAppPath + ? await readIosApplicationIdentifierAsync({ artifactPath: installAppPath, env }) + : undefined); + const launch = parseServeSimLaunchInputs( + { launchAppIdentifier: applicationIdentifier, launchArgs, openUrl }, + { runtimePlatform } + ); + if ( + runtimePlatform !== BuildRuntimePlatform.DARWIN || + (!bootSimulator && !deviceIdentifier && !installAppPath) + ) { + return { launch }; + } + validateServeSimLaunchOptions(launch); + signal?.throwIfAborted(); + let selectedIdentifier = (deviceIdentifier ?? undefined) as + | IosSimulatorUuid + | IosSimulatorName + | undefined; + if (selectedIdentifier === undefined) { + const devices = await IosSimulatorUtils.getAvailableDevicesAsync({ env, filter: 'available' }); + selectedIdentifier = minBy( + devices.filter(device => device.name.startsWith('iPhone')), + device => device.name.length + )?.udid; + } + if (!selectedIdentifier) { + throw new Error('Could not find an iPhone among available simulator devices.'); + } + const iosSimulatorUdid = ( + await IosSimulatorUtils.resolveUdidAsync({ deviceIdentifier: selectedIdentifier, env }) + ).toUpperCase() as IosSimulatorUuid; + const bootEnv = await resolveLocalEgressServeSimBootEnvironmentAsync(); + signal?.throwIfAborted(); + if (bootEnv) { + const device = await IosSimulatorUtils.getDeviceAsync({ udid: iosSimulatorUdid, env }); + signal?.throwIfAborted(); + if (device && device.state !== 'Shutdown') { + logger.info(`Shutting down ${iosSimulatorUdid} to apply the local egress guard at boot.`); + await spawn('xcrun', ['simctl', 'shutdown', iosSimulatorUdid], { env, logger, signal }); + } + signal?.throwIfAborted(); + await startLocalEgressGuardRelayAsync({ logger }); + } + signal?.throwIfAborted(); + return { + launch, + iosStartup: { iosSimulatorUdid, installAppPath, bootEnv: bootEnv ?? undefined }, + }; +} diff --git a/packages/build-tools/src/steps/utils/localEgressGuard.ts b/packages/build-tools/src/steps/utils/localEgressGuard.ts index 7990b164c2..0c85b995a5 100644 --- a/packages/build-tools/src/steps/utils/localEgressGuard.ts +++ b/packages/build-tools/src/steps/utils/localEgressGuard.ts @@ -110,6 +110,18 @@ export async function resolveEgressGuardCheckAsync( return await resolvePackagedFileAsync(binDir, EGRESS_GUARD_CHECK_FILE); } +async function requireEgressGuardCheckAsync(checkPath?: string | null): Promise { + const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath; + if (!resolvedCheck) { + throw new SystemError( + 'The local egress guard self-check is not available on this device host, so this session cannot ' + + 'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' + + 'this is a service problem, please contact support.' + ); + } + return resolvedCheck; +} + /** * Turns guard events into session log lines: one line the first time a * process reaches a destination through a given call, counts after that, and @@ -278,6 +290,14 @@ export class GuardLogTailer { } } +type LocalEgressBootEnvironmentOptions = { + handoffPath?: string; + /** Explicit library path, `null` for "not available"; resolved from the package when omitted. */ + libraryPath?: string | null; + logPath?: string; + mode?: EgressGuardMode; +}; + /** * The environment the simulator's launchd must have from its first process: * the guard and the proxy variables. Pass it to `IosSimulatorUtils.bootAsync`, @@ -291,13 +311,7 @@ export async function resolveLocalEgressBootEnvironmentAsync({ libraryPath, logPath = LOCAL_EGRESS_GUARD_LOG_PATH, mode = 'block', -}: { - handoffPath?: string; - /** Explicit library path, `null` for "not available"; resolved from the package when omitted. */ - libraryPath?: string | null; - logPath?: string; - mode?: EgressGuardMode; -} = {}): Promise | null> { +}: LocalEgressBootEnvironmentOptions = {}): Promise | null> { const handoff = await readLocalEgressHandoffAsync(handoffPath); if (!handoff) { return null; @@ -317,6 +331,27 @@ export async function resolveLocalEgressBootEnvironmentAsync({ }; } +/** Supply the guard at boot and require its self-check before starting serve-sim. */ +export async function resolveLocalEgressServeSimBootEnvironmentAsync({ + checkPath, + ...options +}: LocalEgressBootEnvironmentOptions & { + checkPath?: string | null; +} = {}): Promise | null> { + const environment = await resolveLocalEgressBootEnvironmentAsync(options); + if (!environment) { + return null; + } + await requireEgressGuardCheckAsync(checkPath); + const { DYLD_INSERT_LIBRARIES, ...childEnvironment } = environment; + return { + SERVE_SIM_ADDITIONAL_DYLIBS: DYLD_INSERT_LIBRARIES, + ...Object.fromEntries( + Object.entries(childEnvironment).map(([name, value]) => [`SIMCTL_CHILD_${name}`, value]) + ), + }; +} + type ActiveRelay = { source: { stopAsync: () => Promise }; relay: GuardEventRelay; @@ -325,6 +360,44 @@ type ActiveRelay = { }; const activeRelays = new Map(); +/** Start reporting guard events without changing the Simulator's launchd environment. */ +export async function startLocalEgressGuardRelayAsync({ + logger, + logPath = LOCAL_EGRESS_GUARD_LOG_PATH, + tailIntervalMs, +}: { + logger: bunyan; + logPath?: string; + tailIntervalMs?: number; +}): Promise { + try { + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + await fs.promises.appendFile(logPath, ''); + } catch (err) { + logger.warn( + { err }, + `Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.` + ); + return; + } + if (activeRelays.has(logPath)) { + return; + } + const relay = new GuardEventRelay(logger); + const tailer = new GuardLogTailer({ + path: logPath, + intervalMs: tailIntervalMs, + onLine: line => { + const event = parseGuardLogLine(line); + if (event) { + relay.handle(event); + } + }, + }); + tailer.start(); + activeRelays.set(logPath, { source: tailer, relay }); +} + /** * Report events from another source, such as the Android emulator's packet * fence, through the same session log lines, step rebinding and summary as the @@ -396,17 +469,7 @@ export async function installLocalEgressGuardAsync({ await logAlreadyRunningProcessesAsync({ env, logger }); - let logWritable = true; - try { - await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); - await fs.promises.appendFile(logPath, ''); - } catch (err) { - logWritable = false; - logger.warn( - { err }, - `Local egress guard: could not create ${logPath}, so refused connections will not be reported in this log. They are still refused.` - ); - } + await startLocalEgressGuardRelayAsync({ logger, logPath, tailIntervalMs }); try { await IosSimulatorUtils.setLaunchdEnvironmentAsync({ @@ -423,22 +486,6 @@ export async function installLocalEgressGuardAsync({ ); } - if (logWritable && !activeRelays.has(logPath)) { - const relay = new GuardEventRelay(logger); - const tailer = new GuardLogTailer({ - path: logPath, - intervalMs: tailIntervalMs, - onLine: line => { - const event = parseGuardLogLine(line); - if (event) { - relay.handle(event); - } - }, - }); - tailer.start(); - activeRelays.set(logPath, { source: tailer, relay }); - } - logger.info( `Local egress guard installed in the Simulator (mode ${mode}): connections that bypass the system proxy are ${ mode === 'block' ? 'refused' : 'observed' @@ -658,14 +705,7 @@ export async function verifyLocalEgressGuardAsync({ /** Explicit check binary path, `null` for "not available"; resolved from the package when omitted. */ checkPath?: string | null; }): Promise { - const resolvedCheck = checkPath === undefined ? await resolveEgressGuardCheckAsync() : checkPath; - if (!resolvedCheck) { - throw new SystemError( - 'The local egress guard self-check is not available on this device host, so this session cannot ' + - 'verify that the guard is in effect. The device host image is missing bin/egress-guard-check; ' + - 'this is a service problem, please contact support.' - ); - } + const resolvedCheck = await requireEgressGuardCheckAsync(checkPath); let output: string; try { const result = await spawn('xcrun', ['simctl', 'spawn', udid, resolvedCheck, '--mode', mode], { diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index ee7e97129c..f2f8f376c2 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -717,6 +717,53 @@ export function spawnDetached({ }; } +export function createIosSessionStartupInputProviders(): ReturnType< + typeof BuildStepInput.createProvider +>[] { + return [ + BuildStepInput.createProvider({ + id: 'boot_simulator', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN, + }), + ...['device_identifier', 'install_app_path'].map(id => + BuildStepInput.createProvider({ + id, + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }) + ), + ]; +} + +export type ServeSimApplicationOptions = ServeSimLaunchOptions & { + installAppPath: string; + launchAppIdentifier: string; +}; + +export function validateServeSimLaunchOptions({ + launchArgs = [], + openUrl, +}: Pick): void { + // Reject oversized iOS command arguments before starting the host. + if ( + launchArgs.length > 256 || + launchArgs.some(arg => arg.length > 8192 || arg.includes('\0')) || + launchArgs.reduce((bytes, arg) => bytes + Buffer.byteLength(arg) + 1, 0) > 128 * 1024 + ) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'iOS launch_args supports up to 256 arguments of 8192 characters each, without NUL, and at most 128 KiB total UTF-8 bytes including terminators.' + ); + } + if (openUrl && (openUrl.length > 8192 || openUrl.includes('\0'))) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'iOS open_url supports at most 8192 characters, without NUL.' + ); + } +} + export interface ServeSimLaunchOptions { launchAppIdentifier?: string; launchArgs?: string[]; diff --git a/packages/build-tools/src/steps/utils/serveSimActions.ts b/packages/build-tools/src/steps/utils/serveSimActions.ts new file mode 100644 index 0000000000..562a39415d --- /dev/null +++ b/packages/build-tools/src/steps/utils/serveSimActions.ts @@ -0,0 +1,101 @@ +import { SystemError } from '@expo/eas-build-job'; +import { constants } from 'node:fs'; +import { cp, mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import WebSocket from 'ws'; + +export async function stageServeSimAppAsync( + appPath: string, + tmpdir = os.tmpdir() +): Promise<{ + directory: string; + path: string; +}> { + // Clone into app.install's approved root while preserving the caller's app. + const root = path.join(tmpdir, 'serve-sim-uploads'); + await mkdir(root, { recursive: true, mode: 0o700 }); + const directory = await mkdtemp(path.join(root, 'eas-app-')); + const stagedPath = path.join(directory, path.basename(appPath)); + try { + await cp(await realpath(appPath), stagedPath, { + recursive: true, + mode: constants.COPYFILE_FICLONE, + verbatimSymlinks: true, + }); + return { directory, path: stagedPath }; + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } +} + +export async function runServeSimActionAsync({ + port, + token, + action, + params, + timeoutMs, + signal, +}: { + port: number; + token: string; + action: string; + params: Record; + timeoutMs: number; + signal?: AbortSignal; +}): Promise { + signal?.throwIfAborted(); + await new Promise((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${port}/exec-ws`, [`serve-sim.token.${token}`]); + let settled = false; + let sent = false; + const finish = (error?: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', aborted); + socket.terminate(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const aborted = () => finish(signal?.reason ?? new Error('Serve-sim action aborted.')); + const timer = setTimeout( + () => finish(new SystemError(`serve-sim ${action} timed out.`)), + timeoutMs + ); + signal?.addEventListener('abort', aborted, { once: true }); + socket.on('error', () => finish(new SystemError(`serve-sim ${action} connection failed.`))); + socket.on('close', () => + finish(new SystemError(`serve-sim ${action} connection closed before completion.`)) + ); + socket.on('message', data => { + let reply; + try { + reply = JSON.parse(data.toString()); + } catch { + return finish(new SystemError(`serve-sim ${action} returned invalid JSON.`)); + } + if (reply?.ready === true && !sent) { + sent = true; + socket.send(JSON.stringify({ id: 1, action, params })); + } else if (reply?.id === 1 && sent) { + finish( + reply.exitCode === 0 + ? undefined + : new SystemError( + `serve-sim ${action} failed: ${(typeof reply.error === 'string' && reply.error) || (typeof reply.stderr === 'string' && reply.stderr) || 'invalid response'}` + ) + ); + } + }); + if (signal?.aborted) { + aborted(); + } + }); +}