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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [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

## [21.4.0](https://github.com/expo/eas-cli/releases/tag/v21.4.0) - 2026-07-28
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably good for now but I wish we had easier time:

  • logging stuff in phases at will
  • run steps in parallel

Then we wouldn't mix so much in this one function and one log group…

Going to wait for hooks and custom fns to land and see if adding parallel: true would be so hard


logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';

import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
ensureFfmpegInstalledAsync,
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
waitForDeviceRunSessionStoppedAsync,
Expand All @@ -14,6 +17,7 @@ import {
jest.mock('node:timers/promises');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
jest.mock('@expo/turtle-spawn');

function createLoggerMock(): bunyan {
return {
Expand Down Expand Up @@ -279,3 +283,135 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
);
});
});

describe(ensureFfmpegInstalledAsync, () => {
const spawnMock = jest.mocked(spawn);
let existsSyncSpy: jest.SpyInstance;

function spawnResolved(): ReturnType<typeof spawn> {
return Promise.resolve({}) as unknown as ReturnType<typeof spawn>;
}

function spawnRejected(): ReturnType<typeof spawn> {
return Promise.reject(new Error('boom')) as unknown as ReturnType<typeof spawn>;
}

beforeEach(() => {
spawnMock.mockReset();
jest.mocked(Sentry).capture.mockReset();
// Default to "no ffmpeg on any fallback path" so each test opts in.
existsSyncSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(false);
});

afterEach(() => {
existsSyncSpy.mockRestore();
});

it('does not install when ffmpeg is already on a fallback path', async () => {
existsSyncSpy.mockImplementation(path => path === '/opt/homebrew/bin/ffmpeg');

await ensureFfmpegInstalledAsync({
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: createEnvMock(),
logger: createLoggerMock(),
});

expect(spawnMock).not.toHaveBeenCalled();
});

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('sh', ['-c', 'command -v ffmpeg'], 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()) // command -v ffmpeg
.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()) // command -v ffmpeg
.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();
});
});
93 changes: 92 additions & 1 deletion packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -179,6 +180,96 @@ async function sleepUntilAbortedAsync(
}
}

// Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
// which it resolves from PATH and then from these prefixes. Keep the list in
// sync with argent so we never reinstall a binary it can already find.
const FFMPEG_FALLBACK_PATHS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason to do it like this. I think our list should only check our expected places for ffmpeg and our expected list is just $PATH?

Does argent really not adhere to $PATH?

'/opt/homebrew/bin/ffmpeg',
'/usr/local/bin/ffmpeg',
'/usr/bin/ffmpeg',
];

async function isFfmpegAvailableAsync(env: BuildStepEnv): Promise<boolean> {
if (FFMPEG_FALLBACK_PATHS.some(ffmpegPath => fs.existsSync(ffmpegPath))) {
return true;
}
return (await asyncResult(spawn('sh', ['-c', 'command -v ffmpeg'], { env }))).ok;
}

async function installFfmpegWithHomebrewAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
await spawn('brew', ['install', 'ffmpeg'], {
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
logger,
});
}

async function installFfmpegWithAptAsync({
env,
logger,
}: {
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
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. Never rejects,
* which is what lets the caller run it in the background.
*/
export async function ensureFfmpegInstalledAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<void> {
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.`
);
const installResult = await asyncResult(
isDarwin
? installFfmpegWithHomebrewAsync({ env, logger })
: installFfmpegWithAptAsync({ env, logger })
);
if (!installResult.ok) {
Sentry.capture('Could not install ffmpeg for argent screen recording', installResult.reason);
logger.warn(
{ err: installResult.reason },
'Could not install ffmpeg. Argent screen recording will not work in this session.'
);
return;
}
logger.info('Installed ffmpeg.');
}

const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
Expand Down
Loading