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