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 { Device, getContainerPathAsync } from './simctl';
8import * as Log from '../../../log';
9import { CommandError } from '../../../utils/errors';
10import { installExitHooks } from '../../../utils/exit';
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(
100    public device: Pick<Device, 'udid'>,
101    public resolver: ProcessResolver
102  ) {}
103
104  isAttached() {
105    return !!this.childProcess;
106  }
107
108  async resolvePidAsync() {
109    if ('pid' in this.resolver) {
110      return this.resolver.pid;
111    }
112    return getImageNameFromBundleIdentifierAsync(this.device.udid, this.resolver.appId);
113  }
114
115  async attachAsync() {
116    await this.detachAsync();
117
118    const pid = await this.resolvePidAsync();
119
120    if (!pid) {
121      throw new CommandError(`Could not find pid for ${this.device.udid}`);
122    }
123
124    // xcrun simctl spawn booted log stream --process --style json
125    this.childProcess = spawn('xcrun', [
126      'simctl',
127      'spawn',
128      this.device.udid,
129      'log',
130      'stream',
131      '--process',
132      pid,
133      // ndjson provides a better format than json.
134      '--style',
135      'ndjson',
136      // Provide the source so we can filter logs better
137      '--source',
138      // log, activity, trace -- activity was related to layouts, trace didn't work, so that leaves log.
139      // Passing nothing combines all three, but we don't use activity.
140      '--type',
141      'log',
142      // backtrace doesn't seem very useful in basic cases.
143      // TODO: Maybe we can format as a stack trace for native errors.
144      '--no-backtrace',
145    ]);
146
147    this.childProcess.stdout.on('data', (data: Buffer) => {
148      // Sometimes more than one chunk comes at a time, here we split by system newline,
149      // then trim and filter.
150      const strings = data
151        .toString()
152        .split(EOL)
153        .map((value) => value.trim())
154        // This filters out the first log which says something like:
155        // Filtering the log data using "process BEGINSWITH[cd] "my-app" AND type == 1024"
156        .filter((value) => value.startsWith('{'));
157
158      strings.forEach((str) => {
159        const simLog = parseMessageJson(str);
160        if (!simLog) {
161          return;
162        }
163        onMessage(simLog);
164      });
165    });
166
167    this.childProcess.on('error', ({ message }) => {
168      Log.debug('[simctl error]:', message);
169    });
170
171    this.off = installExitHooks(() => {
172      this.detachAsync.bind(this);
173    });
174  }
175
176  private off: (() => void) | null = null;
177
178  detachAsync() {
179    this.off?.();
180    this.off = null;
181    if (this.childProcess) {
182      return new Promise<void>((resolve) => {
183        this.childProcess?.on('close', resolve);
184        this.childProcess?.kill();
185        this.childProcess = null;
186      });
187    }
188    return Promise.resolve();
189  }
190}
191
192function parseMessageJson(data: string) {
193  const stringData = data.toString();
194  try {
195    return JSON.parse(stringData) as SimControlLog;
196  } catch {
197    Log.debug('Failed to parse simctl JSON message:\n' + stringData);
198  }
199  return null;
200}
201
202// There are a lot of networking logs in RN that aren't relevant to the user.
203function isNetworkLog(simLog: SimControlLog): boolean {
204  return (
205    simLog.subsystem === 'com.apple.network' ||
206    simLog.category === 'connection' ||
207    simLog.source?.image === 'CFNetwork'
208  );
209}
210
211function isReactLog(simLog: SimControlLog): boolean {
212  return simLog.subsystem === 'com.facebook.react.log' && simLog.source?.file === 'RCTLog.mm';
213}
214
215// It's not clear what these are but they aren't very useful.
216// (The connection to service on pid 0 named com.apple.commcenter.coretelephony.xpc was invalidated)
217// We can add them later if need.
218function isCoreTelephonyLog(simLog: SimControlLog): boolean {
219  // [CoreTelephony] Updating selectors failed with: Error Domain=NSCocoaErrorDomain Code=4099
220  // "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.}
221  return simLog.subsystem === 'com.apple.CoreTelephony';
222}
223
224// https://stackoverflow.com/a/65313219/4047926
225function isWebKitLog(simLog: SimControlLog): boolean {
226  // [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
227  // 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}
228  return simLog.subsystem === 'com.apple.WebKit';
229}
230
231// Similar to WebKit logs
232function isRunningBoardServicesLog(simLog: SimControlLog): boolean {
233  // [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"
234  // name:"Background" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Target is not running or required target entitlement is missing}>
235  return simLog.subsystem === 'com.apple.runningboard';
236}
237
238function formatMessage(simLog: SimControlLog): string {
239  // TODO: Maybe change "TCC" to "Consent" or "System".
240  const category = chalk.gray(`[${simLog.source?.image ?? simLog.subsystem}]`);
241  const message = simLog.eventMessage;
242  return wrapAnsi(category + ' ' + message, process.stdout.columns || 80);
243}
244
245export function onMessage(simLog: SimControlLog) {
246  let hasLogged = false;
247
248  if (simLog.messageType === 'Error') {
249    if (
250      // Hide all networking errors which are mostly useless.
251      !isNetworkLog(simLog) &&
252      // Showing React errors will result in duplicate messages.
253      !isReactLog(simLog) &&
254      !isCoreTelephonyLog(simLog) &&
255      !isWebKitLog(simLog) &&
256      !isRunningBoardServicesLog(simLog)
257    ) {
258      hasLogged = true;
259      // 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.
260      Log.error(formatMessage(simLog));
261    }
262  } else if (simLog.eventMessage) {
263    // If the source has a file (i.e. not a system log).
264    if (
265      simLog.source?.file ||
266      simLog.eventMessage.includes('Terminating app due to uncaught exception')
267    ) {
268      hasLogged = true;
269      Log.log(formatMessage(simLog));
270    }
271  }
272
273  if (!hasLogged) {
274    Log.debug(formatMessage(simLog));
275  } else {
276    // console.log('DATA:', JSON.stringify(simLog));
277  }
278}
279
280/**
281 *
282 * @param udid
283 * @param bundleIdentifier
284 * @returns Image name like `Exponent` and `null` when the app is not installed on the provided simulator.
285 */
286async function getImageNameFromBundleIdentifierAsync(
287  udid: string,
288  bundleIdentifier: string
289): Promise<string | null> {
290  const containerPath = await getContainerPathAsync({ udid }, { appId: bundleIdentifier });
291
292  if (containerPath) {
293    return getImageNameFromContainerPath(containerPath);
294  }
295  return null;
296}
297
298function getImageNameFromContainerPath(binaryPath: string): string {
299  return path.basename(binaryPath).split('.')[0];
300}
301