diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fe16ff05..99f6111323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder)) +- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic)) ### ๐Ÿงน Chores diff --git a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts index 4b22fe3cb4..94f9b4b5b8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession-orchestration.test.ts @@ -40,6 +40,7 @@ jest.mock('../../utils/argentEvents', () => ({ startArgentEventCollectionAsync: jest.fn(), })); jest.mock('../../utils/remoteDeviceRunSession', () => ({ + ensureFfmpegInstalledAsync: jest.fn(), getDeviceRunSessionIdOrThrow: jest.fn(), getNgrokAuthtokenOrThrow: jest.fn(), getNgrokTunnelDomainOrThrow: jest.fn(), diff --git a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts index b88fbd797f..a62c2dfe91 100644 --- a/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts +++ b/packages/build-tools/src/steps/functions/startArgentRemoteSession.ts @@ -20,6 +20,7 @@ import { sleepAsync } from '../../utils/retry'; import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts'; import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents'; import { + ensureFfmpegInstalledAsync, getDeviceRunSessionIdOrThrow, getNgrokAuthtokenOrThrow, getNgrokTunnelDomainOrThrow, @@ -89,6 +90,12 @@ export function createStartArgentRemoteSessionBuildFunction( await selectXcodeDeveloperDirectoryAsync({ env, logger }); } + // Argent shells out to ffmpeg to record the screen. Installing it pulls a + // large Homebrew dependency tree, so do not block session readiness on it: + // the session comes up at its usual speed and only a recording started in + // the first moments misses ffmpeg. Never rejects, so `void` is safe. + void ensureFfmpegInstalledAsync({ runtimePlatform, env, logger }); + logger.info('Enabling the Argent artifacts list endpoint flag.'); await spawn( 'bunx', 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 5b5ed21e2b..84908b4cec 100644 --- a/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/remoteDeviceRunSession.test.ts @@ -1,5 +1,6 @@ import { bunyan } from '@expo/logger'; -import { BuildStepEnv } from '@expo/steps'; +import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps'; +import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { setTimeout as setTimeoutAsync } from 'node:timers/promises'; @@ -9,6 +10,7 @@ import { turtleFetch } from '../../../utils/turtleFetch'; import { sleepAsync } from '../../../utils/retry'; import { createServeSimArgs, + ensureFfmpegInstalledAsync, fetchServeSimTurnArgsAsync, startNgrokTunnelAsync, turnIceServersToServeSimArgs, @@ -21,6 +23,7 @@ jest.mock('node:timers/promises'); jest.mock('../../../utils/turtleFetch'); jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() })); jest.mock('../../../sentry'); +jest.mock('@expo/turtle-spawn'); function createLoggerMock(): bunyan { return { @@ -379,3 +382,137 @@ describe(waitForDeviceRunSessionStoppedAsync, () => { ); }); }); + +describe(ensureFfmpegInstalledAsync, () => { + const spawnMock = jest.mocked(spawn); + + function spawnResolved(): ReturnType { + return Promise.resolve({}) as unknown as ReturnType; + } + + function spawnRejected(): ReturnType { + return Promise.reject(new Error('boom')) as unknown as ReturnType; + } + + beforeEach(() => { + spawnMock.mockReset(); + jest.mocked(Sentry).capture.mockReset(); + }); + + it('does not install when ffmpeg is on PATH', async () => { + spawnMock.mockReturnValueOnce(spawnResolved()); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith('ffmpeg', ['-version'], expect.anything()); + }); + + it('installs ffmpeg with Homebrew on darwin when it is missing', async () => { + spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnResolved()); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenLastCalledWith( + 'brew', + ['install', 'ffmpeg'], + expect.objectContaining({ + env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }), + }) + ); + }); + + it('installs ffmpeg with apt on linux when it is missing', async () => { + spawnMock + .mockReturnValueOnce(spawnRejected()) // ffmpeg -version + .mockReturnValueOnce(spawnResolved()) // apt-get update + .mockReturnValueOnce(spawnResolved()); // apt-get install + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.LINUX, + env: createEnvMock(), + logger: createLoggerMock(), + }); + + expect(spawnMock).toHaveBeenCalledWith( + 'sudo', + ['apt-get', 'update'], + expect.objectContaining({ + env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }), + }) + ); + expect(spawnMock).toHaveBeenLastCalledWith( + 'sudo', + ['apt-get', 'install', '-y', 'ffmpeg'], + expect.objectContaining({ + env: expect.objectContaining({ DEBIAN_FRONTEND: 'noninteractive' }), + }) + ); + }); + + it('still installs on linux when the apt index refresh fails', async () => { + spawnMock + .mockReturnValueOnce(spawnRejected()) // ffmpeg -version + .mockReturnValueOnce(spawnRejected()) // apt-get update + .mockReturnValueOnce(spawnResolved()); // apt-get install + const logger = createLoggerMock(); + + await ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.LINUX, + env: createEnvMock(), + logger, + }); + + expect(spawnMock).toHaveBeenLastCalledWith( + 'sudo', + ['apt-get', 'install', '-y', 'ffmpeg'], + expect.anything() + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('warns and resolves when the install fails, so the session still starts', async () => { + spawnMock.mockReturnValueOnce(spawnRejected()).mockReturnValueOnce(spawnRejected()); + const logger = createLoggerMock(); + + await expect( + ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger, + }) + ).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalled(); + expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); + }); + + // The caller runs this with `void` and the worker installs no unhandledRejection + // handler, so a rejection here would crash the process. `spawn` is not async and + // can throw synchronously, which `asyncResult` cannot catch. + it('resolves when the availability check throws synchronously', async () => { + spawnMock.mockImplementationOnce(() => { + throw new Error('sync spawn failure'); + }); + const logger = createLoggerMock(); + + await expect( + ensureFfmpegInstalledAsync({ + runtimePlatform: BuildRuntimePlatform.DARWIN, + env: createEnvMock(), + logger, + }) + ).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalled(); + expect(jest.mocked(Sentry).capture).toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts index cf444a061c..25c10084b1 100644 --- a/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts +++ b/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts @@ -1,6 +1,7 @@ import { SystemError } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; -import { BuildStepEnv, spawnAsync } from '@expo/steps'; +import { asyncResult } from '@expo/results'; +import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps'; import spawn from '@expo/turtle-spawn'; import * as ngrok from '@ngrok/ngrok'; import { graphql } from 'gql.tada'; @@ -186,6 +187,96 @@ async function sleepUntilAbortedAsync( } } +// Argent encodes screen recordings by piping simulator frames into `ffmpeg`, +// which it resolves from PATH. The tool-server inherits this step's env, so +// spawning ffmpeg resolves against the same PATH argent will search: it rejects +// with ENOENT when the binary is absent, and running it also proves it works. +async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise { + return (await asyncResult(spawn('ffmpeg', ['-version'], { env }))).ok; +} + +async function installFfmpegWithHomebrewAsync({ + env, + logger, +}: { + env: BuildStepEnv; + logger: bunyan; +}): Promise { + await spawn('brew', ['install', 'ffmpeg'], { + env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' }, + logger, + }); +} + +async function installFfmpegWithAptAsync({ + env, + logger, +}: { + env: BuildStepEnv; + logger: bunyan; +}): Promise { + const aptEnv = { ...env, DEBIAN_FRONTEND: 'noninteractive' }; + // The worker's package index can be older than the image it booted from, which + // makes the install 404 on a moved package. Refreshing first avoids that; a + // failed refresh is not fatal because the existing index may still resolve. + await asyncResult(spawn('sudo', ['apt-get', 'update'], { env: aptEnv, logger })); + await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger }); +} + +/** + * Install ffmpeg when the runtime does not already provide it, so argent's + * `screen-recording-start` tool can encode a video. The worker images do not + * ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found + * on PATH" โ€” on macOS (iOS simulators) and Linux (Android emulators) alike. + * + * Best-effort by design: screen recording is one optional argent tool, so a + * failure here is logged and the session continues without it. + * + * The whole body is wrapped because the caller runs this in the background with + * `void`. There is no unhandledRejection handler in the worker, so a rejection + * escaping here would crash the process and take the live session with it. + * `spawn` is not an async function and can throw synchronously, which + * `asyncResult` cannot catch โ€” it only wraps an already-created promise. + */ +export async function ensureFfmpegInstalledAsync({ + runtimePlatform, + env, + logger, +}: { + runtimePlatform: BuildRuntimePlatform; + env: BuildStepEnv; + logger: bunyan; +}): Promise { + try { + if (await isFfmpegAvailableAsync(env)) { + logger.info('ffmpeg is already installed.'); + return; + } + + const isDarwin = runtimePlatform === BuildRuntimePlatform.DARWIN; + logger.info( + `ffmpeg is not installed, installing it with ${ + isDarwin ? 'Homebrew' : 'apt' + } for argent screen recording.` + ); + if (isDarwin) { + await installFfmpegWithHomebrewAsync({ env, logger }); + } else { + await installFfmpegWithAptAsync({ env, logger }); + } + logger.info('Installed ffmpeg.'); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + Sentry.capture('Could not install ffmpeg for argent screen recording', error, { + level: 'warning', + }); + logger.warn( + { err: error }, + 'Could not install ffmpeg. Argent screen recording will not work in this session.' + ); + } +} + const TurnIceServersResponseSchema = z.object({ data: z.object({ iceServers: TurnIceServersSchema,