From 821bf2b5b759cbd2c0efb9fcc43b5c213d70b98b Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Tue, 25 Oct 2022 05:31:48 -0700 Subject: [PATCH] Get files from Flipper folder for every app on an iOS device Summary: Design doc: https://docs.google.com/document/d/1HLCFl46RfqG0o1mSt8SWrwf_HMfOCRg_oENioc1rkvQ/edit# Fetch list of files in the `/sonar` folder on iOS devices and fetch all the files Reviewed By: passy Differential Revision: D40548410 fbshipit-source-id: d38cbbb1e3b5579c13f30777233e3caf7b8c9b34 --- .../src/FlipperServerImpl.tsx | 6 +- .../src/devices/ios/IOSBridge.tsx | 108 +++++++++++++++- .../src/devices/ios/IOSDevice.tsx | 119 +++++++++++++++++- 3 files changed, 228 insertions(+), 5 deletions(-) diff --git a/desktop/flipper-server-core/src/FlipperServerImpl.tsx b/desktop/flipper-server-core/src/FlipperServerImpl.tsx index 468c7ffbd..9679c26b5 100644 --- a/desktop/flipper-server-core/src/FlipperServerImpl.tsx +++ b/desktop/flipper-server-core/src/FlipperServerImpl.tsx @@ -605,7 +605,11 @@ export class FlipperServerImpl implements FlipperServer { private async fetchDebugLogs() { const debugDataForEachDevice = await Promise.all( [...this.devices.values()] - .filter((device) => device.info.os === 'Android') + .filter( + (device) => + device.connected && + (device.info.os === 'Android' || device.info.os === 'iOS'), + ) .map((device) => (device as unknown as DebuggableDevice).readFlipperFolderForAllApps(), ), diff --git a/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx b/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx index a8269247c..5d1caef0c 100644 --- a/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx +++ b/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx @@ -14,8 +14,9 @@ import child_process from 'child_process'; import type {IOSDeviceParams} from 'flipper-common'; import {DeviceType, uuid} from 'flipper-common'; import path from 'path'; -import {exec, execFile} from 'promisify-child-process'; +import {ChildProcessPromise, exec, execFile} from 'promisify-child-process'; import {getFlipperServerConfig} from '../../FlipperServerConfig'; +import iOSContainerUtility from './iOSContainerUtility'; export const ERR_NO_IDB_OR_XCODE_AVAILABLE = 'Neither Xcode nor idb available. Cannot provide iOS device functionality.'; @@ -31,6 +32,16 @@ type iOSSimulatorDevice = { udid: string; }; +// https://fbidb.io/docs/commands#list-apps +interface IOSInstalledAppDescriptor { + bundleID: string; + name: string; + installType: 'user' | 'user_development' | 'system'; + architectures: string[]; + runningStatus: 'Unknown' | 'Running'; + debuggableStatus: boolean; +} + export interface IOSBridge { startLogListener: ( udid: string, @@ -48,6 +59,14 @@ export interface IOSBridge { ipaPath: string, tempPath: string, ) => Promise; + getInstalledApps: (serial: string) => Promise; + ls: (serial: string, appBundleId: string, path: string) => Promise; + pull: ( + serial: string, + src: string, + bundleId: string, + dst: string, + ) => Promise; } export class IDBBridge implements IOSBridge { @@ -56,6 +75,66 @@ export class IDBBridge implements IOSBridge { private enablePhysicalDevices: boolean, ) {} + async getInstalledApps(serial: string): Promise { + const {stdout} = await this._execIdb(`list-apps --udid ${serial}`); + if (typeof stdout !== 'string') { + throw new Error( + `IDBBridge.getInstalledApps -> returned ${typeof stdout}, not a string`, + ); + } + // Skip last item, as the last line also has \n at the end + const appStrings = stdout.split('\n').slice(0, -1); + const appDescriptors = appStrings.map( + (appString): IOSInstalledAppDescriptor => { + const [ + bundleID, + name, + installType, + architecturesString, + runningStatus, + debuggableStatusString, + ] = appString.split(' | '); + return { + bundleID, + name, + installType: installType as IOSInstalledAppDescriptor['installType'], + architectures: architecturesString.split(', '), + runningStatus: + runningStatus as IOSInstalledAppDescriptor['runningStatus'], + debuggableStatus: debuggableStatusString !== 'Not Debuggable', + }; + }, + ); + return appDescriptors; + } + + async ls( + serial: string, + appBundleId: string, + path: string, + ): Promise { + const {stdout} = await this._execIdb( + `file ls --udid ${serial} --log ERROR --bundle-id ${appBundleId} '${path}'`, + ); + if (typeof stdout !== 'string') { + throw new Error( + `IDBBridge.ls -> returned ${typeof stdout}, not a string`, + ); + } + // Skip last item, as the last line also has \n at the end + const pathContent = stdout.split('\n').slice(0, -1); + return pathContent; + } + + pull( + serial: string, + src: string, + bundleId: string, + dst: string, + ): Promise { + return iOSContainerUtility.pull(serial, src, bundleId, dst, this.idbPath); + } + async installApp(serial: string, ipaPath: string): Promise { console.log(`Installing app via IDB ${ipaPath} ${serial}`); await this._execIdb(`install ${ipaPath} --udid ${serial}`); @@ -100,12 +179,37 @@ export class IDBBridge implements IOSBridge { ); } - _execIdb(command: string): child_process.ChildProcess { + _execIdb(command: string): ChildProcessPromise { return exec(`${this.idbPath} ${command}`); } } export class SimctlBridge implements IOSBridge { + pull( + serial: string, + src: string, + bundleId: string, + dst: string, + ): Promise { + return iOSContainerUtility.pull(serial, src, bundleId, dst, ''); + } + + async getInstalledApps( + _serial: string, + ): Promise { + // TODO: Implement me + throw new Error( + 'SimctlBridge does not support getInstalledApps. Install IDB (https://fbidb.io/).', + ); + } + + async ls(_serial: string, _appBundleId: string): Promise { + // TODO: Implement me + throw new Error( + 'SimctlBridge does not support ls. Install IDB (https://fbidb.io/).', + ); + } + async installApp( serial: string, ipaPath: string, diff --git a/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx b/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx index 0123a8dfc..b184114c5 100644 --- a/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx +++ b/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx @@ -7,15 +7,25 @@ * @format */ -import {DeviceType, timeout} from 'flipper-common'; +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'; -export default class IOSDevice extends ServerDevice { +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; @@ -128,6 +138,111 @@ export default class IOSDevice extends ServerDevice { ); } + 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 { + 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 wranings.', + this.info.serial, + userApp.bundleID, + fileName, + filePath, + ); + } + return { + path: filePath, + data: await readFile(path.join(dir, fileName), { + encoding: 'utf-8', + }), + }; + }), + ); + + 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();