diff --git a/desktop/flipper-common/src/server-types.tsx b/desktop/flipper-common/src/server-types.tsx index 2472828f6..53725bc93 100644 --- a/desktop/flipper-common/src/server-types.tsx +++ b/desktop/flipper-common/src/server-types.tsx @@ -223,6 +223,10 @@ export type FlipperServerCommands = { ) => Promise; 'device-stop-screencapture': (serial: string) => Promise; // file path 'device-shell-exec': (serial: string, command: string) => Promise; + 'device-install-app': ( + serial: string, + appBundlePath: string, + ) => Promise; 'device-forward-port': ( serial: string, local: string, diff --git a/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx index a393c16a2..9f88fe905 100644 --- a/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx +++ b/desktop/flipper-frontend-core/src/devices/BaseDevice.tsx @@ -235,6 +235,14 @@ export default class BaseDevice implements Device { return this.flipperServer.exec('device-navigate', this.serial, location); } + async installApp(appBundlePath: string): Promise { + return this.flipperServer.exec( + 'device-install-app', + this.serial, + appBundlePath, + ); + } + async screenshot(): Promise { if (!this.description.features.screenshotAvailable || this.isArchived) { return; diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx index db6939fe1..0e1b03f0d 100644 --- a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -42,6 +42,7 @@ export interface Device { sendMetroCommand(command: string): Promise; navigateToLocation(location: string): Promise; screenshot(): Promise; + installApp(appBundlePath: string): Promise; } export type DevicePluginPredicate = (device: Device) => boolean; diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index c1e6827a1..327c2dc7b 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -602,6 +602,9 @@ function createMockDevice(options?: StartPluginOptions): Device & { get isConnected() { return this.connected.get(); }, + installApp(_: string) { + return Promise.resolve(); + }, navigateToLocation: createStubFunction(), screenshot: createStubFunction(), sendMetroCommand: createStubFunction(), diff --git a/desktop/flipper-server-core/src/FlipperServerImpl.tsx b/desktop/flipper-server-core/src/FlipperServerImpl.tsx index e94d70f23..09e810e45 100644 --- a/desktop/flipper-server-core/src/FlipperServerImpl.tsx +++ b/desktop/flipper-server-core/src/FlipperServerImpl.tsx @@ -285,6 +285,9 @@ export class FlipperServerImpl implements FlipperServer { } private commandHandler: FlipperServerCommands = { + 'device-install-app': async (serial, bundlePath) => { + return this.devices.get(serial)?.installApp(bundlePath); + }, 'get-server-state': async () => ({ state: this.state, error: this.stateError, diff --git a/desktop/flipper-server-core/src/devices/ServerDevice.tsx b/desktop/flipper-server-core/src/devices/ServerDevice.tsx index 2c9bcdc40..89dd1439e 100644 --- a/desktop/flipper-server-core/src/devices/ServerDevice.tsx +++ b/desktop/flipper-server-core/src/devices/ServerDevice.tsx @@ -80,4 +80,8 @@ export abstract class ServerDevice { async navigateToLocation(_location: string) { throw new Error('navigateLocation not implemented on BaseDevice'); } + + async installApp(_appBundlePath: string): Promise { + throw new Error('Install not implemented'); + } } diff --git a/desktop/flipper-server-core/src/devices/android/AndroidDevice.tsx b/desktop/flipper-server-core/src/devices/android/AndroidDevice.tsx index 00d895a4a..163ef0d64 100644 --- a/desktop/flipper-server-core/src/devices/android/AndroidDevice.tsx +++ b/desktop/flipper-server-core/src/devices/android/AndroidDevice.tsx @@ -275,6 +275,11 @@ export default class AndroidDevice extends ServerDevice { } super.disconnect(); } + + async installApp(apkPath: string) { + console.log(`Installing app with adb ${apkPath}`); + await this.adb.install(this.serial, apkPath); + } } export async function launchEmulator( diff --git a/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx b/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx index 97806af0c..a8269247c 100644 --- a/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx +++ b/desktop/flipper-server-core/src/devices/ios/IOSBridge.tsx @@ -16,7 +16,6 @@ import {DeviceType, uuid} from 'flipper-common'; import path from 'path'; import {exec, execFile} from 'promisify-child-process'; import {getFlipperServerConfig} from '../../FlipperServerConfig'; - export const ERR_NO_IDB_OR_XCODE_AVAILABLE = 'Neither Xcode nor idb available. Cannot provide iOS device functionality.'; @@ -44,6 +43,11 @@ export interface IOSBridge { outputFile: string, ) => child_process.ChildProcess; getActiveDevices: (bootedOnly: boolean) => Promise>; + installApp: ( + serial: string, + ipaPath: string, + tempPath: string, + ) => Promise; } export class IDBBridge implements IOSBridge { @@ -51,6 +55,12 @@ export class IDBBridge implements IOSBridge { private idbPath: string, private enablePhysicalDevices: boolean, ) {} + + async installApp(serial: string, ipaPath: string): Promise { + console.log(`Installing app via IDB ${ipaPath} ${serial}`); + await this._execIdb(`install ${ipaPath} --udid ${serial}`); + } + async getActiveDevices(_bootedOnly: boolean): Promise { return iosUtil .targets(this.idbPath, this.enablePhysicalDevices) @@ -96,6 +106,31 @@ export class IDBBridge implements IOSBridge { } export class SimctlBridge implements IOSBridge { + async installApp( + serial: string, + ipaPath: string, + tempPath: string, + ): Promise { + console.log(`Installing app ${ipaPath} with xcrun`); + const buildName = path.parse(ipaPath).name; + + const extractTmpDir = path.join(tempPath, `${buildName}-extract`, uuid()); + + try { + await fs.mkdirp(extractTmpDir); + await unzip(ipaPath, extractTmpDir); + await exec( + `xcrun simctl install ${serial} ${path.join( + extractTmpDir, + 'Payload', + '*.app', + )}`, + ); + } finally { + await fs.rmdir(extractTmpDir, {recursive: true}); + } + } + startLogListener( udid: string, deviceType: DeviceType, @@ -214,6 +249,16 @@ function makeTempScreenshotFilePath() { return path.join(getFlipperServerConfig().paths.tempPath, imageName); } +async function unzip(filePath: string, destination: string): Promise { + //todo this probably shouldn't involve shelling out... + await exec(`unzip -qq -o ${filePath} -d ${destination}`); + if (!(await fs.pathExists(path.join(destination, 'Payload')))) { + throw new Error( + `${path.join(destination, 'Payload')} Directory does not exists`, + ); + } +} + async function readScreenshotIntoBuffer(imagePath: string): Promise { const buffer = await fs.readFile(imagePath); await fs.unlink(imagePath); diff --git a/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx b/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx index 06c6e2d75..0123a8dfc 100644 --- a/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx +++ b/desktop/flipper-server-core/src/devices/ios/IOSDevice.tsx @@ -120,6 +120,14 @@ export default class IOSDevice extends ServerDevice { return output; } + async installApp(ipaPath: string): Promise { + return this.iOSBridge.installApp( + this.serial, + ipaPath, + this.flipperServer.config.paths.tempPath, + ); + } + disconnect() { if (this.recording) { this.stopScreenCapture(); diff --git a/desktop/flipper-server-core/src/devices/ios/__tests__/IOSBridge.node.tsx b/desktop/flipper-server-core/src/devices/ios/__tests__/IOSBridge.node.tsx index 1635abed8..dba9384af 100644 --- a/desktop/flipper-server-core/src/devices/ios/__tests__/IOSBridge.node.tsx +++ b/desktop/flipper-server-core/src/devices/ios/__tests__/IOSBridge.node.tsx @@ -196,3 +196,18 @@ test('uses idb to record when available', async () => { '/usr/local/bin/idb record-video --udid deadbeef /tmo/video.mp4', ); }); + +test('uses idb to install when available', async () => { + const ib = await makeIOSBridge( + '/usr/local/bin/idb', + true, + true, + async (_) => true, + ); + + await ib.installApp('sim1', '/tmp/foo/bar.zip', '/tmp/hello'); + + expect(promisifyChildProcess.exec).toHaveBeenCalledWith( + '/usr/local/bin/idb install /tmp/foo/bar.zip --udid sim1', + ); +}); diff --git a/desktop/types/adbkit.d.ts b/desktop/types/adbkit.d.ts index 42b0b1306..571bc112d 100644 --- a/desktop/types/adbkit.d.ts +++ b/desktop/types/adbkit.d.ts @@ -75,6 +75,8 @@ declare module 'adbkit' { local: string, remote: string, ) => Promise; // TODO: verify correctness of signature + + install: (serial: string, apkPath: string) => Promise; } export function createClient(config: {port: number; host: string}): Client; }