/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import {DeviceDebugData, DeviceType, timeout} from 'flipper-common'; import {ChildProcess} from 'child_process'; import {IOSBridge} from './IOSBridge'; import {ServerDevice} from '../ServerDevice'; import {FlipperServerImpl} from '../../FlipperServerImpl'; import {iOSCrashWatcher} from './iOSCrashUtils'; import {iOSLogListener} from './iOSLogListener'; import {DebuggableDevice} from '../DebuggableDevice'; import tmp, {DirOptions} from 'tmp'; import {promisify} from 'util'; import path from 'path'; import {readFile} from 'fs/promises'; const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise; export default class IOSDevice extends ServerDevice implements DebuggableDevice { private recording?: {process: ChildProcess; destination: string}; private iOSBridge: IOSBridge; readonly logListener: iOSLogListener; readonly crashWatcher: iOSCrashWatcher; constructor( flipperServer: FlipperServerImpl, iOSBridge: IOSBridge, serial: string, deviceType: DeviceType, title: string, ) { super(flipperServer, { serial, deviceType, title, os: 'iOS', icon: 'mobile', features: { screenCaptureAvailable: true, screenshotAvailable: true, }, }); this.iOSBridge = iOSBridge; this.logListener = new iOSLogListener( () => this.connected, (logEntry) => this.addLogEntry(logEntry), this.iOSBridge, this.serial, this.info.deviceType, ); // It is OK not to await the start of the log listener. // We just spawn it and handle errors internally. this.logListener .start() .catch((e) => console.error('IOSDevice.logListener.start -> unexpected error', e), ); this.crashWatcher = new iOSCrashWatcher(this); // It is OK not to await the start of the crash watcher. // We just spawn it and handle errors internally. this.crashWatcher .start() .catch((e) => console.error('IOSDevice.crashWatcher.start -> unexpected error', e), ); } async screenshot(): Promise { if (!this.connected) { return Buffer.from([]); } return await this.iOSBridge.screenshot(this.serial); } async navigateToLocation(location: string) { return this.iOSBridge.navigate(this.serial, location).catch((err) => { console.warn(`Failed to navigate to location ${location}:`, err); return err; }); } async startScreenCapture(destination: string) { const recording = this.recording; if (recording) { throw new Error( `There is already an active recording at ${recording.destination}`, ); } const process = this.iOSBridge.recordVideo(this.serial, destination); this.recording = {process, destination}; } async stopScreenCapture(): Promise { const recording = this.recording; if (!recording) { throw new Error('No recording in progress'); } const prom = new Promise((resolve, _reject) => { recording.process.on( 'exit', async (_code: number | null, _signal: NodeJS.Signals | null) => { resolve(); }, ); recording.process.kill('SIGINT'); }); const output: string = await timeout( 5000, prom, 'Timed out to stop a screen capture.', ) .then(() => { this.recording = undefined; return recording.destination; }) .catch((e) => { this.recording = undefined; console.warn('Failed to terminate iOS screen recording:', e); throw e; }); return output; } async installApp(ipaPath: string): Promise { return this.iOSBridge.installApp( this.serial, ipaPath, this.flipperServer.config.paths.tempPath, ); } async readFlipperFolderForAllApps(): Promise { console.debug('IOSDevice.readFlipperFolderForAllApps', this.info.serial); const installedApps = await this.iOSBridge.getInstalledApps( this.info.serial, ); const userApps = installedApps.filter( ({installType}) => installType === 'user' || installType === 'user_development', ); console.debug( 'IOSDevice.readFlipperFolderForAllApps -> found apps', this.info.serial, userApps, ); const appsCommandsResults = await Promise.all( userApps.map(async (userApp): Promise => { let sonarDirFileNames: string[]; try { sonarDirFileNames = await this.iOSBridge.ls( this.info.serial, userApp.bundleID, '/Library/Application Support/sonar', ); } catch (e) { console.debug( 'IOSDevice.readFlipperFolderForAllApps -> ignoring app as it does not have sonar dir', this.info.serial, userApp.bundleID, ); return; } const dir = await tmpDir({unsafeCleanup: true}); const sonarDirContent = await Promise.all( sonarDirFileNames.map(async (fileName) => { const filePath = `/Library/Application Support/sonar/${fileName}`; if (fileName.endsWith('pem')) { return { path: filePath, data: '===SECURE_CONTENT===', }; } try { // See iOSCertificateProvider to learn why we need 2 pulls try { await this.iOSBridge.pull( this.info.serial, filePath, userApp.bundleID, dir, ); } catch (e) { console.debug( 'IOSDevice.readFlipperFolderForAllApps -> Original idb pull failed. Most likely it is a physical device that requires us to handle the dest path dirrently. Forcing a re-try with the updated dest path. See D32106952 for details. Original error:', this.info.serial, userApp.bundleID, fileName, filePath, e, ); await this.iOSBridge.pull( this.info.serial, filePath, userApp.bundleID, path.join(dir, fileName), ); console.debug( 'IOSDevice.readFlipperFolderForAllApps -> Subsequent idb pull succeeded. Nevermind previous warnings.', this.info.serial, userApp.bundleID, fileName, filePath, ); } return { path: filePath, data: await readFile(path.join(dir, fileName), { encoding: 'utf-8', }), }; } catch (e) { return { path: filePath, data: `Couldn't pull the file: ${e}`, }; } }), ); return { serial: this.info.serial, appId: userApp.bundleID, data: [ { command: 'iOSBridge.ls /Library/Application Support/sonar', result: sonarDirFileNames.join('\n'), }, ...sonarDirContent, ], }; }), ); return ( appsCommandsResults // Filter out apps without Flipper integration .filter((res): res is DeviceDebugData => !!res) ); } disconnect() { if (this.recording) { this.stopScreenCapture(); } super.disconnect(); } }