1import chalk from 'chalk';
2import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
3import { EOL } from 'os';
4import path from 'path';
5import wrapAnsi from 'wrap-ansi';
6
7import * as Log from '../../../log';
8import { CommandError } from '../../../utils/errors';
9import { installExitHooks } from '../../../utils/exit';
10import { Device, getContainerPathAsync } from './simctl';
11
12export type SimControlLog = {
13  /**
14   * 258753568922927108
15   */
16  traceID: number;
17  /**
18   *
19   * "Connection 1: done",
20   */
21  eventMessage: string;
22  /**
23   * "logEvent" | "activityCreateEvent",
24   */
25  eventType: 'logEvent' | 'activityCreateEvent';
26  source: null | {
27    /**
28     * 'RCTDefaultLogFunction_block_invoke' | '__TCC_CRASHING_DUE_TO_PRIVACY_VIOLATION__'
29     */
30    symbol: string;
31    line: number;
32    /**
33     * 'TCC' | 'Security' | 'CFNetwork' | 'libnetwork.dylib' | 'myapp'
34     *
35     * TCC is apple sys, it means "Transparency, Consent, and Control"
36     */
37    image: string;
38    /**
39     * 'RCTLog.mm' | ''
40     */
41    file: string;
42  };
43  /**
44   * "Connection %llu: done"
45   */
46  formatString: string;
47  /**
48   * 0
49   */
50  activityIdentifier: number;
51  subsystem:
52    | ''
53    | 'com.apple.network'
54    | 'com.facebook.react.log'
55    | 'com.apple.TCC'
56    | 'com.apple.CoreTelephony'
57    | 'com.apple.WebKit'
58    | 'com.apple.runningboard'
59    | string;
60  category: '' | 'access' | 'connection' | 'plugin';
61  /**
62   * "2021-03-15 15:36:28.004331-0700"
63   */
64  timestamp: string;
65  /**
66   * 706567072091713
67   */
68  machTimestamp: number;
69  /**
70   * "Default"
71   */
72  messageType: 'Default' | 'Error';
73  /**
74   * 15192
75   */
76  processID: number;
77};
78
79type ProcessResolver =
80  | {
81      pid: string;
82    }
83  | {
84      appId: string;
85    };
86
87export class SimulatorLogStreamer {
88  private childProcess: ChildProcessWithoutNullStreams | null = null;
89
90  static cache: SimulatorLogStreamer[] = [];
91
92  static getStreamer = (device: Pick<Device, 'udid'>, resolver: ProcessResolver) => {
93    return (
94      SimulatorLogStreamer.cache.find((streamer) => streamer.device.udid === device.udid) ??
95      new SimulatorLogStreamer(device, resolver)
96    );
97  };
98
99  constructor(public device: Pick<Device, 'udid'>, public resolver: ProcessResolver) {}
100
101  isAttached() {
102    return !!this.childProcess;
103  }
104
105  async resolvePidAsync() {
106    if ('pid' in this.resolver) {
107      return this.resolver.pid;
108    }
109    return getImageNameFromBundleIdentifierAsync(this.device.udid, this.resolver.appId);
110  }
111
112  async attachAsync() {
113    await this.detachAsync();
114
115    const pid = await this.resolvePidAsync();
116
117    if (!pid) {
118      throw new CommandError(`Could not find pid for ${this.device.udid}`);
119    }
120
121    // xcrun simctl spawn booted log stream --process --style json
122    this.childProcess = spawn('xcrun', [
123      'simctl',
124      'spawn',
125      this.device.udid,
126      'log',
127      'stream',
128      '--process',
129      pid,
130      // ndjson provides a better format than json.
131      '--style',
132      'ndjson',
133      // Provide the source so we can filter logs better
134      '--source',
135      // log, activity, trace -- activity was related to layouts, trace didn't work, so that leaves log.
136      // Passing nothing combines all three, but we don't use activity.
137      '--type',
138      'log',
139      // backtrace doesn't seem very useful in basic cases.
140      // TODO: Maybe we can format as a stack trace for native errors.
141      '--no-backtrace',
142    ]);
143
144    this.childProcess.stdout.on('data', (data: Buffer) => {
145      // Sometimes more than one chunk comes at a time, here we split by system newline,
146      // then trim and filter.
147      const strings = data
148        .toString()
149        .split(EOL)
150        .map((value) => value.trim())
151        // This filters out the first log which says something like:
152        // Filtering the log data using "process BEGINSWITH[cd] "my-app" AND type == 1024"
153        .filter((value) => value.startsWith('{'));
154
155      strings.forEach((str) => {
156        const simLog = parseMessageJson(str);
157        if (!simLog) {
158          return;
159        }
160        onMessage(simLog);
161      });
162    });
163
164    this.childProcess.on('error', ({ message }) => {
165      Log.debug('[simctl error]:', message);
166    });
167
168    this.off = installExitHooks(() => {
169      this.detachAsync.bind(this);
170    });
171  }
172
173  private off: (() => void) | null = null;
174
175  detachAsync() {
176    this.off?.();
177    this.off = null;
178    if (this.childProcess) {
179      return new Promise<void>((resolve) => {
180        this.childProcess?.on('close', resolve);
181        this.childProcess?.kill();
182        this.childProcess = null;
183      });
184    }
185    return Promise.resolve();
186  }
187}
188
189function parseMessageJson(data: string) {
190  const stringData = data.toString();
191  try {
192    return JSON.parse(stringData) as SimControlLog;
193  } catch {
194    Log.debug('Failed to parse simctl JSON message:\n' + stringData);
195  }
196  return null;
197}
198
199// There are a lot of networking logs in RN that aren't relevant to the user.
200function isNetworkLog(simLog: SimControlLog): boolean {
201  return (
202    simLog.subsystem === 'com.apple.network' ||
203    simLog.category === 'connection' ||
204    simLog.source?.image === 'CFNetwork'
205  );
206}
207
208function isReactLog(simLog: SimControlLog): boolean {
209  return simLog.subsystem === 'com.facebook.react.log' && simLog.source?.file === 'RCTLog.mm';
210}
211
212// It's not clear what these are but they aren't very useful.
213// (The connection to service on pid 0 named com.apple.commcenter.coretelephony.xpc was invalidated)
214// We can add them later if need.
215function isCoreTelephonyLog(simLog: SimControlLog): boolean {
216  // [CoreTelephony] Updating selectors failed with: Error Domain=NSCocoaErrorDomain Code=4099
217  // "The connection to service on pid 0 named com.apple.commcenter.coretelephony.xpc was invalidated." UserInfo={NSDebugDescription=The connection to service on pid 0 named com.apple.commcenter.coretelephony.xpc was invalidated.}
218  return simLog.subsystem === 'com.apple.CoreTelephony';
219}
220
221// https://stackoverflow.com/a/65313219/4047926
222function isWebKitLog(simLog: SimControlLog): boolean {
223  // [WebKit] 0x1143ca500 - ProcessAssertion: Failed to acquire RBS Background assertion 'WebProcess Background Assertion' for process with PID 27084, error: Error Domain=RBSAssertionErrorDomain Code=3 "Target is not running or required target
224  // entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit" name:"Background" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Target is not running or required target entitlement is missing}
225  return simLog.subsystem === 'com.apple.WebKit';
226}
227
228// Similar to WebKit logs
229function isRunningBoardServicesLog(simLog: SimControlLog): boolean {
230  // [RunningBoardServices] Error acquiring assertion: <Error Domain=RBSAssertionErrorDomain Code=3 "Target is not running or required target entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit"
231  // name:"Background" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Target is not running or required target entitlement is missing}>
232  return simLog.subsystem === 'com.apple.runningboard';
233}
234
235function formatMessage(simLog: SimControlLog): string {
236  // TODO: Maybe change "TCC" to "Consent" or "System".
237  const category = chalk.gray(`[${simLog.source?.image ?? simLog.subsystem}]`);
238  const message = simLog.eventMessage;
239  return wrapAnsi(category + ' ' + message, process.stdout.columns || 80);
240}
241
242export function onMessage(simLog: SimControlLog) {
243  let hasLogged = false;
244
245  if (simLog.messageType === 'Error') {
246    if (
247      // Hide all networking errors which are mostly useless.
248      !isNetworkLog(simLog) &&
249      // Showing React errors will result in duplicate messages.
250      !isReactLog(simLog) &&
251      !isCoreTelephonyLog(simLog) &&
252      !isWebKitLog(simLog) &&
253      !isRunningBoardServicesLog(simLog)
254    ) {
255      hasLogged = true;
256      // Sim: This app has crashed because it attempted to access privacy-sensitive data without a usage description.  The app's Info.plist must contain an NSCameraUsageDescription key with a string value explaining to the user how the app uses this data.
257      Log.error(formatMessage(simLog));
258    }
259  } else if (simLog.eventMessage) {
260    // If the source has a file (i.e. not a system log).
261    if (
262      simLog.source?.file ||
263      simLog.eventMessage.includes('Terminating app due to uncaught exception')
264    ) {
265      hasLogged = true;
266      Log.log(formatMessage(simLog));
267    }
268  }
269
270  if (!hasLogged) {
271    Log.debug(formatMessage(simLog));
272  } else {
273    // console.log('DATA:', JSON.stringify(simLog));
274  }
275}
276
277/**
278 *
279 * @param udid
280 * @param bundleIdentifier
281 * @returns Image name like `Exponent` and `null` when the app is not installed on the provided simulator.
282 */
283async function getImageNameFromBundleIdentifierAsync(
284  udid: string,
285  bundleIdentifier: string
286): Promise<string | null> {
287  const containerPath = await getContainerPathAsync({ udid }, { appId: bundleIdentifier });
288
289  if (containerPath) {
290    return getImageNameFromContainerPath(containerPath);
291  }
292  return null;
293}
294
295function getImageNameFromContainerPath(binaryPath: string): string {
296  return path.basename(binaryPath).split('.')[0];
297}
298