Screenshot in titlebar

Summary: Now, that we always have one device selected, we can put the buttons for screenshot and screen capture to Sonar's titlebar. This diff also adds support for iOS devices.

Reviewed By: jknoxville

Differential Revision: D8732632

fbshipit-source-id: 56271fbba7b4a2c10c2742c5c457dbb4c3c16777
This commit is contained in:
Daniel Büchele
2018-07-05 07:25:44 -07:00
committed by Facebook Github Bot
parent de353a7ed0
commit 03a8e696a9
4 changed files with 283 additions and 296 deletions

View File

@@ -0,0 +1,281 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {Button, ButtonGroup, Component} from 'sonar';
import {connect} from 'react-redux';
import AndroidDevice from '../devices/AndroidDevice';
import IOSDevice from '../devices/IOSDevice';
import os from 'os';
import fs from 'fs';
import adb from 'adbkit-fb';
import path from 'path';
import {exec} from 'child_process';
const SCREENSHOT_FILE_NAME = 'screen.png';
const VIDEO_FILE_NAME = 'video.mp4';
const SCREENSHOT_PATH = path.join(
os.homedir(),
'/.sonar/',
SCREENSHOT_FILE_NAME,
);
const VIDEO_PATH = path.join(os.homedir(), '.sonar', VIDEO_FILE_NAME);
import type BaseDevice from '../devices/BaseDevice';
type PullTransfer = any;
type Props = {|
devices: Array<BaseDevice>,
selectedDeviceIndex: number,
|};
type State = {|
pullingData: boolean,
recording: boolean,
recordingEnabled: boolean,
capturingScreenshot: boolean,
|};
function openFile(path: string): Promise<*> {
return new Promise((resolve, reject) => {
exec(`${getOpenCommand()} ${path}`, (error, stdout, stderr) => {
if (error) {
reject(error);
} else {
resolve(path);
}
});
});
}
function getOpenCommand(): string {
//TODO: TESTED ONLY ON MAC!
switch (os.platform()) {
case 'win32':
return 'start';
case 'linux':
return 'xdg-open';
default:
return 'open';
}
}
function writePngStreamToFile(stream: PullTransfer): Promise<string> {
return new Promise((resolve, reject) => {
stream.on('end', () => {
resolve(SCREENSHOT_PATH);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(SCREENSHOT_PATH));
});
}
class ScreenCaptureButtons extends Component<Props, State> {
iOSRecorder: ?any;
state = {
pullingData: false,
recording: false,
recordingEnabled: false,
capturingScreenshot: false,
};
componentDidMount() {
this.checkIfRecordingIsAvailable();
}
componentWillReceiveProps(nextProps: Props) {
this.checkIfRecordingIsAvailable(nextProps);
}
checkIfRecordingIsAvailable = (props: Props = this.props): void => {
const {devices, selectedDeviceIndex} = props;
const device: BaseDevice = devices[selectedDeviceIndex];
if (device instanceof AndroidDevice) {
this.executeShell(
device,
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
).then(output =>
this.setState({
recordingEnabled: !output,
}),
);
} else if (
device instanceof IOSDevice &&
device.deviceType === 'emulator'
) {
this.setState({
recordingEnabled: true,
});
} else {
this.setState({
recordingEnabled: false,
});
}
};
captureScreenshot = () => {
const {devices, selectedDeviceIndex} = this.props;
const device: BaseDevice = devices[selectedDeviceIndex];
if (device instanceof AndroidDevice) {
return device.adb
.screencap(device.serial)
.then(writePngStreamToFile)
.then(openFile)
.catch(console.error);
} else if (device instanceof IOSDevice) {
exec(
`xcrun simctl io booted screenshot ${SCREENSHOT_PATH}`,
(err, data) => {
if (err) {
console.error(err);
} else {
openFile(SCREENSHOT_PATH);
}
},
);
}
};
startRecording = () => {
const {devices, selectedDeviceIndex} = this.props;
const device: BaseDevice = devices[selectedDeviceIndex];
if (device instanceof AndroidDevice) {
this.setState({
recording: true,
});
this.executeShell(
device,
`screenrecord --bugreport /sdcard/${VIDEO_FILE_NAME}`,
)
.then(output => {
if (output) {
throw output;
}
})
.then(() => {
this.setState({
recording: false,
pullingData: true,
});
})
.then(
(): Promise<string> => {
return this.pullFromDevice(
device,
`/sdcard/${VIDEO_FILE_NAME}`,
VIDEO_PATH,
);
},
)
.then(openFile)
.then(() => {
this.executeShell(device, `rm /sdcard/${VIDEO_FILE_NAME}`);
})
.then(() => {
this.setState({
pullingData: false,
});
})
.catch(error => {
console.error(`unable to capture video: ${error}`);
this.setState({
recording: false,
pullingData: false,
});
});
} else if (device instanceof IOSDevice) {
this.setState({
recording: true,
});
this.iOSRecorder = exec(
`xcrun simctl io booted recordVideo ${VIDEO_PATH}`,
);
}
};
pullFromDevice = (
device: AndroidDevice,
src: string,
dst: string,
): Promise<string> => {
return new Promise((resolve, reject) => {
return device.adb.pull(device.serial, src).then(stream => {
stream.on('end', () => {
resolve(dst);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(dst));
});
});
};
stopRecording = () => {
const {devices, selectedDeviceIndex} = this.props;
const device: BaseDevice = devices[selectedDeviceIndex];
if (device instanceof AndroidDevice) {
this.executeShell(device, `pgrep 'screenrecord' -L 2`);
} else if (this.iOSRecorder) {
this.iOSRecorder.kill();
this.setState({
recording: false,
});
openFile(VIDEO_PATH);
}
};
executeShell = (device: AndroidDevice, command: string): Promise<string> => {
return device.adb
.shell(device.serial, command)
.then(adb.util.readAll)
.then(output => output.toString().trim());
};
onRecordingClicked = () => {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
};
render() {
const {recordingEnabled} = this.state;
const {devices, selectedDeviceIndex} = this.props;
const device: ?BaseDevice =
selectedDeviceIndex > -1 ? devices[selectedDeviceIndex] : null;
return (
<ButtonGroup>
<Button
compact={true}
onClick={this.captureScreenshot}
icon="camera"
title="Take Screenshot"
disabled={!device}
/>
<Button
compact={true}
onClick={this.onRecordingClicked}
icon={this.state.recording ? 'stop-playback' : 'camcorder'}
pulse={this.state.recording}
selected={this.state.recording}
title="Make Screen Recording"
disabled={!device || !recordingEnabled}
/>
</ButtonGroup>
);
}
}
export default connect(({connections: {devices, selectedDeviceIndex}}) => ({
devices,
selectedDeviceIndex,
}))(ScreenCaptureButtons);

View File

@@ -22,6 +22,7 @@ import {
togglePluginManagerVisible, togglePluginManagerVisible,
} from '../reducers/application.js'; } from '../reducers/application.js';
import DevicesButton from './DevicesButton.js'; import DevicesButton from './DevicesButton.js';
import ScreenCaptureButtons from './ScreenCaptureButtons.js';
import AutoUpdateVersion from './AutoUpdateVersion.js'; import AutoUpdateVersion from './AutoUpdateVersion.js';
import config from '../fb-stubs/config.js'; import config from '../fb-stubs/config.js';
@@ -71,6 +72,7 @@ class SonarTitleBar extends Component<Props> {
return ( return (
<TitleBar focused={this.props.windowIsFocused} className="toolbar"> <TitleBar focused={this.props.windowIsFocused} className="toolbar">
<DevicesButton /> <DevicesButton />
<ScreenCaptureButtons />
<Spacer /> <Spacer />
{process.platform === 'darwin' ? <AutoUpdateVersion /> : null} {process.platform === 'darwin' ? <AutoUpdateVersion /> : null}
{config.bugReportButtonVisible && ( {config.bugReportButtonVisible && (

View File

@@ -10,7 +10,6 @@ import type {SonarDevicePlugin} from '../plugin.js';
import {GK} from 'sonar'; import {GK} from 'sonar';
import logs from './logs/index.js'; import logs from './logs/index.js';
import cpu from './cpu/index.js'; import cpu from './cpu/index.js';
import screen from './screen/index.js';
const plugins: Array<Class<SonarDevicePlugin<any>>> = [logs]; const plugins: Array<Class<SonarDevicePlugin<any>>> = [logs];
@@ -18,8 +17,4 @@ if (GK.get('sonar_uiperf')) {
plugins.push(cpu); plugins.push(cpu);
} }
if (GK.get('sonar_screen_plugin')) {
plugins.push(screen);
}
export const devicePlugins = plugins; export const devicePlugins = plugins;

View File

@@ -1,291 +0,0 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {SonarDevicePlugin} from 'sonar';
import {
Button,
FlexColumn,
FlexRow,
LoadingIndicator,
styled,
colors,
Component,
} from 'sonar';
const os = require('os');
const fs = require('fs');
const adb = require('adbkit-fb');
const path = require('path');
const exec = require('child_process').exec;
const SCREENSHOT_FILE_NAME = 'screen.png';
const VIDEO_FILE_NAME = 'video.mp4';
const SCREENSHOT_PATH = path.join(
os.homedir(),
'/.sonar/',
SCREENSHOT_FILE_NAME,
);
const VIDEO_PATH = path.join(os.homedir(), '.sonar', VIDEO_FILE_NAME);
type AndroidDevice = any;
type AdbClient = any;
type PullTransfer = any;
type State = {|
pullingData: boolean,
recording: boolean,
recordingEnabled: boolean,
capturingScreenshot: boolean,
|};
const BigButton = Button.extends({
height: 200,
width: 200,
flexGrow: 1,
fontSize: 24,
});
const ButtonContainer = FlexRow.extends({
alignItems: 'center',
justifyContent: 'space-around',
padding: 20,
});
const LoadingSpinnerContainer = FlexRow.extends({
flexGrow: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
});
const LoadingSpinnerText = styled.text({
fontSize: 24,
marginLeft: 12,
color: colors.grey,
});
class LoadingSpinner extends Component<{}, {}> {
render() {
return (
<LoadingSpinnerContainer>
<LoadingIndicator />
<LoadingSpinnerText>Pulling files from device...</LoadingSpinnerText>
</LoadingSpinnerContainer>
);
}
}
function openFile(path: string): Promise<*> {
return new Promise((resolve, reject) => {
exec(`${getOpenCommand()} ${path}`, (error, stdout, stderr) => {
if (error) {
reject(error);
} else {
resolve(path);
}
});
});
}
function getOpenCommand(): string {
//TODO: TESTED ONLY ON MAC!
switch (os.platform()) {
case 'win32':
return 'start';
case 'linux':
return 'xdg-open';
default:
return 'open';
}
}
function writePngStreamToFile(stream: PullTransfer): Promise<string> {
return new Promise((resolve, reject) => {
stream.on('end', () => {
resolve(SCREENSHOT_PATH);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(SCREENSHOT_PATH));
});
}
export default class ScreenPlugin extends SonarDevicePlugin<State> {
static id = 'DeviceScreen';
static title = 'Screen';
static icon = 'mobile';
device: AndroidDevice;
adbClient: AdbClient;
state = {
pullingData: false,
recording: false,
recordingEnabled: false,
capturingScreenshot: false,
};
init() {
this.adbClient = this.device.adb;
this.executeShell(
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
).then(output => {
if (output) {
console.error(
'screenrecord util does not exist. Most likely it is an emulator which does not support screen recording via adb',
);
this.setState({
recordingEnabled: false,
});
} else {
this.setState({
recordingEnabled: true,
});
}
});
}
captureScreenshot = () => {
return this.adbClient
.screencap(this.device.serial)
.then(writePngStreamToFile)
.then(openFile)
.catch(error => {
//TODO: proper logging?
console.error(error);
});
};
pullFromDevice = (src: string, dst: string): Promise<string> => {
return new Promise((resolve, reject) => {
return this.adbClient.pull(this.device.serial, src).then(stream => {
stream.on('end', () => {
resolve(dst);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(dst));
});
});
};
onRecordingClicked = () => {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
};
onScreenshotClicked = () => {
var self = this;
this.setState({
capturingScreenshot: true,
});
this.captureScreenshot().then(() => {
self.setState({
capturingScreenshot: false,
});
});
};
startRecording = () => {
const self = this;
this.setState({
recording: true,
});
this.executeShell(`screenrecord --bugreport /sdcard/${VIDEO_FILE_NAME}`)
.then(output => {
if (output) {
throw output;
}
})
.then(() => {
self.setState({
recording: false,
pullingData: true,
});
})
.then(
(): Promise<string> => {
return self.pullFromDevice(`/sdcard/${VIDEO_FILE_NAME}`, VIDEO_PATH);
},
)
.then(openFile)
.then(() => {
self.executeShell(`rm /sdcard/${VIDEO_FILE_NAME}`);
})
.then(() => {
self.setState({
pullingData: false,
});
})
.catch(error => {
console.error(`unable to capture video: ${error}`);
self.setState({
recording: false,
pullingData: false,
});
});
};
stopRecording = () => {
this.executeShell(`pgrep 'screenrecord' -L 2`);
};
executeShell = (command: string): Promise<string> => {
return this.adbClient
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(output => {
return new Promise((resolve, reject) => {
resolve(output.toString().trim());
});
});
};
getLoadingSpinner = () => {
return this.state.pullingData ? <LoadingSpinner /> : null;
};
render() {
const recordingEnabled =
this.state.recordingEnabled &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
const screenshotEnabled =
!this.state.recording &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
return (
<FlexColumn>
<ButtonContainer>
<BigButton
key="video_btn"
onClick={!recordingEnabled ? null : this.onRecordingClicked}
icon={this.state.recording ? 'stop' : 'camcorder'}
disabled={!recordingEnabled}
selected={true}
pulse={this.state.recording}
iconSize={24}>
{!this.state.recording ? 'Record screen' : 'Stop recording'}
</BigButton>
<BigButton
key="screenshot_btn"
icon="camera"
selected={true}
onClick={!screenshotEnabled ? null : this.onScreenshotClicked}
iconSize={24}
pulse={this.state.capturingScreenshot}
disabled={!screenshotEnabled}>
Take screenshot
</BigButton>
</ButtonContainer>
{this.getLoadingSpinner()}
</FlexColumn>
);
}
}