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