Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
/**
* 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 {FlexRow, Text, colors, LoadingIndicator, Glyph, Component} from 'sonar';
import {remote} from 'electron';
import {isProduction} from '../utils/dynamicPluginLoading';
import config from '../fb-stubs/config.js';
const version = remote.app.getVersion();
const VersionText = Text.extends({
color: colors.light50,
marginLeft: 4,
marginTop: 2,
});
const Container = FlexRow.extends({
alignItems: 'center',
});
type State = {
updater:
| 'error'
| 'checking-for-update'
| 'update-available'
| 'update-not-available'
| 'update-downloaded',
error?: string,
};
export default class AutoUpdateVersion extends Component<{}, State> {
state = {
updater: 'update-not-available',
};
componentDidMount() {
if (isProduction()) {
remote.autoUpdater.setFeedURL(
`${config.updateServer}?version=${version}`,
);
remote.autoUpdater.on('update-downloaded', () => {
this.setState({updater: 'update-downloaded'});
remote.dialog.showMessageBox(
{
title: 'Update available',
message: 'A new version of Sonar is available!',
detail: `You have Sonar ${version} which is outdated. Update to the latest version now.`,
buttons: ['Install and Restart'],
},
() => {
remote.autoUpdater.quitAndInstall();
},
);
});
remote.autoUpdater.on('error', error => {
this.setState({updater: 'error', error: error.toString()});
});
remote.autoUpdater.on('checking-for-update', () => {
this.setState({updater: 'checking-for-update'});
});
remote.autoUpdater.on('update-available', error => {
this.setState({updater: 'update-available'});
});
remote.autoUpdater.on('update-not-available', error => {
this.setState({updater: 'update-not-available'});
});
remote.autoUpdater.checkForUpdates();
}
}
render() {
return (
<Container>
{this.state.updater === 'update-available' && (
<span title="Downloading new version">
<LoadingIndicator size={16} />
</span>
)}
{this.state.updater === 'error' && (
<span title={`Error fetching update: ${this.state.error || ''}`}>
<Glyph color={colors.light30} name="caution-triangle" />
</span>
)}
{this.state.updater === 'update-downloaded' && (
<span title="Update available. Restart Sonar.">
<Glyph color={colors.light30} name="breaking-news" />
</span>
)}
{isProduction() && <VersionText>{version}</VersionText>}
</Container>
);
}
}

View File

@@ -0,0 +1,239 @@
/**
* 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 type BugReporter from '../fb-stubs/BugReporter.js';
import {Component} from 'react';
import {
Button,
colors,
Link,
Input,
FlexColumn,
FlexRow,
Textarea,
Text,
FlexCenter,
styled,
} from 'sonar';
const Container = FlexColumn.extends({
padding: 10,
});
const textareaStyle = {
margin: 0,
marginBottom: 10,
};
const DialogContainer = styled.view({
width: 400,
height: 300,
position: 'absolute',
left: '50%',
marginLeft: -200,
top: 40,
zIndex: 999999,
backgroundColor: '#fff',
border: '1px solid #ddd',
borderTop: 'none',
borderBottomLeftRadius: 5,
borderBottomRightRadius: 5,
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
});
const TitleInput = Input.extends({
...textareaStyle,
height: 30,
});
const DescriptionTextarea = Textarea.extends({
...textareaStyle,
flexGrow: 1,
});
const SubmitButtonContainer = styled.view({
marginLeft: 'auto',
});
const Footer = FlexRow.extends({
lineHeight: '24px',
});
const CloseDoneButton = Button.extends({
width: 50,
margin: '10px auto',
});
type State = {
description: string,
title: string,
submitting: boolean,
success: false | number, // false if not created, id of bug if it's been created
error: ?string,
};
type Props = {
bugReporter: BugReporter,
close: () => void,
};
const DEFAULT_DESCRIPTION = `Thanks for taking the time to provide feedback!
Please fill out the following information to make addressing your issue easier.
What device platform are you using? ios/android
What sort of device are you using? emulator/physical
What app are you trying to use? wilde, fb4a, lite etc
Describe your problem in as much detail as possible: `;
export default class BugReporterDialog extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
description: DEFAULT_DESCRIPTION,
title: '',
submitting: false,
success: false,
error: null,
};
}
titleRef: HTMLElement;
descriptionRef: HTMLElement;
onDescriptionChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({description: e.target.value});
};
onTitleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({title: e.target.value});
};
onSubmit = () => {
// validate fields
const {title, description} = this.state;
if (!title) {
this.setState({
error: 'Title required.',
});
this.titleRef.focus();
return;
}
if (!description) {
this.setState({
error: 'Description required.',
});
this.descriptionRef.focus();
return;
}
this.setState(
{
error: null,
submitting: true,
},
() => {
// this will be called before the next repaint
requestAnimationFrame(() => {
// we have to call this again to ensure a repaint has actually happened
// as requestAnimationFrame is called BEFORE a repaint, not after which
// means we have to queue up twice to actually ensure a repaint has
// happened
requestAnimationFrame(() => {
this.props.bugReporter
.report(title, description)
.then((id: number) => {
this.setState({
submitting: false,
success: id,
});
})
.catch(err => {
this.setState({
error: err.message,
submitting: false,
});
});
});
});
},
);
};
setTitleRef = (ref: HTMLElement) => {
this.titleRef = ref;
};
setDescriptionRef = (ref: HTMLElement) => {
this.descriptionRef = ref;
};
onCancel = () => {
this.props.close();
};
render() {
let content;
const {title, success, error, description} = this.state;
if (success) {
content = (
<FlexCenter fill={true}>
<FlexColumn>
<Text>
<Text>Bug </Text>
<Text bold={true}>
<Link
href={`https://our.intern.facebook.com/intern/bug/${success}`}>
{success}
</Link>
</Text>
<Text> created. Thank you for the report!</Text>
</Text>
<CloseDoneButton onClick={this.onCancel}>Close</CloseDoneButton>
</FlexColumn>
</FlexCenter>
);
} else {
content = (
<Container fill={true}>
<TitleInput
placeholder="Title..."
value={title}
innerRef={this.setTitleRef}
onChange={this.onTitleChange}
/>
<DescriptionTextarea
placeholder="Description..."
value={description}
innerRef={this.setDescriptionRef}
onChange={this.onDescriptionChange}
/>
<Footer>
{error != null && <Text color={colors.red}>{error}</Text>}
<SubmitButtonContainer>
<Button type="primary" onClick={this.onSubmit}>
Submit report
</Button>
<Button type="danger" onClick={this.onCancel}>
Cancel
</Button>
</SubmitButtonContainer>
</Footer>
</Container>
);
}
return <DialogContainer>{content}</DialogContainer>;
}
}

291
src/chrome/DevicesButton.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* 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 {Component, styled, Glyph, Button, colors} from 'sonar';
import {connect} from 'react-redux';
import BaseDevice from '../devices/BaseDevice.js';
import child_process from 'child_process';
import DevicesList from './DevicesList.js';
const adb = require('adbkit-fb');
const Light = styled.view(
{
width: 10,
height: 10,
borderRadius: '999em',
backgroundColor: props => (props.active ? '#70f754' : colors.light20),
border: props => `1px solid ${props.active ? '#52d936' : colors.light30}`,
},
{
ignoreAttributes: ['active'],
},
);
type Props = {|
devices: Array<BaseDevice>,
|};
type Emulator = {|
name: string,
os?: string,
isRunning: boolean,
|};
type State = {
androidEmulators: Array<Emulator>,
iOSSimulators: Array<Emulator>,
popoverVisible: boolean,
};
type IOSSimulatorList = {
devices: {
[os: string]: Array<{
state: 'Shutdown' | 'Booted',
availability: string,
name: string,
udid: string,
os?: string,
}>,
},
};
class DevicesButton extends Component<Props, State> {
state = {
androidEmulators: [],
iOSSimulators: [],
popoverVisible: false,
};
client = adb.createClient();
_iOSSimulatorRefreshInterval: ?number;
componentDidMount() {
this.updateEmulatorState(this.openMenuWhenNoDevicesConnected);
this.fetchIOSSimulators();
this._iOSSimulatorRefreshInterval = window.setInterval(
this.fetchIOSSimulators,
5000,
);
this.client.trackDevices().then(tracker => {
tracker.on('add', () => this.updateEmulatorState());
tracker.on('remove', () => this.updateEmulatorState());
tracker.on('end', () => this.updateEmulatorState());
});
}
componentWillUnmount() {
if (this._iOSSimulatorRefreshInterval != null) {
window.clearInterval(this._iOSSimulatorRefreshInterval);
}
}
fetchIOSSimulators = () => {
child_process.exec(
'xcrun simctl list devices --json',
(err: ?Error, data: ?string) => {
if (data != null && err == null) {
const devicesList: IOSSimulatorList = JSON.parse(data);
const iOSSimulators = Object.keys(devicesList.devices)
.map(os =>
devicesList.devices[os].map(device => {
device.os = os;
return device;
}),
)
.reduce((acc, cv) => acc.concat(cv), [])
.filter(device => device.state === 'Booted')
.map(device => ({
name: device.name,
os: device.os,
isRunning: true,
}));
this.setState({iOSSimulators});
}
},
);
};
openMenuWhenNoDevicesConnected = () => {
const numberOfEmulators = this.state.androidEmulators.filter(
e => e.isRunning,
).length;
const numberOfDevices = Object.values(this.props.devices).length;
if (numberOfEmulators + numberOfDevices === 0) {
this.setState({popoverVisible: true});
}
};
updateEmulatorState = async (cb?: Function) => {
try {
const devices = await this.getEmulatorNames();
const ports = await this.getRunningEmulatorPorts();
const runningDevices = await Promise.all(
ports.map(port => this.getRunningName(port)),
);
this.setState(
{
androidEmulators: devices.map(name => ({
name,
isRunning: runningDevices.indexOf(name) > -1,
})),
},
cb,
);
} catch (e) {
console.error(e);
}
};
getEmulatorNames(): Promise<Array<string>> {
return new Promise((resolve, reject) => {
child_process.exec(
'/opt/android_sdk/tools/emulator -list-avds',
(error: ?Error, data: ?string) => {
if (error == null && data != null) {
resolve(data.split('\n').filter(name => name !== ''));
} else {
reject(error);
}
},
);
});
}
getRunningEmulatorPorts(): Promise<Array<string>> {
const EMULATOR_PREFIX = 'emulator-';
return adb
.createClient()
.listDevices()
.then((devices: Array<{id: string}>) =>
devices
.filter(d => d.id.startsWith(EMULATOR_PREFIX))
.map(d => d.id.replace(EMULATOR_PREFIX, '')),
)
.catch((e: Error) => {
return [];
});
}
getRunningName(port: string): Promise<?string> {
return new Promise((resolve, reject) => {
child_process.exec(
`echo "avd name" | nc -w 1 localhost ${port}`,
(error: ?Error, data: ?string) => {
if (error == null && data != null) {
const match = data.trim().match(/(.*)\r\nOK$/);
resolve(match != null && match.length > 0 ? match[1] : null);
} else {
reject(error);
}
},
);
});
}
launchEmulator = (name: string) => {
child_process.exec(
`/opt/android_sdk/tools/emulator @${name}`,
this.updateEmulatorState,
);
};
createEmualtor = () => {};
onClick = () => {
this.setState({popoverVisible: !this.state.popoverVisible});
this.updateEmulatorState();
this.fetchIOSSimulators();
};
onDismissPopover = () => {
this.setState({popoverVisible: false});
};
render() {
let text = 'No devices running';
let glyph = 'minus-circle';
const runnningEmulators = this.state.androidEmulators.filter(
emulator => emulator.isRunning,
);
const numberOfRunningDevices =
runnningEmulators.length + this.state.iOSSimulators.length;
if (numberOfRunningDevices > 0) {
text = `${numberOfRunningDevices} device${
numberOfRunningDevices > 1 ? 's' : ''
} running`;
glyph = 'mobile';
}
const connectedDevices = this.props.devices;
return (
<Button
compact={true}
onClick={this.onClick}
icon={glyph}
disabled={this.state.androidEmulators.length === 0}>
{text}
{this.state.popoverVisible && (
<DevicesList
onDismiss={this.onDismissPopover}
sections={[
{
title: 'Running',
items: [
...connectedDevices
.filter(device => device.deviceType === 'physical')
.map(device => ({
title: device.title,
subtitle: device.os,
icon: <Light active={true} />,
})),
...runnningEmulators.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
icon: <Light active={true} />,
})),
...this.state.iOSSimulators.map(simulator => ({
title: simulator.name,
subtitle: `${String(simulator.os)} Simulator`,
icon: <Light active={true} />,
})),
],
},
{
title: 'Not Running',
items: [
...this.state.androidEmulators
.filter(emulator => !emulator.isRunning)
.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
onClick: () => this.launchEmulator(emulator.name),
icon: <Light active={false} />,
})),
{
title: 'Connect a device',
subtitle: 'Plugins will load automatically',
icon: <Glyph name="mobile" size={12} />,
},
],
},
]}
/>
)}
</Button>
);
}
}
export default connect(({devices}) => ({
devices,
}))(DevicesButton);

142
src/chrome/DevicesList.js Normal file
View File

@@ -0,0 +1,142 @@
/**
* 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 {
PureComponent,
FlexRow,
FlexBox,
Text,
Button,
Popover,
styled,
colors,
} from 'sonar';
const Heading = Text.extends({
display: 'block',
backgroundColor: colors.white,
color: colors.light30,
fontSize: 11,
fontWeight: 600,
lineHeight: '21px',
padding: '4px 8px 0',
});
const PopoverItem = FlexRow.extends({
alignItems: 'center',
borderBottom: `1px solid ${colors.light05}`,
height: 50,
'&:last-child': {
borderBottom: 'none',
},
});
const ItemTitle = Text.extends({
display: 'block',
fontSize: 14,
fontWeight: 400,
lineHeight: '120%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
marginBottom: 1,
});
const ItemSubtitle = Text.extends({
display: 'block',
fontWeight: 400,
fontSize: 11,
color: colors.light30,
lineHeight: '14px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
const ItemImage = FlexBox.extends({
alignItems: 'center',
justifyContent: 'center',
width: 40,
flexShrink: 0,
});
const ItemContent = styled.view({
minWidth: 0,
paddingRight: 5,
flexGrow: 1,
});
const Section = styled.view({
maxWidth: 260,
borderBottom: `1px solid ${colors.light05}`,
'&:last-child': {
borderBottom: 'none',
},
});
const Action = Button.extends({
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
background: 'transparent',
color: colors.macOSTitleBarIconSelected,
marginRight: 8,
marginLeft: 4,
lineHeight: '22px',
'&:hover': {
background: 'transparent',
},
'&:active': {
background: 'transparent',
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
},
});
type Props = {|
sections: Array<{
title: string,
items: Array<{
title: string,
subtitle: string,
onClick?: Function,
icon?: React.Element<*>,
}>,
}>,
onDismiss: Function,
|};
export default class DevicesList extends PureComponent<Props> {
render() {
return (
<Popover onDismiss={this.props.onDismiss}>
{this.props.sections.map(section => {
if (section.items.length > 0) {
return (
<Section key={section.title}>
<Heading>{section.title}</Heading>
{section.items.map(item => (
<PopoverItem key={item.title}>
<ItemImage>{item.icon}</ItemImage>
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemSubtitle>{item.subtitle}</ItemSubtitle>
</ItemContent>
{item.onClick && (
<Action onClick={item.onClick} compact={true}>
Run
</Action>
)}
</PopoverItem>
))}
</Section>
);
} else {
return null;
}
})}
</Popover>
);
}
}

28
src/chrome/ErrorBar.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 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 {styled, colors} from 'sonar';
const ErrorBarContainer = styled.view({
backgroundColor: colors.cherry,
bottom: 0,
color: '#fff',
left: 0,
lineHeight: '26px',
position: 'absolute',
right: 0,
textAlign: 'center',
zIndex: 2,
});
export default function ErrorBar(props: {|text: ?string|}) {
if (props.text == null) {
return null;
} else {
return <ErrorBarContainer>{props.text}</ErrorBarContainer>;
}
}

305
src/chrome/MainSidebar.js Normal file
View File

@@ -0,0 +1,305 @@
/**
* 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 type {SonarBasePlugin} from '../plugin.js';
import type {Client} from '../server.js';
import {
Component,
Sidebar,
FlexBox,
ClickableList,
ClickableListItem,
colors,
brandColors,
Text,
Glyph,
} from 'sonar';
import {devicePlugins} from '../device-plugins/index.js';
import type BaseDevice from '../devices/BaseDevice.js';
import PropTypes from 'prop-types';
import plugins from '../plugins/index.js';
const CustomClickableListItem = ClickableListItem.extends({
paddingLeft: 10,
display: 'flex',
alignItems: 'center',
marginBottom: 2,
});
const SidebarHeader = FlexBox.extends({
display: 'block',
alignItems: 'center',
padding: 3,
color: colors.macOSSidebarSectionTitle,
fontSize: 11,
fontWeight: 500,
marginLeft: 7,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
flexShrink: 0,
});
const PluginShape = FlexBox.extends(
{
marginRight: 5,
backgroundColor: props => props.backgroundColor,
borderRadius: 3,
flexShrink: 0,
width: 18,
height: 18,
justifyContent: 'center',
alignItems: 'center',
},
{
ignoreAttributes: ['backgroundColor'],
},
);
const PluginName = Text.extends({
minWidth: 0,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
function PluginIcon({
backgroundColor,
name,
color,
}: {
backgroundColor: string,
name: string,
color: string,
}) {
return (
<PluginShape backgroundColor={backgroundColor}>
<Glyph size={12} name={name} color={color} />
</PluginShape>
);
}
class PluginSidebarListItem extends Component<{
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
appKey: string,
appName?: string,
isActive: boolean,
Plugin: Class<SonarBasePlugin<>>,
windowFocused: boolean,
}> {
onClick = () => {
const {props} = this;
props.onActivatePlugin(props.appKey, props.Plugin.id);
};
render() {
const {isActive, Plugin, windowFocused, appKey, appName} = this.props;
let iconColor;
if (appName != null) {
iconColor = brandColors[appName];
}
if (iconColor == null) {
const pluginColors = [
colors.seaFoam,
colors.teal,
colors.lime,
colors.lemon,
colors.orange,
colors.tomato,
colors.cherry,
colors.pink,
colors.grape,
];
iconColor = pluginColors[parseInt(appKey, 36) % pluginColors.length];
}
return (
<CustomClickableListItem
active={isActive}
onClick={this.onClick}
windowFocused={windowFocused}>
<PluginIcon
name={Plugin.icon}
backgroundColor={
isActive
? windowFocused
? colors.white
: colors.macOSSidebarSectionTitle
: iconColor
}
color={
isActive
? windowFocused
? colors.macOSTitleBarIconSelected
: colors.white
: colors.white
}
/>
<PluginName>{Plugin.title}</PluginName>
</CustomClickableListItem>
);
}
}
function PluginSidebarList(props: {|
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
appKey: string,
appName?: string,
enabledPlugins: Array<Class<SonarBasePlugin<>>>,
windowFocused: boolean,
|}) {
if (props.enabledPlugins.length === 0) {
return <Text>No available plugins for this device</Text>;
}
return (
<ClickableList>
{props.enabledPlugins.map(Plugin => {
const isActive =
props.activeAppKey === props.appKey &&
props.activePluginKey === Plugin.id;
return (
<PluginSidebarListItem
key={Plugin.id}
activePluginKey={props.activePluginKey}
activeAppKey={props.activeAppKey}
onActivatePlugin={props.onActivatePlugin}
appKey={props.appKey}
appName={props.appName}
isActive={isActive}
Plugin={Plugin}
windowFocused={props.windowFocused}
/>
);
})}
</ClickableList>
);
}
function AppSidebarInfo(props: {|
client: Client,
appKey: string,
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
windowFocused: boolean,
|}): any {
const {appKey, client, windowFocused} = props;
let enabledPlugins = [];
for (const Plugin of plugins) {
if (client.supportsPlugin(Plugin)) {
enabledPlugins.push(Plugin);
}
}
enabledPlugins = enabledPlugins.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
return [
<SidebarHeader key={client.query.app}>{`${client.query.app} (${
client.query.os
}) - ${client.query.device}`}</SidebarHeader>,
<PluginSidebarList
key={`list-${appKey}`}
activePluginKey={props.activePluginKey}
activeAppKey={props.activeAppKey}
onActivatePlugin={props.onActivatePlugin}
appKey={appKey}
appName={client.query.app}
enabledPlugins={enabledPlugins}
windowFocused={windowFocused}
/>,
];
}
type MainSidebarProps = {|
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
devices: Array<BaseDevice>,
server: Server,
|};
export default class MainSidebar extends Component<MainSidebarProps> {
static contextTypes = {
windowIsFocused: PropTypes.bool,
};
render() {
const connections = Array.from(this.props.server.connections.values()).sort(
(a, b) => {
return (a.client.query.app || '').localeCompare(b.client.query.app);
},
);
const sidebarContent = connections.map(conn => {
const {client} = conn;
return (
<AppSidebarInfo
key={`app=${client.id}`}
client={client}
appKey={client.id}
activePluginKey={this.props.activePluginKey}
activeAppKey={this.props.activeAppKey}
onActivatePlugin={this.props.onActivatePlugin}
windowFocused={this.context.windowIsFocused}
/>
);
});
let {devices} = this.props;
devices = devices.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
for (const device of devices) {
let enabledPlugins = [];
for (const DevicePlugin of devicePlugins) {
if (device.supportsPlugin(DevicePlugin)) {
enabledPlugins.push(DevicePlugin);
}
}
enabledPlugins = enabledPlugins.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
sidebarContent.unshift([
<SidebarHeader key={device.title}>{device.title}</SidebarHeader>,
<PluginSidebarList
key={`list-${device.serial}`}
activePluginKey={this.props.activePluginKey}
activeAppKey={this.props.activeAppKey}
onActivatePlugin={this.props.onActivatePlugin}
appKey={device.serial}
enabledPlugins={enabledPlugins}
windowFocused={this.context.windowIsFocused}
/>,
]);
}
return (
<Sidebar
position="left"
width={200}
backgroundColor={
this.context.windowIsFocused ? 'transparent' : '#f6f6f6'
}>
{sidebarContent}
</Sidebar>
);
}
}

404
src/chrome/PluginManager.js Normal file
View File

@@ -0,0 +1,404 @@
/**
* 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 {
PureComponent,
Button,
FlexColumn,
FlexBox,
Text,
LoadingIndicator,
ButtonGroup,
colors,
Glyph,
FlexRow,
styled,
Searchable,
} from 'sonar';
const {spawn} = require('child_process');
const path = require('path');
const {app, shell} = require('electron').remote;
const SONAR_PLUGIN_PATH = path.join(app.getPath('home'), '.sonar');
const DYNAMIC_PLUGINS = JSON.parse(window.process.env.PLUGINS || '[]');
type NPMModule = {
name: string,
version: string,
description?: string,
error?: Object,
};
type Status =
| 'installed'
| 'outdated'
| 'install'
| 'remove'
| 'update'
| 'uninstalled'
| 'uptodate';
type PluginT = {
name: string,
version?: string,
description?: string,
status: Status,
managed?: boolean,
entry?: string,
rootDir?: string,
};
type Props = {
searchTerm: string,
};
type State = {
plugins: {
[name: string]: PluginT,
},
restartRequired: boolean,
searchCompleted: boolean,
};
const Container = FlexBox.extends({
width: '100%',
flexGrow: 1,
background: colors.light02,
overflowY: 'scroll',
});
const Title = Text.extends({
fontWeight: 500,
});
const Plugin = FlexColumn.extends({
backgroundColor: colors.white,
borderRadius: 4,
padding: 15,
margin: '0 15px 25px',
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
});
const SectionTitle = styled.text({
fontWeight: 'bold',
fontSize: 24,
margin: 15,
marginLeft: 20,
});
const Loading = FlexBox.extends({
padding: 50,
alignItems: 'center',
justifyContent: 'center',
});
const RestartRequired = FlexBox.extends({
textAlign: 'center',
justifyContent: 'center',
fontWeight: 500,
color: colors.white,
padding: 12,
backgroundColor: colors.green,
cursor: 'pointer',
});
const TitleRow = FlexRow.extends({
alignItems: 'center',
marginBottom: 10,
fontSize: '1.1em',
});
const Description = FlexRow.extends({
marginBottom: 15,
lineHeight: '130%',
});
const PluginGlyph = Glyph.extends({
marginRight: 5,
});
const PluginLoading = LoadingIndicator.extends({
marginLeft: 5,
marginTop: 5,
});
const getLatestVersion = (name: string): Promise<NPMModule> => {
return fetch(`http://registry.npmjs.org/${name}/latest`).then(res =>
res.json(),
);
};
const getPluginList = (): Promise<Array<NPMModule>> => {
return fetch(
'http://registry.npmjs.org/-/v1/search?text=keywords:sonar&size=250',
)
.then(res => res.json())
.then(res => res.objects.map(o => o.package));
};
const sortByName = (a: PluginT, b: PluginT): 1 | -1 =>
a.name > b.name ? 1 : -1;
const INSTALLED = ['installed', 'outdated', 'uptodate'];
class PluginItem extends PureComponent<
{
plugin: PluginT,
onChangeState: (action: Status) => void,
},
{
working: boolean,
},
> {
state = {
working: false,
};
npmAction = (action: Status) => {
const {name, status: initialStatus} = this.props.plugin;
this.setState({working: true});
const npm = spawn('npm', [action, name], {
cwd: SONAR_PLUGIN_PATH,
});
npm.stderr.on('data', e => {
console.error(e.toString());
});
npm.on('close', code => {
this.setState({working: false});
const newStatus = action === 'remove' ? 'uninstalled' : 'uptodate';
this.props.onChangeState(code !== 0 ? initialStatus : newStatus);
});
};
render() {
const {
entry,
status,
version,
description,
managed,
name,
rootDir,
} = this.props.plugin;
return (
<Plugin>
<TitleRow>
<PluginGlyph
name="apps"
size={24}
variant="outline"
color={colors.light30}
/>
<Title>{name}</Title>
&nbsp;
<Text code={true}>{version}</Text>
</TitleRow>
{description && <Description>{description}</Description>}
<FlexRow>
{managed ? (
<Text size="0.9em" color={colors.light30}>
This plugin is not managed by Sonar, but loaded from{' '}
<Text size="1em" code={true}>
{rootDir}
</Text>
</Text>
) : (
<ButtonGroup>
{status === 'outdated' && (
<Button
disabled={this.state.working}
onClick={() => this.npmAction('update')}>
Update
</Button>
)}
{INSTALLED.includes(status) ? (
<Button
disabled={this.state.working}
title={
managed === true && entry != null
? `This plugin is dynamically loaded from ${entry}`
: undefined
}
onClick={() => this.npmAction('remove')}>
Remove
</Button>
) : (
<Button
disabled={this.state.working}
onClick={() => this.npmAction('install')}>
Install
</Button>
)}
<Button
onClick={() =>
shell.openExternal(`https://www.npmjs.com/package/${name}`)
}>
Info
</Button>
</ButtonGroup>
)}
{this.state.working && <PluginLoading size={18} />}
</FlexRow>
</Plugin>
);
}
}
class PluginManager extends PureComponent<Props, State> {
state = {
plugins: DYNAMIC_PLUGINS.reduce((acc, plugin) => {
acc[plugin.name] = {
...plugin,
managed: !(plugin.entry, '').startsWith(SONAR_PLUGIN_PATH),
status: 'installed',
};
return acc;
}, {}),
restartRequired: false,
searchCompleted: false,
};
componentDidMount() {
Promise.all(
Object.keys(this.state.plugins)
.filter(name => this.state.plugins[name].managed)
.map(getLatestVersion),
).then((res: Array<NPMModule>) => {
const updates = {};
res.forEach(plugin => {
if (
plugin.error == null &&
this.state.plugins[plugin.name].version !== plugin.version
) {
updates[plugin.name] = {
...plugin,
...this.state.plugins[plugin.name],
status: 'outdated',
};
}
});
this.setState({
plugins: {
...this.state.plugins,
...updates,
},
});
});
getPluginList().then(pluginList => {
const plugins = {...this.state.plugins};
pluginList.forEach(plugin => {
if (plugins[plugin.name] != null) {
plugins[plugin.name] = {
...plugin,
...plugins[plugin.name],
status:
plugin.version === plugins[plugin.name].version
? 'uptodate'
: 'outdated',
};
} else {
plugins[plugin.name] = {
...plugin,
status: 'uninstalled',
};
}
});
this.setState({
plugins,
searchCompleted: true,
});
});
}
onChangePluginState = (name: string, status: Status) => {
this.setState({
plugins: {
...this.state.plugins,
[name]: {
...this.state.plugins[name],
status,
},
},
restartRequired: true,
});
};
relaunch() {
app.relaunch();
app.exit(0);
}
render() {
// $FlowFixMe
const plugins: Array<PluginT> = Object.values(this.state.plugins);
const availablePlugins = plugins.filter(
({status}) => !INSTALLED.includes(status),
);
return (
<Container>
<FlexColumn fill={true}>
{this.state.restartRequired && (
<RestartRequired onClick={this.relaunch}>
<Glyph name="arrows-circle" size={12} color={colors.white} />
&nbsp; Restart Required: Click to Restart
</RestartRequired>
)}
<SectionTitle>Installed Plugins</SectionTitle>
{plugins
.filter(
({status, name}) =>
INSTALLED.includes(status) &&
name.indexOf(this.props.searchTerm) > -1,
)
.sort(sortByName)
.map((plugin: PluginT) => (
<PluginItem
plugin={plugin}
key={plugin.name}
onChangeState={action =>
this.onChangePluginState(plugin.name, action)
}
/>
))}
<SectionTitle>Available Plugins</SectionTitle>
{availablePlugins
.filter(({name}) => name.indexOf(this.props.searchTerm) > -1)
.sort(sortByName)
.map((plugin: PluginT) => (
<PluginItem
plugin={plugin}
key={plugin.name}
onChangeState={action =>
this.onChangePluginState(plugin.name, action)
}
/>
))}
{!this.state.searchCompleted && (
<Loading>
<LoadingIndicator size={32} />
</Loading>
)}
</FlexColumn>
</Container>
);
}
}
const SearchablePluginManager = Searchable(PluginManager);
export default class extends PureComponent<{}> {
render() {
return (
<FlexColumn fill={true}>
<SearchablePluginManager />
</FlexColumn>
);
}
}

196
src/chrome/Popover.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* 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 {
PureComponent,
FlexColumn,
FlexRow,
FlexBox,
Text,
Button,
styled,
colors,
} from 'sonar';
const Anchor = styled.image({
zIndex: 6,
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translate(-50%, calc(100% + 2px))',
});
const PopoverContainer = FlexColumn.extends({
backgroundColor: colors.white,
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.3)',
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
position: 'absolute',
zIndex: 5,
minWidth: 240,
bottom: 0,
marginTop: 15,
left: '50%',
transform: 'translate(-50%, calc(100% + 15px))',
overflow: 'hidden',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
height: 13,
top: -13,
width: 26,
backgroundColor: colors.white,
},
});
const Heading = Text.extends({
display: 'block',
backgroundColor: colors.white,
color: colors.light30,
fontSize: 11,
fontWeight: 600,
lineHeight: '21px',
padding: '4px 8px 0',
});
const PopoverItem = FlexRow.extends({
alignItems: 'center',
borderBottom: `1px solid ${colors.light05}`,
height: 50,
'&:last-child': {
borderBottom: 'none',
},
});
const ItemTitle = Text.extends({
display: 'block',
fontSize: 14,
fontWeight: 400,
lineHeight: '120%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
marginBottom: 1,
});
const ItemSubtitle = Text.extends({
display: 'block',
fontWeight: 400,
fontSize: 11,
color: colors.light30,
lineHeight: '14px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
const ItemImage = FlexBox.extends({
alignItems: 'center',
justifyContent: 'center',
width: 40,
flexShrink: 0,
});
const ItemContent = styled.view({
minWidth: 0,
paddingRight: 5,
flexGrow: 1,
});
const Section = styled.view({
borderBottom: `1px solid ${colors.light05}`,
'&:last-child': {
borderBottom: 'none',
},
});
const Action = Button.extends({
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
background: 'transparent',
color: colors.macOSTitleBarIconSelected,
marginRight: 8,
lineHeight: '22px',
'&:hover': {
background: 'transparent',
},
'&:active': {
background: 'transparent',
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
},
});
type Props = {|
sections: Array<{
title: string,
items: Array<{
title: string,
subtitle: string,
onClick?: Function,
icon?: React.Element<*>,
}>,
}>,
onDismiss: Function,
|};
export default class Popover extends PureComponent<Props> {
_ref: ?Element;
componentDidMount() {
window.document.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
window.document.addEventListener('click', this.handleClick);
}
handleClick = (e: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (this._ref && !this._ref.contains(e.target)) {
this.props.onDismiss();
}
};
_setRef = (ref: ?Element) => {
this._ref = ref;
};
render() {
return [
<Anchor src="./anchor.svg" key="anchor" />,
<PopoverContainer innerRef={this._setRef} key="popup">
{this.props.sections.map(section => {
if (section.items.length > 0) {
return (
<Section key={section.title}>
<Heading>{section.title}</Heading>
{section.items.map(item => (
<PopoverItem key={item.title}>
<ItemImage>{item.icon}</ItemImage>
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemSubtitle>{item.subtitle}</ItemSubtitle>
</ItemContent>
{item.onClick && (
<Action onClick={item.onClick} compact={true}>
Run
</Action>
)}
</PopoverItem>
))}
</Section>
);
} else {
return null;
}
})}
</PopoverContainer>,
];
}
}

157
src/chrome/SonarTitleBar.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* 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 {
colors,
Button,
ButtonGroup,
FlexRow,
FlexBox,
Component,
Spacer,
Glyph,
GK,
} from 'sonar';
import {
loadsDynamicPlugins,
dynamicPluginPath,
} from '../utils/dynamicPluginLoading.js';
import {connect} from 'react-redux';
import {
toggleBugDialogVisible,
toggleLeftSidebarVisible,
toggleRightSidebarVisible,
togglePluginManagerVisible,
} from '../reducers/application.js';
import DevicesButton from './DevicesButton.js';
import Version from './Version.js';
import AutoUpdateVersion from './AutoUpdateVersion.js';
import config from '../fb-stubs/config.js';
const TitleBar = FlexRow.extends(
{
background: props =>
props.focused
? `linear-gradient(to bottom, ${
colors.macOSTitleBarBackgroundTop
} 0%, ${colors.macOSTitleBarBackgroundBottom} 100%)`
: colors.macOSTitleBarBackgroundBlur,
borderBottom: props =>
`1px solid ${
props.focused
? colors.macOSTitleBarBorder
: colors.macOSTitleBarBorderBlur
}`,
height: 38,
flexShrink: 0,
width: '100%',
alignItems: 'center',
paddingLeft: 80,
paddingRight: 10,
justifyContent: 'space-between',
// $FlowFixMe
WebkitAppRegion: 'drag',
},
{
ignoreAttributes: ['focused'],
},
);
const Icon = FlexBox.extends({
marginRight: 3,
});
type Props = {|
windowIsFocused: boolean,
leftSidebarVisible: boolean,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
pluginManagerVisible: boolean,
toggleBugDialogVisible: (visible?: boolean) => void,
toggleLeftSidebarVisible: (visible?: boolean) => void,
toggleRightSidebarVisible: (visible?: boolean) => void,
togglePluginManagerVisible: (visible?: boolean) => void,
|};
class SonarTitleBar extends Component<Props> {
render() {
return (
<TitleBar focused={this.props.windowIsFocused} className="toolbar">
<DevicesButton />
<Spacer />
{loadsDynamicPlugins() && (
<Icon
title={`Plugins are loaded dynamically from ${dynamicPluginPath() ||
''}`}>
<Glyph color={colors.light30} name="flash-default" size={16} />
</Icon>
)}
{process.platform === 'darwin' ? <AutoUpdateVersion /> : <Version />}
{config.bugReportButtonVisible && (
<Button
compact={true}
onClick={() => this.props.toggleBugDialogVisible()}
title="Report Bug"
icon="bug"
/>
)}
{GK.get('sonar_dynamic_plugins') && (
<Button
compact={true}
onClick={() => this.props.toggleBugDialogVisible()}
selected={this.props.pluginManagerVisible}
title="Plugin Manager"
icon="apps"
/>
)}
<ButtonGroup>
<Button
compact={true}
selected={this.props.leftSidebarVisible}
onClick={() => this.props.toggleLeftSidebarVisible()}
icon="icons/sidebar_left.svg"
iconSize={20}
title="Toggle Plugins"
/>
<Button
compact={true}
selected={this.props.rightSidebarVisible}
onClick={() => this.props.toggleRightSidebarVisible()}
icon="icons/sidebar_right.svg"
iconSize={20}
title="Toggle Details"
disabled={!this.props.rightSidebarAvailable}
/>
</ButtonGroup>
</TitleBar>
);
}
}
export default connect(
({
application: {
windowIsFocused,
leftSidebarVisible,
rightSidebarVisible,
rightSidebarAvailable,
pluginManagerVisible,
},
}) => ({
windowIsFocused,
leftSidebarVisible,
rightSidebarVisible,
rightSidebarAvailable,
pluginManagerVisible,
}),
{
toggleBugDialogVisible,
toggleLeftSidebarVisible,
toggleRightSidebarVisible,
togglePluginManagerVisible,
},
)(SonarTitleBar);

92
src/chrome/Version.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* 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 {Component, FlexRow, colors, LoadingIndicator} from 'sonar';
import {version} from '../../package.json';
import {remote} from 'electron';
import * as path from 'path';
import {userInfo} from 'os';
import * as fs from 'fs';
const VERSION_URL =
'https://interngraph.intern.facebook.com/sonar/version?app=543626909362475&token=AeNRaexWgPooanyxG0';
type VersionState = {
status: 'unknown' | 'outdated' | 'latest' | 'updated' | 'errored',
};
export default class Version extends Component<{}, VersionState> {
state = {
status: 'unknown',
};
static Container = FlexRow.extends({
alignItems: 'center',
marginRight: 7,
marginLeft: 7,
marginTop: -1,
color: colors.light50,
});
static UpdatedContainer = FlexRow.extends({
backgroundColor: colors.blackAlpha10,
borderRadius: '999em',
padding: '2px 6px',
marginLeft: 7,
color: colors.light80,
'&:hover': {
backgroundColor: colors.blackAlpha15,
},
});
componentDidMount() {
this.watchUpdates();
this.checkVersion().catch(() => {
this.setState({status: 'errored'});
});
}
async watchUpdates() {
fs.watch(path.join(userInfo().homedir, '.sonar-desktop'), () =>
this.setState({status: 'updated'}),
);
}
async checkVersion() {
const req = await fetch(VERSION_URL);
const json = await req.json();
this.setState({status: json.version === version ? 'latest' : 'outdated'});
}
onClick = () => {
// mark the app to relaunch once killed
remote.app.relaunch();
// close the current window
remote.getCurrentWindow().destroy();
};
render() {
const {status} = this.state;
return (
<Version.Container>
{version}
{status === 'outdated' && [
<Version.Container key="loading">
<LoadingIndicator size={16} />
</Version.Container>,
'Updating...',
]}
{status === 'updated' && (
<Version.UpdatedContainer onClick={this.onClick}>
Restart Sonar
</Version.UpdatedContainer>
)}
</Version.Container>
);
}
}

177
src/chrome/WelcomeScreen.js Normal file
View File

@@ -0,0 +1,177 @@
/**
* 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 {
styled,
PureComponent,
FlexColumn,
FlexRow,
Text,
Glyph,
colors,
brandColors,
} from 'sonar';
import {isProduction} from '../utils/dynamicPluginLoading';
import {shell, remote} from 'electron';
const Container = FlexColumn.extends({
height: '100%',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
backgroundImage: 'url(./pattern.gif)',
});
const Welcome = FlexColumn.extends(
{
width: 460,
background: colors.white,
borderRadius: 10,
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
overflow: 'hidden',
opacity: props => (props.isMounted ? 1 : 0),
transform: props => `translateY(${props.isMounted ? 0 : 20}px)`,
transition: '0.6s all ease-out',
},
{
ignoreAttributes: ['isMounted'],
},
);
const Title = Text.extends({
fontSize: 24,
fontWeight: 300,
textAlign: 'center',
color: colors.light50,
marginBottom: 16,
});
const Version = Text.extends({
textAlign: 'center',
fontSize: 11,
fontWeight: 300,
color: colors.light30,
marginBottom: 60,
});
const Item = FlexRow.extends({
padding: 10,
cursor: 'pointer',
alignItems: 'center',
borderTop: `1px solid ${colors.light10}`,
'&:hover, &:focus, &:active': {
backgroundColor: colors.light02,
textDecoration: 'none',
},
});
const ItemTitle = Text.extends({
color: colors.light50,
fontSize: 15,
});
const ItemSubTitle = Text.extends({
color: colors.light30,
fontSize: 11,
marginTop: 2,
});
const Icon = Glyph.extends({
marginRight: 11,
marginLeft: 6,
});
const Logo = styled.image({
width: 128,
height: 128,
alignSelf: 'center',
marginTop: 50,
marginBottom: 20,
});
type Props = {};
type State = {
isMounted: boolean,
};
export default class WelcomeScreen extends PureComponent<Props, State> {
state = {
isMounted: false,
};
componentDidMount() {
setTimeout(
() =>
this.setState({
isMounted: true,
}),
100,
);
}
render() {
return (
<Container>
<Welcome isMounted={this.state.isMounted}>
<Logo src="./icon.png" />
<Title>Welcome to Sonar</Title>
<Version>
{isProduction()
? `Version ${remote.app.getVersion()}`
: 'Development Mode'}
</Version>
<Item
onClick={() =>
shell.openExternal('https://fbsonar.com/docs/understand.html')
}>
<Icon size={20} name="rocket" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Using Sonar</ItemTitle>
<ItemSubTitle>
Learn how Sonar can help you debugging your App
</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal('https://fbsonar.com/docs/create-plugin.html')
}>
<Icon size={20} name="magic-wand" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Create your own plugin</ItemTitle>
<ItemSubTitle>Get started with these pointers</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal(
'https://fbsonar.com/docs/getting-started.html',
)
}>
<Icon size={20} name="tools" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Add Sonar support to your app</ItemTitle>
<ItemSubTitle>Get started with these pointers</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal('https://github.com/facebook/Sonar/issues')
}>
<Icon size={20} name="posts" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Contributing and Feedback</ItemTitle>
<ItemSubTitle>
Report issues and help us improving Sonar
</ItemSubTitle>
</FlexColumn>
</Item>
</Welcome>
</Container>
);
}
}