diff --git a/src/chrome/ScreenCaptureButtons.tsx b/src/chrome/ScreenCaptureButtons.tsx index ab3c365c2..bcc632b99 100644 --- a/src/chrome/ScreenCaptureButtons.tsx +++ b/src/chrome/ScreenCaptureButtons.tsx @@ -8,13 +8,9 @@ import {Button, ButtonGroup, writeBufferToFile} from 'flipper'; import React, {Component} from 'react'; import {connect} from 'react-redux'; -import AndroidDevice from '../devices/AndroidDevice'; -import IOSDevice from '../devices/IOSDevice'; import expandTilde from 'expand-tilde'; -import fs from 'fs'; import os from 'os'; -import adb from 'adbkit-fb'; -import {exec, spawn} from 'child_process'; +import {spawn} from 'child_process'; import {remote} from 'electron'; import path from 'path'; import {reportPlatformFailures} from '../utils/metrics'; @@ -35,13 +31,15 @@ type StateFromProps = { type DispatchFromProps = {}; type State = { - pullingData: boolean; recording: boolean; recordingEnabled: boolean; capturingScreenshot: boolean; }; -function openFile(path: string) { +function openFile(path: string | null) { + if (!path) { + return; + } const child = spawn(getOpenCommand(), [path]); child.on('exit', code => { if (code != 0) { @@ -68,11 +66,9 @@ function getFileName(extension: 'png' | 'mp4'): string { type Props = OwnProps & StateFromProps & DispatchFromProps; class ScreenCaptureButtons extends Component { - iOSRecorder: any | null | undefined; videoPath: string | null | undefined; state = { - pullingData: false, recording: false, recordingEnabled: false, capturingScreenshot: false, @@ -83,33 +79,17 @@ class ScreenCaptureButtons extends Component { } componentWillReceiveProps(nextProps: Props) { - this.checkIfRecordingIsAvailable(nextProps); + if (nextProps.selectedDevice !== this.props.selectedDevice) { + this.checkIfRecordingIsAvailable(nextProps); + } } - checkIfRecordingIsAvailable = (props: Props = this.props): void => { + checkIfRecordingIsAvailable = async (props: Props = this.props) => { const {selectedDevice} = props; - - if (selectedDevice instanceof AndroidDevice) { - this.executeShell( - selectedDevice, - `[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`, - ).then(output => - this.setState({ - recordingEnabled: !output, - }), - ); - } else if ( - selectedDevice instanceof IOSDevice && - selectedDevice.deviceType === 'emulator' - ) { - this.setState({ - recordingEnabled: true, - }); - } else { - this.setState({ - recordingEnabled: false, - }); - } + const recordingEnabled = selectedDevice + ? await selectedDevice.screenCaptureAvailable() + : false; + this.setState({recordingEnabled}); }; captureScreenshot: Promise | any = () => { @@ -126,119 +106,29 @@ class ScreenCaptureButtons extends Component { } }; - startRecording = () => { + startRecording = async () => { const {selectedDevice} = this.props; - const videoPath = path.join(CAPTURE_LOCATION, getFileName('mp4')); - this.videoPath = videoPath; - if (selectedDevice instanceof AndroidDevice) { - const devicePath = '/sdcard/flipper_recorder'; - - this.setState({ - recording: true, - }); - - this.executeShell( - selectedDevice, - `mkdir -p "${devicePath}" && echo -n > "${devicePath}/.nomedia"`, - ) - .then(output => { - if (output) { - throw output; - } - }) - .then(() => - this.executeShell( - selectedDevice, - `screenrecord --bugreport "${devicePath}/video.mp4"`, - ), - ) - .then(output => { - if (output) { - throw output; - } - }) - .then(() => { - this.setState({ - recording: false, - pullingData: true, - }); - }) - .then( - (): Promise => - this.pullFromDevice( - selectedDevice, - `${devicePath}/video.mp4`, - videoPath, - ), - ) - .then(openFile) - .then(() => this.executeShell(selectedDevice, `rm -rf "${devicePath}"`)) - .then(output => { - if (output) { - throw output; - } - }) - .then(() => { - this.setState({ - pullingData: false, - }); - }) - .catch(error => { - console.error(`unable to capture video: ${error}`); - this.setState({ - recording: false, - pullingData: false, - }); - }); - } else if (selectedDevice instanceof IOSDevice) { - this.setState({ - recording: true, - }); - this.iOSRecorder = exec( - `xcrun simctl io booted recordVideo "${videoPath}"`, - ); + if (!selectedDevice) { + return; } - }; + const videoPath = path.join(CAPTURE_LOCATION, getFileName('mp4')); + await selectedDevice.startScreenCapture(videoPath); - pullFromDevice = ( - device: AndroidDevice, - src: string, - dst: string, - ): Promise => { - return new Promise((resolve, reject) => { - return device.adb - .pull(device.serial, src) - .then((stream: NodeJS.ReadStream) => { - stream.on('end', () => { - resolve(dst); - }); - stream.on('error', reject); - stream.pipe(fs.createWriteStream(dst)); - }); + this.setState({ + recording: true, }); }; - stopRecording = () => { - const {videoPath} = this; + stopRecording = async () => { const {selectedDevice} = this.props; - this.videoPath = null; - - if (selectedDevice instanceof AndroidDevice) { - this.executeShell(selectedDevice, `pgrep 'screenrecord' -L 2`); - } else if (this.iOSRecorder && videoPath) { - this.iOSRecorder.kill('SIGINT'); - this.setState({ - recording: false, - }); - openFile(videoPath); + if (!selectedDevice) { + return; } - }; - - executeShell = (device: AndroidDevice, command: string): Promise => { - return device.adb - .shell(device.serial, command) - .then(adb.util.readAll) - .then((output: Buffer) => output.toString().trim()); + const path = await selectedDevice.stopScreenCapture(); + this.setState({ + recording: false, + }); + openFile(path); }; onRecordingClicked = () => { diff --git a/src/devices/AndroidDevice.tsx b/src/devices/AndroidDevice.tsx index 569ffd7d1..0da485cef 100644 --- a/src/devices/AndroidDevice.tsx +++ b/src/devices/AndroidDevice.tsx @@ -6,13 +6,15 @@ */ import BaseDevice, {DeviceType, DeviceShell, LogLevel} from './BaseDevice'; +import adb from 'adbkit-fb'; import {Priority} from 'adbkit-logcat-fb'; import child_process from 'child_process'; import {spawn} from 'promisify-child-process'; import ArchivedDevice from './ArchivedDevice'; -import {ReadStream} from 'fs'; +import {createWriteStream} from 'fs'; type ADBClient = any; +const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder'; export default class AndroidDevice extends BaseDevice { constructor( @@ -61,6 +63,7 @@ export default class AndroidDevice extends BaseDevice { adb: ADBClient; pidAppMapping: {[key: number]: string} = {}; logReader: any; + private recordingDestination?: string; supportedColumns(): Array { return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time']; @@ -102,7 +105,7 @@ export default class AndroidDevice extends BaseDevice { screenshot(): Promise { return new Promise((resolve, reject) => { - this.adb.screencap(this.serial).then((stream: ReadStream) => { + this.adb.screencap(this.serial).then((stream: NodeJS.WriteStream) => { const chunks: Array = []; stream .on('data', (chunk: Buffer) => chunks.push(chunk)) @@ -113,4 +116,57 @@ export default class AndroidDevice extends BaseDevice { }); }); } + + async screenCaptureAvailable(): Promise { + try { + await this.executeShell( + `[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`, + ); + return true; + } catch (_e) { + return false; + } + } + + private async executeShell(command: string): Promise { + const output = await this.adb + .shell(this.serial, command) + .then(adb.util.readAll) + .then((output: Buffer) => output.toString().trim()); + if (output) { + throw new Error(output); + } + } + + async startScreenCapture(destination: string) { + await this.executeShell( + `mkdir -p "${DEVICE_RECORDING_DIR}" && echo -n > "${DEVICE_RECORDING_DIR}/.nomedia"`, + ); + this.recordingDestination = destination; + const recordingLocation = `${DEVICE_RECORDING_DIR}/video.mp4`; + this.adb + .shell(this.serial, `screenrecord --bugreport "${recordingLocation}"`) + .then( + () => + new Promise((resolve, reject) => + this.adb + .pull(this.serial, recordingLocation) + .then((stream: NodeJS.WriteStream) => { + stream.on('end', resolve); + stream.on('error', reject); + stream.pipe(createWriteStream(destination)); + }), + ), + ); + } + + async stopScreenCapture(): Promise { + const {recordingDestination} = this; + if (!recordingDestination) { + return Promise.reject(new Error('Recording was not properly started')); + } + this.recordingDestination = undefined; + await this.adb.shell(this.serial, `pgrep 'screenrecord' -L 2`); + return recordingDestination; + } } diff --git a/src/devices/BaseDevice.tsx b/src/devices/BaseDevice.tsx index 637bd2c64..07666c14e 100644 --- a/src/devices/BaseDevice.tsx +++ b/src/devices/BaseDevice.tsx @@ -152,4 +152,16 @@ export default class BaseDevice { new Error('No screenshot support for current device'), ); } + + async screenCaptureAvailable(): Promise { + return false; + } + + async startScreenCapture(destination: string) { + throw new Error('startScreenCapture not implemented on BaseDevice '); + } + + async stopScreenCapture(): Promise { + return null; + } } diff --git a/src/devices/IOSDevice.tsx b/src/devices/IOSDevice.tsx index a0d74d630..5f755c07b 100644 --- a/src/devices/IOSDevice.tsx +++ b/src/devices/IOSDevice.tsx @@ -6,7 +6,7 @@ */ import {DeviceType, LogLevel, DeviceLogEntry} from './BaseDevice'; -import child_process from 'child_process'; +import child_process, {ChildProcess} from 'child_process'; import BaseDevice from './BaseDevice'; import JSONStream from 'JSONStream'; import {Transform} from 'stream'; @@ -39,6 +39,8 @@ type RawLogEntry = { export default class IOSDevice extends BaseDevice { log: any; buffer: string; + private recordingProcess?: ChildProcess; + private recordingLocation?: string; constructor(serial: string, deviceType: DeviceType, title: string) { super(serial, deviceType, title, 'iOS'); @@ -138,11 +140,11 @@ export default class IOSDevice extends BaseDevice { static parseLogEntry(entry: RawLogEntry): DeviceLogEntry { const LOG_MAPPING: Map = new Map([ - ['Default', 'debug'], - ['Info', 'info'], - ['Debug', 'debug'], - ['Error', 'error'], - ['Fault', 'fatal'], + ['Default' as IOSLogLevel, 'debug' as LogLevel], + ['Info' as IOSLogLevel, 'info' as LogLevel], + ['Debug' as IOSLogLevel, 'debug' as LogLevel], + ['Error' as IOSLogLevel, 'error' as LogLevel], + ['Fault' as IOSLogLevel, 'fatal' as LogLevel], ]); let type: LogLevel = LOG_MAPPING.get(entry.messageType) || 'unknown'; @@ -174,6 +176,27 @@ export default class IOSDevice extends BaseDevice { type, }; } + + async screenCaptureAvailable() { + return this.deviceType === 'emulator'; + } + + async startScreenCapture(destination: string) { + this.recordingProcess = exec( + `xcrun simctl io booted recordVideo "${destination}"`, + ); + this.recordingLocation = destination; + } + + async stopScreenCaputre(): Promise { + if (this.recordingProcess && this.recordingLocation) { + this.recordingProcess.kill('SIGINT'); + const {recordingLocation} = this; + this.recordingLocation = undefined; + return recordingLocation; + } + return null; + } } // Used to strip the initial output of the logging utility where it prints out settings.