mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../../simulator/serveSimHost';
|
||||
import SimulatorSimctl, { parseSimctlArgv } from '../simctl';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/serveSimHost', () => ({
|
||||
...jest.requireActual('../../../simulator/serveSimHost'),
|
||||
getServeSimDeviceUdidAsync: jest.fn(),
|
||||
runServeSimHostActionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
const SPRINGBOARD_RESTART = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockGetServeSimDeviceUdidAsync = jest.mocked(getServeSimDeviceUdidAsync);
|
||||
const mockRunServeSimHostActionAsync = jest.mocked(runServeSimHostActionAsync);
|
||||
|
||||
function makeSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.Argent,
|
||||
platform: AppPlatform.Ios,
|
||||
remoteConfig: {
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: 'preview-token',
|
||||
},
|
||||
...overrides,
|
||||
} as DeviceRunSessionById;
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(parseSimctlArgv, () => {
|
||||
it('reads --id before the simctl arguments', () => {
|
||||
expect(parseSimctlArgv(['--id', 'session-1', 'ui', 'appearance', 'dark'])).toEqual({
|
||||
id: 'session-1',
|
||||
args: ['ui', 'appearance', 'dark'],
|
||||
});
|
||||
expect(parseSimctlArgv(['--id=session-1', ...SPRINGBOARD_RESTART])).toEqual({
|
||||
id: 'session-1',
|
||||
args: SPRINGBOARD_RESTART,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes simctl flags through untouched', () => {
|
||||
expect(parseSimctlArgv(SPRINGBOARD_RESTART)).toEqual({ args: SPRINGBOARD_RESTART });
|
||||
expect(parseSimctlArgv(['--', '--id', 'x'])).toEqual({ args: ['--id', 'x'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe(SimulatorSimctl, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
jest.mocked(loadSimulatorEnvAsync).mockResolvedValue();
|
||||
mockByIdAsync.mockResolvedValue(makeSession());
|
||||
mockGetServeSimDeviceUdidAsync.mockResolvedValue(UDID);
|
||||
mockRunServeSimHostActionAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorSimctl {
|
||||
const command = new SimulatorSimctl(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('restarts SpringBoard on the session simulator', async () => {
|
||||
await createCommand(['--id', 'session-123', ...SPRINGBOARD_RESTART]).runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
{ apiUrl: 'https://preview.example.com', token: 'preview-token' },
|
||||
'springboard.restart',
|
||||
{ udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the appearance on the session from the env file', async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-123';
|
||||
|
||||
await createCommand(['ui', 'appearance', 'dark']).runAsync();
|
||||
|
||||
expect(mockRunServeSimHostActionAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'appearance.set',
|
||||
{ value: 'dark', udid: UDID }
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unsupported command before fetching the session', async () => {
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'spawn', 'sh', '-c', 'id']).runAsync()
|
||||
).rejects.toThrow('Unsupported simctl command: spawn sh -c id');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an Android session', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ platform: AppPlatform.Android }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('only supports iOS');
|
||||
expect(mockRunServeSimHostActionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a session that is not running', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
await expect(
|
||||
createCommand(['--id', 'session-123', 'ui', 'appearance', 'dark']).runAsync()
|
||||
).rejects.toThrow('is stopped, not running');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { AppPlatform, DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../../simulator/serveSimHost';
|
||||
import { SUPPORTED_SIMCTL_COMMANDS, resolveSimctlHostAction } from '../../simulator/simctl';
|
||||
|
||||
export function parseSimctlArgv(rawArgv: string[]): { id?: string; args: string[] } {
|
||||
let id: string | undefined;
|
||||
let index = 0;
|
||||
while (index < rawArgv.length) {
|
||||
const arg = rawArgv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (arg === '--id') {
|
||||
id = rawArgv[index + 1];
|
||||
if (!id) {
|
||||
throw new Error('--id needs a simulator session ID.');
|
||||
}
|
||||
index += 2;
|
||||
} else if (arg.startsWith('--id=')) {
|
||||
id = arg.slice('--id='.length);
|
||||
index += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { id, args: rawArgv.slice(index) };
|
||||
}
|
||||
|
||||
export default class SimulatorSimctl extends EasCommand {
|
||||
static override aliases = ['sim:simctl'];
|
||||
static override description = `[EXPERIMENTAL] run a supported simctl command on a remote iOS simulator session, without the device argument. Supported: ${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => `\`${command}\``
|
||||
).join(', ')}`;
|
||||
static override strict = false;
|
||||
static override usage = ['simulator:simctl [--id <value>] <simctl arguments...>'];
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const rawArgv = [...this.argv];
|
||||
await this.parse(SimulatorSimctl, []);
|
||||
const { id: flagId, args } = parseSimctlArgv(rawArgv);
|
||||
|
||||
const hostAction = resolveSimctlHostAction(args);
|
||||
if (!hostAction) {
|
||||
throw new Error(
|
||||
`Unsupported simctl command${args.length > 0 ? `: ${args.join(' ')}` : ''}. Supported commands, without the device argument:\n${SUPPORTED_SIMCTL_COMMANDS.map(
|
||||
command => ` ${command}`
|
||||
).join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorSimctl, { nonInteractive: true });
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const sessionId = flagId || process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!sessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, sessionId);
|
||||
if (session.platform !== AppPlatform.Ios) {
|
||||
throw new Error(`simulator:simctl only supports iOS simulator sessions.`);
|
||||
}
|
||||
if (session.status !== DeviceRunSessionStatus.InProgress) {
|
||||
throw new Error(
|
||||
`Simulator session ${session.id} is ${session.status.toLowerCase()}, not running.`
|
||||
);
|
||||
}
|
||||
const host = session.remoteConfig ? getServeSimHost(session.remoteConfig) : null;
|
||||
if (!host) {
|
||||
throw new Error(`Simulator session ${session.id} has no simulator host to run simctl on.`);
|
||||
}
|
||||
|
||||
const udid = await getServeSimDeviceUdidAsync(host);
|
||||
const { stdout, stderr } = await runServeSimHostActionAsync(host, hostAction.action, {
|
||||
...hostAction.params,
|
||||
udid,
|
||||
});
|
||||
if (stdout) {
|
||||
Log.log(stdout.trimEnd());
|
||||
}
|
||||
if (stderr) {
|
||||
Log.warn(stderr.trimEnd());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AddressInfo } from 'net';
|
||||
import nock from 'nock';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import {
|
||||
UnsupportedServeSimHostActionError,
|
||||
getServeSimDeviceUdidAsync,
|
||||
getServeSimHost,
|
||||
runServeSimHostActionAsync,
|
||||
} from '../serveSimHost';
|
||||
import { DeviceRunSessionRemoteConfig } from '../utils';
|
||||
|
||||
const TOKEN = 'preview-token';
|
||||
const UDID = '5B3A2F1E-0C4D-4E8F-9A7B-1C2D3E4F5A6B';
|
||||
|
||||
describe(getServeSimHost, () => {
|
||||
it('reads the web preview host of a controller session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ArgentRunSessionRemoteConfig',
|
||||
toolsUrl: 'https://argent.example.com',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('reads the preview host of a serve-sim session', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig',
|
||||
previewApiUrl: 'https://preview.example.com',
|
||||
previewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toEqual({ apiUrl: 'https://preview.example.com', token: TOKEN });
|
||||
});
|
||||
|
||||
it('returns null before the session reports its preview host', () => {
|
||||
expect(
|
||||
getServeSimHost({
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
previewApiUrl: null,
|
||||
webPreviewToken: TOKEN,
|
||||
} as DeviceRunSessionRemoteConfig)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getServeSimDeviceUdidAsync, () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('reads the device from the preview state with the session token', async () => {
|
||||
nock('https://preview.example.com', {
|
||||
reqheaders: { authorization: `Bearer ${TOKEN}` },
|
||||
})
|
||||
.get('/api')
|
||||
.reply(200, { device: UDID, port: 3200 });
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).resolves.toBe(UDID);
|
||||
});
|
||||
|
||||
it('fails when no simulator is running', async () => {
|
||||
nock('https://preview.example.com').get('/api').reply(200, 'null');
|
||||
|
||||
await expect(
|
||||
getServeSimDeviceUdidAsync({ apiUrl: 'https://preview.example.com', token: TOKEN })
|
||||
).rejects.toThrow('no running simulator');
|
||||
});
|
||||
});
|
||||
|
||||
describe(runServeSimHostActionAsync, () => {
|
||||
let server: WebSocketServer;
|
||||
let apiUrl: string;
|
||||
let reply: (request: { id: number; action: string; params: unknown }) => unknown;
|
||||
let protocols: string[];
|
||||
let requests: unknown[];
|
||||
|
||||
beforeEach(async () => {
|
||||
protocols = [];
|
||||
requests = [];
|
||||
server = new WebSocketServer({
|
||||
port: 0,
|
||||
path: '/exec-ws',
|
||||
handleProtocols: offered => {
|
||||
protocols = [...offered];
|
||||
return offered.values().next().value ?? false;
|
||||
},
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
socket.send(JSON.stringify({ ready: true }));
|
||||
socket.on('message', data => {
|
||||
const request = JSON.parse(data.toString());
|
||||
requests.push(request);
|
||||
socket.send(JSON.stringify(reply(request)));
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
server.once('listening', resolve);
|
||||
});
|
||||
apiUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('runs one action over the token-authenticated exec channel', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: '', exitCode: 0 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).resolves.toEqual({ stdout: '', stderr: '' });
|
||||
expect(protocols).toEqual([`serve-sim.token.${TOKEN}`]);
|
||||
expect(requests).toEqual([
|
||||
{ id: 1, action: 'appearance.set', params: { udid: UDID, value: 'dark' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a rejected session token', async () => {
|
||||
server.removeAllListeners('connection');
|
||||
server.on('connection', socket => {
|
||||
socket.close();
|
||||
});
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('rejected the session token');
|
||||
});
|
||||
|
||||
it('reports an action the session does not have yet', async () => {
|
||||
reply = ({ id, action }) => ({ id, error: `unknown action ${action}` });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'springboard.restart', { udid: UDID })
|
||||
).rejects.toThrow(UnsupportedServeSimHostActionError);
|
||||
});
|
||||
|
||||
it('reports a failed command with its stderr', async () => {
|
||||
reply = ({ id }) => ({ id, stdout: '', stderr: 'Invalid device\n', exitCode: 148 });
|
||||
|
||||
await expect(
|
||||
runServeSimHostActionAsync({ apiUrl, token: TOKEN }, 'appearance.set', {
|
||||
udid: UDID,
|
||||
value: 'dark',
|
||||
})
|
||||
).rejects.toThrow('appearance.set failed: Invalid device');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { resolveSimctlHostAction } from '../simctl';
|
||||
|
||||
describe(resolveSimctlHostAction, () => {
|
||||
it('maps the appearance command', () => {
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'dark'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'dark' },
|
||||
});
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'light'])).toEqual({
|
||||
action: 'appearance.set',
|
||||
params: { value: 'light' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps the SpringBoard restart', () => {
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
])
|
||||
).toEqual({ action: 'springboard.restart', params: {} });
|
||||
});
|
||||
|
||||
it('rejects every other command', () => {
|
||||
expect(resolveSimctlHostAction([])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['ui', 'appearance', 'sepia'])).toBeNull();
|
||||
expect(resolveSimctlHostAction(['spawn', 'launchctl', 'list'])).toBeNull();
|
||||
expect(
|
||||
resolveSimctlHostAction([
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.backboardd',
|
||||
])
|
||||
).toBeNull();
|
||||
expect(resolveSimctlHostAction(['openurl', 'myapp://'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import fetch, { httpsProxyAgent } from '../fetch';
|
||||
import { DeviceRunSessionRemoteConfig } from './utils';
|
||||
|
||||
const SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX = 'serve-sim.token.';
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type ServeSimHost = {
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type ServeSimHostActionResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export class UnsupportedServeSimHostActionError extends Error {}
|
||||
|
||||
export function getServeSimHost(remoteConfig: DeviceRunSessionRemoteConfig): ServeSimHost | null {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.previewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.previewToken }
|
||||
: null;
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
case 'AppiumRunSessionRemoteConfig':
|
||||
return remoteConfig.previewApiUrl && remoteConfig.webPreviewToken
|
||||
? { apiUrl: remoteConfig.previewApiUrl, token: remoteConfig.webPreviewToken }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServeSimDeviceUdidAsync(host: ServeSimHost): Promise<string> {
|
||||
const response = await fetch(resolveServeSimUrl(host.apiUrl, 'api').toString(), {
|
||||
headers: { Authorization: `Bearer ${host.token}` },
|
||||
});
|
||||
const state = (await response.json()) as { device?: unknown } | null;
|
||||
if (typeof state?.device !== 'string' || state.device.length === 0) {
|
||||
throw new Error('The simulator session has no running simulator.');
|
||||
}
|
||||
return state.device;
|
||||
}
|
||||
|
||||
export async function runServeSimHostActionAsync(
|
||||
host: ServeSimHost,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
{ timeoutMs = DEFAULT_ACTION_TIMEOUT_MS }: { timeoutMs?: number } = {}
|
||||
): Promise<ServeSimHostActionResult> {
|
||||
const url = resolveServeSimUrl(host.apiUrl, 'exec-ws');
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return await new Promise<ServeSimHostActionResult>((resolve, reject) => {
|
||||
const socket = new WebSocket(
|
||||
url.toString(),
|
||||
[SERVE_SIM_TOKEN_SUBPROTOCOL_PREFIX + host.token],
|
||||
{
|
||||
...(httpsProxyAgent ? { agent: httpsProxyAgent } : {}),
|
||||
}
|
||||
);
|
||||
let settled = false;
|
||||
let sent = false;
|
||||
const finish = (error: Error | null, result?: ServeSimHostActionResult): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.terminate();
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(result!);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Timed out waiting for the simulator host to run ${action}.`));
|
||||
}, timeoutMs);
|
||||
socket.on('error', error => {
|
||||
finish(new Error(`Could not connect to the simulator host: ${error.message}`));
|
||||
});
|
||||
socket.on('close', () => {
|
||||
finish(
|
||||
new Error(
|
||||
sent
|
||||
? `The simulator host closed the connection before ${action} finished.`
|
||||
: 'The simulator host rejected the session token.'
|
||||
)
|
||||
);
|
||||
});
|
||||
socket.on('message', data => {
|
||||
let reply: any;
|
||||
try {
|
||||
reply = JSON.parse(data.toString());
|
||||
} catch {
|
||||
finish(new Error('The simulator host sent an invalid response.'));
|
||||
return;
|
||||
}
|
||||
if (reply?.ready === true && !sent) {
|
||||
sent = true;
|
||||
socket.send(JSON.stringify({ id: 1, action, params }));
|
||||
return;
|
||||
}
|
||||
if (reply?.id !== 1 || !sent) {
|
||||
return;
|
||||
}
|
||||
if (typeof reply.error === 'string') {
|
||||
finish(
|
||||
reply.error === `unknown action ${action}`
|
||||
? new UnsupportedServeSimHostActionError(
|
||||
`This simulator session does not support ${action} yet. Start a new session to use it.`
|
||||
)
|
||||
: new Error(`${action} failed: ${reply.error}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const stdout = typeof reply.stdout === 'string' ? reply.stdout : '';
|
||||
const stderr = typeof reply.stderr === 'string' ? reply.stderr : '';
|
||||
if (reply.exitCode !== 0) {
|
||||
finish(new Error(`${action} failed: ${stderr.trim() || `exit code ${reply.exitCode}`}`));
|
||||
return;
|
||||
}
|
||||
finish(null, { stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveServeSimUrl(apiUrl: string, route: string): URL {
|
||||
return new URL(route, apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type SimctlHostAction = {
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SPRINGBOARD_RESTART_ARGS = [
|
||||
'spawn',
|
||||
'launchctl',
|
||||
'kickstart',
|
||||
'-k',
|
||||
'system/com.apple.SpringBoard',
|
||||
];
|
||||
|
||||
export const SUPPORTED_SIMCTL_COMMANDS = [
|
||||
'ui appearance <light|dark>',
|
||||
SPRINGBOARD_RESTART_ARGS.join(' '),
|
||||
];
|
||||
|
||||
export function resolveSimctlHostAction(args: string[]): SimctlHostAction | null {
|
||||
if (args.length === 3 && args[0] === 'ui' && args[1] === 'appearance') {
|
||||
const value = args[2];
|
||||
if (value === 'light' || value === 'dark') {
|
||||
return { action: 'appearance.set', params: { value } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
args.length === SPRINGBOARD_RESTART_ARGS.length &&
|
||||
args.every((arg, index) => arg === SPRINGBOARD_RESTART_ARGS[index])
|
||||
) {
|
||||
return { action: 'springboard.restart', params: {} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user