Move app/src (mostly) to flipper-ui-core/src

Summary:
This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts.

* But at least flipper-ui-core is Electron free :)
* Killed all cross module imports as well, as they where now even more in the way
* Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that)
* Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those

Follow up work:
* make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here
* remove node deps (aigoncharov)
* figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module
* clean up deps

Reviewed By: aigoncharov

Differential Revision: D32427722

fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
Michel Weststrate
2021-11-16 05:25:40 -08:00
committed by Facebook GitHub Bot
parent 54b7ce9308
commit 7e50c0466a
293 changed files with 483 additions and 497 deletions

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
// These paths lead to circular import issues in Flipper app and are forbidden
paths: ['flipper'],
patterns: ['flipper-ui-core/src/*'],
},
],
},
};

View File

@@ -0,0 +1,706 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
// We're using `deviceSync` here on purpose which is triggering a lot of warnings.
/* eslint-disable node/no-sync */
import {PluginDefinition} from './plugin';
import BaseDevice from './devices/BaseDevice';
import {Logger} from 'flipper-common';
import {Store} from './reducers/index';
import {performance} from 'perf_hooks';
import {reportPluginFailures} from 'flipper-common';
import {default as isProduction} from './utils/isProduction';
import {EventEmitter} from 'events';
import invariant from 'invariant';
import {getPluginKey} from './utils/pluginKey';
import {defaultEnabledBackgroundPlugins} from './utils/pluginUtils';
import {processMessagesLater} from './utils/messageQueue';
import {emitBytesReceived} from './dispatcher/tracking';
import {debounce} from 'lodash';
import {batch} from 'react-redux';
import {
timeout,
ClientQuery,
ClientResponseType,
ClientErrorType,
} from 'flipper-common';
import {
createState,
_SandyPluginInstance,
getFlipperLib,
_SandyPluginDefinition,
} from 'flipper-plugin';
import {freeze} from 'immer';
import {message} from 'antd';
import {
isFlipperMessageDebuggingEnabled,
registerFlipperDebugMessage,
} from './chrome/FlipperMessages';
type Plugins = Set<string>;
type PluginsArr = Array<string>;
export type ClientExport = {
id: string;
query: ClientQuery;
};
export type Params = {
api: string;
method: string;
params?: Object;
};
export type RequestMetadata = {
method: string;
id: number;
params: Params | undefined;
};
const handleError = (
store: Store,
device: BaseDevice,
error: ClientErrorType,
) => {
if (store.getState().settingsState.suppressPluginErrors) {
return;
}
const crashReporterPlugin = device.sandyPluginStates.get('CrashReporter');
if (!crashReporterPlugin) {
return;
}
if (!crashReporterPlugin.instanceApi.reportCrash) {
console.error('CrashReporterPlugin persistedStateReducer broken');
return;
}
const isCrashReport: boolean = Boolean(error.name || error.message);
const payload = isCrashReport
? {
name: error.name,
reason: error.message,
callstack: error.stacktrace,
}
: {
name: 'Plugin Error',
reason: JSON.stringify(error),
};
crashReporterPlugin.instanceApi.reportCrash(payload);
};
export interface ClientConnection {
send(data: any): void;
sendExpectResponse(data: any): Promise<ClientResponseType>;
}
export default class Client extends EventEmitter {
connected = createState(false);
id: string;
query: ClientQuery;
sdkVersion: number;
messageIdCounter: number;
plugins: Plugins; // TODO: turn into atom, and remove eventEmitter
backgroundPlugins: Plugins;
connection: ClientConnection | null | undefined;
store: Store;
activePlugins: Set<string>;
device: BaseDevice;
logger: Logger;
broadcastCallbacks: Map<string, Map<string, Set<Function>>>;
messageBuffer: Record<
string /*pluginKey*/,
{
plugin: _SandyPluginInstance;
messages: Params[];
}
> = {};
sandyPluginStates = new Map<string /*pluginID*/, _SandyPluginInstance>();
constructor(
id: string,
query: ClientQuery,
conn: ClientConnection | null | undefined,
logger: Logger,
store: Store,
plugins: Plugins | null | undefined,
device: BaseDevice,
) {
super();
this.connected.set(!!conn);
this.plugins = plugins ? plugins : new Set();
this.backgroundPlugins = new Set();
this.connection = conn;
this.id = id;
this.query = query;
this.sdkVersion = query.sdk_version || 0;
this.messageIdCounter = 0;
this.logger = logger;
this.store = store;
this.broadcastCallbacks = new Map();
this.activePlugins = new Set();
this.device = device;
}
supportsPlugin(pluginId: string): boolean {
return this.plugins.has(pluginId);
}
isBackgroundPlugin(pluginId: string) {
return this.backgroundPlugins.has(pluginId);
}
isEnabledPlugin(pluginId: string) {
return this.store
.getState()
.connections.enabledPlugins[this.query.app]?.includes(pluginId);
}
shouldConnectAsBackgroundPlugin(pluginId: string) {
return (
defaultEnabledBackgroundPlugins.includes(pluginId) ||
this.isEnabledPlugin(pluginId)
);
}
async init() {
await this.loadPlugins();
// this starts all sandy enabled plugins
this.plugins.forEach((pluginId) =>
this.startPluginIfNeeded(this.getPlugin(pluginId)),
);
this.backgroundPlugins = new Set(await this.getBackgroundPlugins());
this.backgroundPlugins.forEach((plugin) => {
if (this.shouldConnectAsBackgroundPlugin(plugin)) {
this.initPlugin(plugin);
}
});
this.emit('plugins-change');
}
initFromImport(initialStates: Record<string, Record<string, any>>): this {
this.plugins.forEach((pluginId) => {
const plugin = this.getPlugin(pluginId);
if (plugin) {
this.loadPlugin(plugin, initialStates[pluginId]);
}
});
this.emit('plugins-change');
return this;
}
// get the supported plugins
async loadPlugins(): Promise<Plugins> {
const {plugins} = await timeout(
30 * 1000,
this.rawCall<{plugins: Plugins}>('getPlugins', false),
'Fetch plugin timeout for ' + this.id,
);
this.plugins = new Set(plugins);
return plugins;
}
loadPlugin(
plugin: _SandyPluginDefinition,
initialState?: Record<string, any>,
) {
try {
this.sandyPluginStates.set(
plugin.id,
new _SandyPluginInstance(
getFlipperLib(),
plugin,
this,
getPluginKey(this.id, {serial: this.query.device_id}, plugin.id),
initialState,
),
);
} catch (e) {
console.error(`Failed to start plugin '${plugin.id}': `, e);
}
}
startPluginIfNeeded(
plugin: PluginDefinition | undefined,
isEnabled = plugin ? this.isEnabledPlugin(plugin.id) : false,
) {
// start a plugin on start if it is a SandyPlugin, which is enabled, and doesn't have persisted state yet
if (
plugin &&
(isEnabled || defaultEnabledBackgroundPlugins.includes(plugin.id)) &&
!this.sandyPluginStates.has(plugin.id)
) {
this.loadPlugin(plugin);
}
}
stopPluginIfNeeded(pluginId: string, force = false) {
if (defaultEnabledBackgroundPlugins.includes(pluginId) && !force) {
return;
}
const pluginKey = getPluginKey(
this.id,
{serial: this.query.device_id},
pluginId,
);
delete this.messageBuffer[pluginKey];
const instance = this.sandyPluginStates.get(pluginId);
if (instance) {
instance.destroy();
this.sandyPluginStates.delete(pluginId);
}
}
// connection lost, but Client might live on
disconnect() {
this.sandyPluginStates.forEach((instance) => {
instance.disconnect();
});
this.emit('close');
this.connected.set(false);
}
// clean up this client
destroy() {
this.disconnect();
this.plugins.forEach((pluginId) => this.stopPluginIfNeeded(pluginId, true));
}
// gets a plugin by pluginId
getPlugin(pluginId: string): PluginDefinition | undefined {
const plugins = this.store.getState().plugins;
return (
plugins.clientPlugins.get(pluginId) || plugins.devicePlugins.get(pluginId)
);
}
// get the supported background plugins
async getBackgroundPlugins(): Promise<PluginsArr> {
if (this.sdkVersion < 4) {
return [];
}
const data = await timeout(
30 * 1000,
this.rawCall<{plugins: PluginsArr}>('getBackgroundPlugins', false),
'Fetch background plugins timeout for ' + this.id,
);
return data.plugins;
}
// get the plugins, and update the UI
async refreshPlugins() {
const oldBackgroundPlugins = this.backgroundPlugins;
await this.loadPlugins();
this.plugins.forEach((pluginId) =>
this.startPluginIfNeeded(this.getPlugin(pluginId)),
);
const newBackgroundPlugins = await this.getBackgroundPlugins();
this.backgroundPlugins = new Set(newBackgroundPlugins);
// diff the background plugin list, disconnect old, connect new ones
oldBackgroundPlugins.forEach((plugin) => {
if (
!this.backgroundPlugins.has(plugin) &&
this.store
.getState()
.connections.enabledPlugins[this.query.app]?.includes(plugin)
) {
this.deinitPlugin(plugin);
}
});
newBackgroundPlugins.forEach((plugin) => {
if (
!oldBackgroundPlugins.has(plugin) &&
this.shouldConnectAsBackgroundPlugin(plugin)
) {
this.initPlugin(plugin);
}
});
this.emit('plugins-change');
}
onMessage(msg: string) {
if (typeof msg !== 'string') {
return;
}
batch(() => {
let rawData;
try {
rawData = freeze(JSON.parse(msg), true);
} catch (err) {
console.error(`Invalid JSON: ${msg}`, 'clientMessage');
return;
}
const data: {
id?: number;
method?: string;
params?: Params;
success?: Object;
error?: ClientErrorType;
} = rawData;
const {id, method} = data;
if (isFlipperMessageDebuggingEnabled()) {
registerFlipperDebugMessage({
device: this.device?.displayTitle(),
app: this.query.app,
flipperInternalMethod: method,
plugin: data.params?.api,
pluginMethod: data.params?.method,
payload: data.params?.params,
direction: 'toFlipper:message',
});
}
if (id == null) {
const {error} = data;
if (error != null) {
console.error(
`Error received from device ${
method ? `when calling ${method}` : ''
}: ${error.message} + \nDevice Stack Trace: ${error.stacktrace}`,
'deviceError',
);
handleError(this.store, this.device, error);
} else if (method === 'refreshPlugins') {
this.refreshPlugins();
} else if (method === 'execute') {
invariant(data.params, 'expected params');
const params: Params = data.params;
const bytes = msg.length * 2; // string lengths are measured in UTF-16 units (not characters), so 2 bytes per char
emitBytesReceived(params.api, bytes);
if (bytes > 5 * 1024 * 1024) {
console.warn(
`Plugin '${params.api}' received excessively large message for '${
params.method
}': ${Math.round(bytes / 1024)}kB`,
);
}
const persistingPlugin: PluginDefinition | undefined =
this.store.getState().plugins.clientPlugins.get(params.api) ||
this.store.getState().plugins.devicePlugins.get(params.api);
let handled = false; // This is just for analysis
if (
persistingPlugin &&
((persistingPlugin as any).persistedStateReducer ||
// only send messages to enabled sandy plugins
this.sandyPluginStates.has(params.api))
) {
handled = true;
const pluginKey = getPluginKey(
this.id,
{serial: this.query.device_id},
params.api,
);
if (!this.messageBuffer[pluginKey]) {
this.messageBuffer[pluginKey] = {
plugin: (this.sandyPluginStates.get(params.api) ??
persistingPlugin) as any,
messages: [params],
};
} else {
this.messageBuffer[pluginKey].messages.push(params);
}
this.flushMessageBufferDebounced();
}
const apiCallbacks = this.broadcastCallbacks.get(params.api);
if (apiCallbacks) {
const methodCallbacks = apiCallbacks.get(params.method);
if (methodCallbacks) {
for (const callback of methodCallbacks) {
handled = true;
callback(params.params);
}
}
}
if (!handled && !isProduction()) {
console.warn(`Unhandled message ${params.api}.${params.method}`);
}
}
return; // method === 'execute'
}
});
}
onResponse(
data: ClientResponseType,
resolve: ((a: any) => any) | undefined,
reject: (error: ClientErrorType) => any,
) {
if (data.success) {
resolve && resolve(data.success);
} else if (data.error) {
reject(data.error);
const {error} = data;
if (error) {
handleError(this.store, this.device, error);
}
} else {
// ???
}
}
toJSON(): ClientExport {
return {id: this.id, query: this.query};
}
subscribe(api: string, method: string, callback: (params: Object) => void) {
let apiCallbacks = this.broadcastCallbacks.get(api);
if (!apiCallbacks) {
apiCallbacks = new Map();
this.broadcastCallbacks.set(api, apiCallbacks);
}
let methodCallbacks = apiCallbacks.get(method);
if (!methodCallbacks) {
methodCallbacks = new Set();
apiCallbacks.set(method, methodCallbacks);
}
methodCallbacks.add(callback);
}
unsubscribe(api: string, method: string, callback: Function) {
const apiCallbacks = this.broadcastCallbacks.get(api);
if (!apiCallbacks) {
return;
}
const methodCallbacks = apiCallbacks.get(method);
if (!methodCallbacks) {
return;
}
methodCallbacks.delete(callback);
}
rawCall<T>(method: string, fromPlugin: boolean, params?: Params): Promise<T> {
return new Promise(async (resolve, reject) => {
const id = this.messageIdCounter++;
const metadata: RequestMetadata = {
method,
id,
params,
};
const data = {
id,
method,
params,
};
const plugin = params ? params.api : undefined;
console.debug(data, 'message:call');
const mark = this.getPerformanceMark(metadata);
performance.mark(mark);
if (!this.connected.get()) {
message.warn({
content: 'Not connected',
key: 'appnotconnectedwarning',
duration: 0.5,
});
reject(new Error('Not connected to client'));
return;
}
if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
try {
const response = await this.connection!.sendExpectResponse(data);
if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
const logEventName = this.getLogEventName(data);
this.logger.trackTimeSince(mark, logEventName);
emitBytesReceived(plugin || 'unknown', response.length * 2);
this.onResponse(response, resolve, reject);
if (isFlipperMessageDebuggingEnabled()) {
registerFlipperDebugMessage({
device: this.device?.displayTitle(),
app: this.query.app,
flipperInternalMethod: method,
payload: response,
plugin,
pluginMethod: params?.method,
direction: 'toFlipper:response',
});
}
}
} catch (error) {
// This is only called if the connection is dead. Not in expected
// and recoverable cases like a missing receiver/method.
this.disconnect();
reject(new Error('Unable to send, connection error: ' + error));
}
} else {
reject(
new Error(
`Cannot send ${method}, client is not accepting messages for plugin ${plugin}`,
),
);
}
if (isFlipperMessageDebuggingEnabled()) {
registerFlipperDebugMessage({
device: this.device?.displayTitle(),
app: this.query.app,
flipperInternalMethod: method,
plugin: params?.api,
pluginMethod: params?.method,
payload: params?.params,
direction: 'toClient:call',
});
}
});
}
flushMessageBuffer = () => {
// batch to make sure that Redux collapsed the dispatches
batch(() => {
for (const pluginKey in this.messageBuffer) {
processMessagesLater(
this.store,
pluginKey,
this.messageBuffer[pluginKey].plugin,
this.messageBuffer[pluginKey].messages,
);
}
this.messageBuffer = {};
});
};
flushMessageBufferDebounced = debounce(this.flushMessageBuffer, 200, {
leading: true,
trailing: true,
});
startTimingRequestResponse(data: RequestMetadata) {
performance.mark(this.getPerformanceMark(data));
}
finishTimingRequestResponse(data: RequestMetadata) {
const mark = this.getPerformanceMark(data);
const logEventName = this.getLogEventName(data);
this.logger.trackTimeSince(mark, logEventName);
}
isAcceptingMessagesFromPlugin(plugin: string | null | undefined) {
return this.connection && (!plugin || this.activePlugins.has(plugin));
}
getPerformanceMark(data: RequestMetadata): string {
const {method, id} = data;
return `request_response_${method}_${id}`;
}
getLogEventName(data: RequestMetadata): string {
const {method, params} = data;
return params && params.api && params.method
? `request_response_${method}_${params.api}_${params.method}`
: `request_response_${method}`;
}
initPlugin(pluginId: string) {
this.activePlugins.add(pluginId);
if (this.connected.get()) {
this.rawSend('init', {plugin: pluginId});
this.sandyPluginStates.get(pluginId)?.connect();
}
}
deinitPlugin(pluginId: string) {
this.activePlugins.delete(pluginId);
this.sandyPluginStates.get(pluginId)?.disconnect();
if (this.connected.get()) {
this.rawSend('deinit', {plugin: pluginId});
}
}
rawSend(method: string, params?: Object): void {
const data = {
method,
params,
};
console.debug(data, 'message:send');
if (this.connection) {
this.connection.send(data);
}
if (isFlipperMessageDebuggingEnabled()) {
registerFlipperDebugMessage({
device: this.device?.displayTitle(),
app: this.query.app,
flipperInternalMethod: method,
payload: params,
direction: 'toClient:send',
});
}
}
call(
api: string,
method: string,
fromPlugin: boolean,
params?: Object,
): Promise<Object> {
return reportPluginFailures(
this.rawCall<Object>('execute', fromPlugin, {
api,
method,
params,
}).catch((err: Error) => {
// We only throw errors if the connection is still alive
// as connection-related ones aren't recoverable from
// user code.
if (this.connected.get()) {
// This is a special case where we a send failed because of
// a disconnect "mid-air". This can happen, for instance,
// when you pull the plug from a connected phone. We can
// still handle this gracefully.
if (err.toString().includes('Socket closed unexpectedly')) {
console.warn(
`Failed to call device due to unexpected disconnect: ${err}`,
);
this.disconnect();
return {};
}
throw err;
}
// This effectively preserves the previous behavior
// of ignoring disconnection-related call failures.
return {};
}),
`Call-${method}`,
api,
);
}
send(api: string, method: string, params?: Object): void {
if (!isProduction()) {
console.warn(
`${api}:${
method || ''
} client.send() is deprecated. Please use call() instead so you can handle errors.`,
);
}
return this.rawSend('execute', {api, method, params});
}
async supportsMethod(api: string, method: string): Promise<boolean> {
const response = await this.rawCall<{
isSupported: boolean;
}>('isMethodSupported', true, {
api,
method,
});
return response.isSupported;
}
}

View File

@@ -0,0 +1,577 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {SearchableProps} from './ui';
import {Logger} from 'flipper-common';
import {
Searchable,
Button,
ButtonGroup,
FlexBox,
FlexColumn,
FlexRow,
Glyph,
ContextMenu,
styled,
colors,
} from './ui';
import {PluginDefinition, DevicePluginMap, ClientPluginMap} from './plugin';
import {connect} from 'react-redux';
import React, {Component, Fragment} from 'react';
import {
PluginNotification,
updatePluginBlocklist,
updateCategoryBlocklist,
} from './reducers/notifications';
import {selectPlugin} from './reducers/connections';
import {State as StoreState} from './reducers/index';
import {textContent} from 'flipper-plugin';
import createPaste from './fb-stubs/createPaste';
import {getPluginTitle} from './utils/pluginUtils';
import {getFlipperLib} from 'flipper-plugin';
import {ContextMenuItem} from './ui/components/ContextMenu';
type OwnProps = {
onClear: () => void;
selectedID: string | null | undefined;
logger: Logger;
} & Partial<SearchableProps>;
type StateFromProps = {
activeNotifications: Array<PluginNotification>;
invalidatedNotifications: Array<PluginNotification>;
blocklistedPlugins: Array<string>;
blocklistedCategories: Array<string>;
devicePlugins: DevicePluginMap;
clientPlugins: ClientPluginMap;
};
type DispatchFromProps = {
selectPlugin: typeof selectPlugin;
updatePluginBlocklist: (blocklist: Array<string>) => any;
updateCategoryBlocklist: (blocklist: Array<string>) => any;
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
type State = {
selectedNotification: string | null | undefined;
};
const Content = styled(FlexColumn)({
padding: '0 10px',
backgroundColor: colors.light02,
overflow: 'scroll',
flexGrow: 1,
});
const Heading = styled(FlexBox)({
display: 'block',
alignItems: 'center',
marginTop: 15,
marginBottom: 5,
color: colors.macOSSidebarSectionTitle,
fontSize: 11,
fontWeight: 500,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
flexShrink: 0,
});
const NoContent = styled(FlexColumn)({
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
flexGrow: 1,
fontWeight: 500,
lineHeight: 2.5,
color: colors.light30,
});
class NotificationsTable extends Component<Props & SearchableProps, State> {
contextMenuItems = [{label: 'Clear all', click: this.props.onClear}];
state: State = {
selectedNotification: this.props.selectedID,
};
componentDidUpdate(prevProps: Props & SearchableProps) {
if (this.props.filters.length !== prevProps.filters.length) {
this.props.updatePluginBlocklist(
this.props.filters
.filter(
(f) => f.type === 'exclude' && f.key.toLowerCase() === 'plugin',
)
.map((f) => String(f.value)),
);
this.props.updateCategoryBlocklist(
this.props.filters
.filter(
(f) => f.type === 'exclude' && f.key.toLowerCase() === 'category',
)
.map((f) => String(f.value)),
);
}
if (
this.props.selectedID &&
prevProps.selectedID !== this.props.selectedID
) {
this.setState({
selectedNotification: this.props.selectedID,
});
}
}
onHidePlugin = (pluginId: string) => {
// add filter to searchbar
this.props.addFilter({
value: pluginId,
type: 'exclude',
key: 'plugin',
});
this.props.updatePluginBlocklist(
this.props.blocklistedPlugins.concat(pluginId),
);
};
onHideCategory = (category: string) => {
// add filter to searchbar
this.props.addFilter({
value: category,
type: 'exclude',
key: 'category',
});
this.props.updatePluginBlocklist(
this.props.blocklistedCategories.concat(category),
);
};
getFilter =
(): ((n: PluginNotification) => boolean) => (n: PluginNotification) => {
const searchTerm = this.props.searchTerm.toLowerCase();
// filter plugins
const blocklistedPlugins = new Set(
this.props.blocklistedPlugins.map((p) => p.toLowerCase()),
);
if (blocklistedPlugins.has(n.pluginId.toLowerCase())) {
return false;
}
// filter categories
const {category} = n.notification;
if (category) {
const blocklistedCategories = new Set(
this.props.blocklistedCategories.map((p) => p.toLowerCase()),
);
if (blocklistedCategories.has(category.toLowerCase())) {
return false;
}
}
if (searchTerm.length === 0) {
return true;
} else if (n.notification.title.toLowerCase().indexOf(searchTerm) > -1) {
return true;
} else if (
typeof n.notification.message === 'string' &&
n.notification.message.toLowerCase().indexOf(searchTerm) > -1
) {
return true;
}
return false;
};
getPlugin = (id: string) =>
this.props.clientPlugins.get(id) || this.props.devicePlugins.get(id);
render() {
const activeNotifications = this.props.activeNotifications
.filter(this.getFilter())
.map((n: PluginNotification) => {
const {category} = n.notification;
return (
<NotificationItem
key={n.notification.id}
{...n}
plugin={this.getPlugin(n.pluginId)}
isSelected={this.state.selectedNotification === n.notification.id}
onHighlight={() =>
this.setState({selectedNotification: n.notification.id})
}
onClear={this.props.onClear}
onHidePlugin={() => this.onHidePlugin(n.pluginId)}
onHideCategory={
category ? () => this.onHideCategory(category) : undefined
}
selectPlugin={this.props.selectPlugin}
logger={this.props.logger}
/>
);
})
.reverse();
const invalidatedNotifications = this.props.invalidatedNotifications
.filter(this.getFilter())
.map((n: PluginNotification) => (
<NotificationItem
key={n.notification.id}
{...n}
plugin={this.getPlugin(n.pluginId)}
onClear={this.props.onClear}
inactive
/>
))
.reverse();
return (
<ContextMenu items={this.contextMenuItems} component={Content}>
{activeNotifications.length > 0 && (
<Fragment>
<Heading>Active notifications</Heading>
<FlexColumn shrink={false}>{activeNotifications}</FlexColumn>
</Fragment>
)}
{invalidatedNotifications.length > 0 && (
<Fragment>
<Heading>Past notifications</Heading>
<FlexColumn shrink={false}>{invalidatedNotifications}</FlexColumn>
</Fragment>
)}
{activeNotifications.length + invalidatedNotifications.length === 0 && (
<NoContent>
<Glyph
name="bell-null"
size={24}
variant="outline"
color={colors.light30}
/>
No Notifications
</NoContent>
)}
</ContextMenu>
);
}
}
export const ConnectedNotificationsTable = connect<
StateFromProps,
DispatchFromProps,
OwnProps,
StoreState
>(
({
notifications: {
activeNotifications,
invalidatedNotifications,
blocklistedPlugins,
blocklistedCategories,
},
plugins: {devicePlugins, clientPlugins},
}) => ({
activeNotifications,
invalidatedNotifications,
blocklistedPlugins,
blocklistedCategories,
devicePlugins,
clientPlugins,
}),
{
updatePluginBlocklist,
updateCategoryBlocklist,
selectPlugin,
},
)(Searchable(NotificationsTable));
const shadow = (
props: {isSelected?: boolean; inactive?: boolean},
_hover?: boolean,
) => {
if (props.inactive) {
return `inset 0 0 0 1px ${colors.light10}`;
}
const shadow = ['1px 1px 5px rgba(0,0,0,0.1)'];
if (props.isSelected) {
shadow.push(`inset 0 0 0 2px ${colors.macOSTitleBarIconSelected}`);
}
return shadow.join(',');
};
const SEVERITY_COLOR_MAP = {
warning: colors.yellow,
error: colors.red,
};
type NotificationBoxProps = {
inactive?: boolean;
isSelected?: boolean;
severity: keyof typeof SEVERITY_COLOR_MAP;
};
const NotificationBox = styled(FlexRow)<NotificationBoxProps>((props) => ({
backgroundColor: props.inactive ? 'transparent' : colors.white,
opacity: props.inactive ? 0.5 : 1,
alignItems: 'flex-start',
borderRadius: 5,
padding: 10,
flexShrink: 0,
overflow: 'hidden',
position: 'relative',
marginBottom: 10,
boxShadow: shadow(props),
'::before': {
content: '""',
display: !props.inactive && !props.isSelected ? 'block' : 'none',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 3,
backgroundColor: SEVERITY_COLOR_MAP[props.severity] || colors.info,
},
':hover': {
boxShadow: shadow(props, true),
'& > *': {
opacity: 1,
},
},
}));
const Title = styled.div({
minWidth: 150,
color: colors.light80,
flexShrink: 0,
marginBottom: 6,
fontWeight: 500,
lineHeight: 1,
fontSize: '1.1em',
});
const NotificationContent = styled(FlexColumn)<{isSelected?: boolean}>(
(props) => ({
marginLeft: 6,
marginRight: 10,
flexGrow: 1,
overflow: 'hidden',
maxHeight: props.isSelected ? 'none' : 56,
lineHeight: 1.4,
color: props.isSelected ? colors.light50 : colors.light30,
userSelect: 'text',
}),
);
const Actions = styled(FlexRow)({
alignItems: 'center',
justifyContent: 'space-between',
color: colors.light20,
marginTop: 12,
borderTop: `1px solid ${colors.light05}`,
paddingTop: 8,
});
const NotificationButton = styled.div({
border: `1px solid ${colors.light20}`,
color: colors.light50,
borderRadius: 4,
textAlign: 'center',
padding: 4,
width: 80,
marginBottom: 4,
opacity: 0,
transition: '0.15s opacity',
'[data-role="notification"]:hover &': {
opacity: 0.5,
},
':last-child': {
marginBottom: 0,
},
'[data-role="notification"] &:hover': {
opacity: 1,
},
});
type ItemProps = {
onHighlight?: () => any;
onHidePlugin?: () => any;
onHideCategory?: () => any;
onClear?: () => any;
isSelected?: boolean;
inactive?: boolean;
selectPlugin?: typeof selectPlugin;
logger?: Logger;
plugin: PluginDefinition | null | undefined;
};
type ItemState = {
reportedNotHelpful: boolean;
};
class NotificationItem extends Component<
ItemProps & PluginNotification,
ItemState
> {
constructor(props: ItemProps & PluginNotification) {
super(props);
const items: Array<ContextMenuItem> = [];
if (props.onHidePlugin && props.plugin) {
items.push({
label: `Hide ${getPluginTitle(props.plugin)} plugin`,
click: this.props.onHidePlugin,
});
}
if (props.onHideCategory) {
items.push({
label: 'Hide Similar',
click: this.props.onHideCategory,
});
}
items.push(
{label: 'Copy', role: 'copy'},
{label: 'Copy All', click: this.copy},
{label: 'Create Paste', click: this.createPaste},
);
this.contextMenuItems = items;
}
state = {reportedNotHelpful: false};
contextMenuItems: Array<ContextMenuItem>;
deepLinkButton = React.createRef();
createPaste = () => {
createPaste(this.getContent());
};
copy = () => getFlipperLib().writeTextToClipboard(this.getContent());
getContent = (): string =>
[
this.props.notification.timestamp,
`[${this.props.notification.severity}] ${this.props.notification.title}`,
this.props.notification.action,
this.props.notification.category,
textContent(this.props.notification.message),
]
.filter(Boolean)
.join('\n');
openDeeplink = () => {
const {notification, pluginId, client} = this.props;
if (this.props.selectPlugin && notification.action) {
this.props.selectPlugin({
selectedPlugin: pluginId,
selectedAppId: client,
deepLinkPayload: notification.action,
});
}
};
reportNotUseful = (e: React.MouseEvent<any>) => {
e.preventDefault();
e.stopPropagation();
if (this.props.logger) {
this.props.logger.track(
'usage',
'notification-not-useful',
this.props.notification,
);
}
this.setState({reportedNotHelpful: true});
};
onHide = (e: React.MouseEvent<any>) => {
e.preventDefault();
e.stopPropagation();
if (this.props.onHideCategory) {
this.props.onHideCategory();
} else if (this.props.onHidePlugin) {
this.props.onHidePlugin();
}
};
render() {
const {
notification,
isSelected,
inactive,
onHidePlugin,
onHideCategory,
plugin,
} = this.props;
const {action} = notification;
return (
<ContextMenu<React.ComponentProps<typeof NotificationBox>>
data-role="notification"
component={NotificationBox}
severity={notification.severity}
onClick={this.props.onHighlight}
isSelected={isSelected}
inactive={inactive}
items={this.contextMenuItems}>
<Glyph name={(plugin ? plugin.icon : 'bell') || 'bell'} size={12} />
<NotificationContent isSelected={isSelected}>
<Title>{notification.title}</Title>
{notification.message}
{!inactive &&
isSelected &&
plugin &&
(action || onHidePlugin || onHideCategory) && (
<Actions>
<FlexRow>
{action && (
<Button onClick={this.openDeeplink}>
Open in {getPluginTitle(plugin)}
</Button>
)}
<ButtonGroup>
{onHideCategory && (
<Button onClick={onHideCategory}>Hide similar</Button>
)}
{onHidePlugin && (
<Button onClick={onHidePlugin}>
Hide {getPluginTitle(plugin)}
</Button>
)}
</ButtonGroup>
</FlexRow>
<span>
{notification.timestamp
? new Date(notification.timestamp).toTimeString()
: ''}
</span>
</Actions>
)}
</NotificationContent>
{action && !inactive && !isSelected && (
<FlexColumn style={{alignSelf: 'center'}}>
{action && (
<NotificationButton onClick={this.openDeeplink}>
Open
</NotificationButton>
)}
{this.state.reportedNotHelpful ? (
<NotificationButton onClick={this.onHide}>
Hide
</NotificationButton>
) : (
<NotificationButton onClick={this.reportNotUseful}>
Not helpful
</NotificationButton>
)}
</FlexColumn>
)}
</ContextMenu>
);
}
}

View File

@@ -0,0 +1,438 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlipperPlugin, FlipperDevicePlugin} from './plugin';
import {Logger, isTest} from 'flipper-common';
import BaseDevice from './devices/BaseDevice';
import {pluginKey as getPluginKey} from './utils/pluginKey';
import Client from './Client';
import {
ErrorBoundary,
FlexColumn,
FlexRow,
colors,
styled,
Glyph,
Label,
VBox,
View,
} from './ui';
import {StaticView, setStaticView} from './reducers/connections';
import {switchPlugin} from './reducers/pluginManager';
import React, {PureComponent} from 'react';
import {connect, ReactReduxContext, ReactReduxContextValue} from 'react-redux';
import {selectPlugin} from './reducers/connections';
import {State as Store, MiddlewareAPI} from './reducers/index';
import {Message} from './reducers/pluginMessageQueue';
import {IdlerImpl} from './utils/Idler';
import {processMessageQueue} from './utils/messageQueue';
import {Layout} from './ui';
import {theme, _SandyPluginRenderer} from 'flipper-plugin';
import {
ActivePluginListItem,
isDevicePlugin,
isDevicePluginDefinition,
} from './utils/pluginUtils';
import {ContentContainer} from './sandy-chrome/ContentContainer';
import {Alert, Typography} from 'antd';
import {InstalledPluginDetails} from 'flipper-plugin-lib';
import semver from 'semver';
import {loadPlugin} from './reducers/pluginManager';
import {produce} from 'immer';
import {reportUsage} from 'flipper-common';
import {PluginInfo} from './chrome/fb-stubs/PluginInfo';
import {getActiveClient, getActivePlugin} from './selectors/connections';
import {AnyAction} from 'redux';
const {Text, Link} = Typography;
export const SidebarContainer = styled(FlexRow)({
backgroundColor: theme.backgroundWash,
height: '100%',
overflow: 'auto',
});
const Waiting = styled(FlexColumn)({
width: '100%',
height: '100%',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
});
function ProgressBar({progress}: {progress: number}) {
return (
<ProgressBarContainer>
<ProgressBarBar progress={progress} />
</ProgressBarContainer>
);
}
const ProgressBarContainer = styled.div({
border: `1px solid ${colors.cyan}`,
borderRadius: 4,
width: 300,
});
const ProgressBarBar = styled.div<{progress: number}>(({progress}) => ({
background: colors.cyan,
width: `${Math.min(100, Math.round(progress * 100))}%`,
height: 8,
}));
type OwnProps = {
logger: Logger;
};
type StateFromProps = {
activePlugin: ActivePluginListItem | null;
target: Client | BaseDevice | null;
pluginKey: string | null;
deepLinkPayload: unknown;
pendingMessages: Message[] | undefined;
latestInstalledVersion: InstalledPluginDetails | undefined;
};
type DispatchFromProps = {
selectPlugin: typeof selectPlugin;
setStaticView: (payload: StaticView) => void;
enablePlugin: typeof switchPlugin;
loadPlugin: typeof loadPlugin;
};
type Props = StateFromProps & DispatchFromProps & OwnProps;
type State = {
progress: {current: number; total: number};
autoUpdateAlertSuppressed: Set<string>;
};
class PluginContainer extends PureComponent<Props, State> {
static contextType: React.Context<ReactReduxContextValue<any, AnyAction>> =
ReactReduxContext;
constructor(props: Props) {
super(props);
this.reloadPlugin = this.reloadPlugin.bind(this);
}
plugin:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
| null
| undefined;
idler?: IdlerImpl;
pluginBeingProcessed: string | null = null;
state = {
progress: {current: 0, total: 0},
autoUpdateAlertSuppressed: new Set<string>(),
};
get store(): MiddlewareAPI {
return this.context.store;
}
componentWillUnmount() {
if (this.plugin) {
this.plugin._teardown();
this.plugin = null;
}
this.cancelCurrentQueue();
}
componentDidMount() {
this.processMessageQueue();
}
componentDidUpdate() {
this.processMessageQueue();
// make sure deeplinks are propagated
const {deepLinkPayload, target, activePlugin} = this.props;
if (deepLinkPayload && activePlugin && target) {
target.sandyPluginStates
.get(activePlugin.details.id)
?.triggerDeepLink(deepLinkPayload);
}
}
processMessageQueue() {
const {pluginKey, pendingMessages, activePlugin, target} = this.props;
if (pluginKey !== this.pluginBeingProcessed) {
this.pluginBeingProcessed = pluginKey;
this.cancelCurrentQueue();
this.setState((state) =>
produce(state, (draft) => {
draft.progress = {current: 0, total: 0};
}),
);
// device plugins don't have connections so no message queues
if (
!activePlugin ||
activePlugin.status !== 'enabled' ||
isDevicePluginDefinition(activePlugin.definition)
) {
return;
}
if (
target instanceof Client &&
activePlugin &&
pluginKey &&
pendingMessages?.length
) {
const start = Date.now();
this.idler = new IdlerImpl();
processMessageQueue(
target.sandyPluginStates.get(activePlugin.definition.id)!,
pluginKey,
this.store,
(progress) => {
this.setState((state) =>
produce(state, (draft) => {
draft.progress = progress;
}),
);
},
this.idler,
)
.then((completed) => {
const duration = Date.now() - start;
this.props.logger.track(
'duration',
'queue-processing-before-plugin-open',
{
completed,
duration,
},
activePlugin.definition.id,
);
})
.catch((err) =>
console.error('Error while processing plugin message queue', err),
);
}
}
}
cancelCurrentQueue() {
if (this.idler && !this.idler.isCancelled()) {
this.idler.cancel();
}
}
render() {
const {activePlugin, pendingMessages} = this.props;
if (!activePlugin) {
return this.renderNoPluginActive();
}
if (activePlugin.status !== 'enabled') {
return this.renderPluginInfo();
}
if (!pendingMessages || pendingMessages.length === 0) {
return this.renderPlugin();
}
return this.renderPluginLoader();
}
renderPluginInfo() {
return <PluginInfo />;
}
renderPluginLoader() {
return (
<View grow>
<Waiting>
<VBox>
<Glyph
name="dashboard"
variant="outline"
size={24}
color={colors.light30}
/>
</VBox>
<VBox>
<Label>
Processing {this.state.progress.total} events for{' '}
{this.props.activePlugin?.details?.id ?? 'plugin'}
</Label>
</VBox>
<VBox>
<ProgressBar
progress={this.state.progress.current / this.state.progress.total}
/>
</VBox>
</Waiting>
</View>
);
}
renderNoPluginActive() {
if (isTest()) {
return <>No plugin selected</>; // to keep 'nothing' clearly recognisable in unit tests
}
return (
<View grow>
<Waiting>
<VBox>
<Glyph
name="cup"
variant="outline"
size={24}
color={colors.light30}
/>
</VBox>
<VBox>
<Label>No plugin selected</Label>
</VBox>
</Waiting>
</View>
);
}
reloadPlugin() {
const {loadPlugin, latestInstalledVersion} = this.props;
if (latestInstalledVersion) {
reportUsage(
'plugin-auto-update:alert:reloadClicked',
{
version: latestInstalledVersion.version,
},
latestInstalledVersion.id,
);
loadPlugin({
plugin: latestInstalledVersion,
enable: false,
notifyIfFailed: true,
});
}
}
renderPlugin() {
const {activePlugin, pluginKey, target, latestInstalledVersion} =
this.props;
if (
!activePlugin ||
!target ||
!pluginKey ||
activePlugin.status !== 'enabled'
) {
console.warn(`No selected plugin. Rendering empty!`);
return this.renderNoPluginActive();
}
const showUpdateAlert =
latestInstalledVersion &&
activePlugin &&
!this.state.autoUpdateAlertSuppressed.has(
`${latestInstalledVersion.name}@${latestInstalledVersion.version}`,
) &&
semver.gt(
latestInstalledVersion.version,
activePlugin.definition.version,
);
// Make sure we throw away the container for different pluginKey!
const instance = target.sandyPluginStates.get(activePlugin.definition.id);
if (!instance) {
// happens if we selected a plugin that is not enabled on a specific app or not supported on a specific device.
return this.renderNoPluginActive();
}
return (
<Layout.Top>
<div>
{showUpdateAlert && (
<Alert
message={
<Text>
Plugin "{activePlugin.definition.title}" v
{latestInstalledVersion?.version} is downloaded and ready to
install. <Link onClick={this.reloadPlugin}>Reload</Link> to
start using the new version.
</Text>
}
type="info"
onClose={() =>
this.setState((state) =>
produce(state, (draft) => {
draft.autoUpdateAlertSuppressed.add(
`${latestInstalledVersion?.name}@${latestInstalledVersion?.version}`,
);
}),
)
}
style={{marginBottom: theme.space.large}}
showIcon
closable
/>
)}
</div>
<Layout.Right>
<ErrorBoundary
heading={`Plugin "${
activePlugin.definition.title || 'Unknown'
}" encountered an error during render`}>
<ContentContainer>
<_SandyPluginRenderer key={pluginKey} plugin={instance} />
</ContentContainer>
</ErrorBoundary>
<SidebarContainer id="detailsSidebar" />
</Layout.Right>
</Layout.Top>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
(state: Store) => {
let pluginKey: string | null = null;
let target: BaseDevice | Client | null = null;
const {
connections: {selectedDevice, deepLinkPayload},
plugins: {installedPlugins},
pluginMessageQueue,
} = state;
const selectedClient = getActiveClient(state);
const activePlugin = getActivePlugin(state);
if (activePlugin) {
if (selectedDevice && isDevicePlugin(activePlugin)) {
target = selectedDevice;
pluginKey = getPluginKey(
selectedDevice.serial,
activePlugin.details.id,
);
} else if (selectedClient) {
target = selectedClient;
pluginKey = getPluginKey(selectedClient.id, activePlugin.details.id);
}
}
const pendingMessages = pluginKey
? pluginMessageQueue[pluginKey]
: undefined;
const s: StateFromProps = {
activePlugin,
target,
deepLinkPayload,
pluginKey,
pendingMessages,
latestInstalledVersion: installedPlugins.get(
activePlugin?.details?.name ?? '',
),
};
return s;
},
{
selectPlugin,
setStaticView,
enablePlugin: switchPlugin,
loadPlugin,
},
)(PluginContainer);

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export enum ReleaseChannel {
DEFAULT = 'default',
STABLE = 'stable',
INSIDERS = 'insiders',
}
export default ReleaseChannel;

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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 {NotificationEvents} from './dispatcher/notifications';
import type {PluginNotification} from './reducers/notifications';
import type {NotificationConstructorOptions} from 'electron';
import type {FlipperLib} from 'flipper-plugin';
import path from 'path';
type ENVIRONMENT_VARIABLES = 'NODE_ENV' | 'DEV_SERVER_URL' | 'CONFIG';
type ENVIRONMENT_PATHS =
| 'appPath'
| 'homePath'
| 'execPath'
| 'staticPath'
| 'tempPath'
| 'desktopPath';
// Events that are emitted from the main.ts ovr the IPC process bridge in Electron
type MainProcessEvents = {
'flipper-protocol-handler': [query: string];
'open-flipper-file': [url: string];
notificationEvent: [
eventName: NotificationEvents,
pluginNotification: PluginNotification,
arg: null | string | number,
];
trackUsage: any[];
getLaunchTime: [launchStartTime: number];
};
// Events that are emitted by the child process, to the main process
type ChildProcessEvents = {
setTheme: [theme: 'dark' | 'light' | 'system'];
sendNotification: [
{
payload: NotificationConstructorOptions;
pluginNotification: PluginNotification;
closeAfter?: number;
},
];
getLaunchTime: [];
componentDidMount: [];
};
/**
* Utilities provided by the render host, e.g. Electron, the Browser, etc
*/
export interface RenderHost {
readonly processId: number;
readonly isProduction: boolean;
readTextFromClipboard(): string | undefined;
writeTextToClipboard(text: string): void;
showSaveDialog?: FlipperLib['showSaveDialog'];
showOpenDialog?: FlipperLib['showOpenDialog'];
showSelectDirectoryDialog?(defaultPath?: string): Promise<string | undefined>;
/**
* @returns
* A callback to unregister the shortcut
*/
registerShortcut(shortCut: string, callback: () => void): () => void;
hasFocus(): boolean;
onIpcEvent<Event extends keyof MainProcessEvents>(
event: Event,
callback: (...arg: MainProcessEvents[Event]) => void,
): void;
sendIpcEvent<Event extends keyof ChildProcessEvents>(
event: Event,
...args: ChildProcessEvents[Event]
): void;
shouldUseDarkColors(): boolean;
restartFlipper(update?: boolean): void;
env: Partial<Record<ENVIRONMENT_VARIABLES, string>>;
paths: Record<ENVIRONMENT_PATHS, string>;
openLink(url: string): void;
loadDefaultPlugins(): Record<string, any>;
}
export function getRenderHostInstance(): RenderHost {
if (!window.FlipperRenderHostInstance) {
throw new Error('global FlipperRenderHostInstance was never set');
}
return window.FlipperRenderHostInstance;
}
if (process.env.NODE_ENV === 'test') {
window.FlipperRenderHostInstance = {
processId: -1,
isProduction: false,
readTextFromClipboard() {
return '';
},
writeTextToClipboard() {},
registerShortcut() {
return () => undefined;
},
hasFocus() {
return true;
},
onIpcEvent() {},
sendIpcEvent() {},
shouldUseDarkColors() {
return false;
},
restartFlipper() {},
openLink() {},
env: process.env,
paths: {
appPath: process.cwd(),
homePath: `/dev/null`,
desktopPath: `/dev/null`,
execPath: process.cwd(),
staticPath: path.join(process.cwd(), 'static'),
tempPath: `/tmp/`,
},
loadDefaultPlugins() {
return {};
},
};
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
module.exports = {
remote: {
process: {
env: {},
},
app: {
getPath: (path: string) => `/${path}`,
getAppPath: process.cwd,
getVersion: () => '0.9.99',
relaunch: () => {},
exit: () => {},
},
getCurrentWindow: () => ({isFocused: () => true}),
},
ipcRenderer: {},
};

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export function v4() {
return '00000000-0000-0000-0000-000000000000';
}
export function v1() {
return '00000000-0000-0000-0000-000000000000';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can create a Fake flipper with legacy wrapper 1`] = `
Object {
"clients": Map {
"TestApp#Android#MockAndroidDevice#serial" => Object {
"id": "TestApp#Android#MockAndroidDevice#serial",
"query": Object {
"app": "TestApp",
"device": "MockAndroidDevice",
"device_id": "serial",
"os": "Android",
"sdk_version": 4,
},
},
},
"deepLinkPayload": null,
"devices": Array [
Object {
"deviceType": "physical",
"os": "Android",
"serial": "serial",
"title": "MockAndroidDevice",
},
],
"enabledDevicePlugins": Set {
"DeviceLogs",
"CrashReporter",
"MobileBuilds",
"Hermesdebuggerrn",
"React",
},
"enabledPlugins": Object {
"TestApp": Array [
"TestPlugin",
],
},
"flipperServer": Object {
"close": [MockFunction],
"exec": [MockFunction],
"off": [MockFunction],
"on": [MockFunction],
},
"pluginMenuEntries": Array [],
"selectedAppId": "TestApp#Android#MockAndroidDevice#serial",
"selectedAppPluginListRevision": 0,
"selectedDevice": Object {
"deviceType": "physical",
"os": "Android",
"serial": "serial",
"title": "MockAndroidDevice",
},
"selectedPlugin": "TestPlugin",
"staticView": null,
"uninitializedClients": Array [],
"userPreferredApp": "TestApp",
"userPreferredDevice": "MockAndroidDevice",
"userPreferredPlugin": "TestPlugin",
}
`;
exports[`can create a Fake flipper with legacy wrapper 2`] = `
Object {
"bundledPlugins": Map {},
"clientPlugins": Map {
"TestPlugin" => SandyPluginDefinition {
"details": Object {
"dir": "/Users/mock/.flipper/thirdparty/flipper-plugin-sample1",
"entry": "./test/index.js",
"id": "TestPlugin",
"isActivatable": true,
"isBundled": false,
"main": "dist/bundle.js",
"name": "flipper-plugin-hello",
"pluginType": "client",
"source": "src/index.js",
"specVersion": 2,
"title": "TestPlugin",
"version": "0.1.0",
},
"id": "TestPlugin",
"isDevicePlugin": false,
"module": Object {
"Component": [Function],
"plugin": [Function],
},
},
},
"devicePlugins": Map {},
"disabledPlugins": Array [],
"failedPlugins": Array [],
"gatekeepedPlugins": Array [],
"initialized": false,
"installedPlugins": Map {},
"loadedPlugins": Map {},
"marketplacePlugins": Array [],
"selectedPlugins": Array [],
"uninstalledPluginNames": Set {},
}
`;

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {createMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin';
import {FlipperPlugin} from '../plugin';
import {TestIdler} from '../utils/Idler';
import {getAllClients} from '../reducers/connections';
interface PersistedState {
count: 1;
}
class TestPlugin extends FlipperPlugin<any, any, any> {
static id = 'TestPlugin';
static defaultPersistedState = {
count: 0,
};
static persistedStateReducer(
persistedState: PersistedState,
method: string,
_payload: {},
) {
if (method === 'inc') {
return Object.assign({}, persistedState, {
count: persistedState.count + 1,
});
}
return persistedState;
}
render() {
return null;
}
}
const testIdler = new TestIdler();
function testOnStatusMessage() {
// emtpy stub
}
test('can create a Fake flipper with legacy wrapper', async () => {
const {client, device, store, sendMessage} =
await createMockFlipperWithPlugin(TestPlugin);
expect(client).toBeTruthy();
expect(device).toBeTruthy();
expect(store).toBeTruthy();
expect(sendMessage).toBeTruthy();
expect(client.plugins.has(TestPlugin.id)).toBe(true);
expect(client.sandyPluginStates.has(TestPlugin.id)).toBe(true);
const state = store.getState();
expect(state.connections).toMatchSnapshot();
expect(state.plugins).toMatchSnapshot();
sendMessage('inc', {});
expect(
await getAllClients(state.connections)[0]
.sandyPluginStates.get(TestPlugin.id)!
.exportState(testIdler, testOnStatusMessage),
).toMatchInlineSnapshot(`"{\\"count\\":1}"`);
});

View File

@@ -0,0 +1,165 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
jest.useFakeTimers();
import React from 'react';
import {renderMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin';
import {
_SandyPluginDefinition,
PluginClient,
TestUtils,
usePlugin,
createState,
useValue,
} from 'flipper-plugin';
import {handleDeeplink} from '../deeplink';
import {Logger} from 'flipper-common';
test('Triggering a deeplink will work', async () => {
const linksSeen: any[] = [];
const plugin = (client: PluginClient) => {
const linkState = createState('');
client.onDeepLink((link) => {
linksSeen.push(link);
linkState.set(String(link));
});
return {
linkState,
};
};
const definition = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin,
Component() {
const instance = usePlugin(plugin);
const linkState = useValue(instance.linkState);
return <h1>{linkState || 'world'}</h1>;
},
},
);
const {renderer, client, store, logger} = await renderMockFlipperWithPlugin(
definition,
);
expect(linksSeen).toEqual([]);
await handleDeeplink(
store,
logger,
`flipper://${client.query.app}/${definition.id}/universe`,
);
jest.runAllTimers();
expect(linksSeen).toEqual(['universe']);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<h1>
universe
</h1>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
});
test('Will throw error on invalid deeplinks', async () => {
const logger: Logger = {
track: jest.fn(),
} as any;
expect(() =>
handleDeeplink(undefined as any, logger, `flipper://test`),
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`);
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenLastCalledWith(
'usage',
'deeplink',
{
query: 'flipper://test',
state: 'ERROR',
errorMessage: 'Unknown deeplink',
},
undefined,
);
});
test('Will throw error on invalid protocol', async () => {
const logger: Logger = {
track: jest.fn(),
} as any;
expect(() =>
handleDeeplink(undefined as any, logger, `notflipper://test`),
).rejects.toThrowErrorMatchingInlineSnapshot(`"Unknown deeplink"`);
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenLastCalledWith(
'usage',
'deeplink',
{
query: 'notflipper://test',
state: 'ERROR',
errorMessage: 'Unknown deeplink',
},
undefined,
);
});
test('Will track deeplinks', async () => {
const definition = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin: () => {},
Component() {
return <h1>{'world'}</h1>;
},
},
);
const {store, logger} = await renderMockFlipperWithPlugin(definition);
logger.track = jest.fn();
await handleDeeplink(
store,
logger,
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'INIT',
},
undefined,
);
});

View File

@@ -0,0 +1,254 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {createMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin';
import {
TestUtils,
_SandyPluginDefinition,
createState,
DevicePluginClient,
PluginClient,
} from 'flipper-plugin';
import {handleClientConnected} from '../dispatcher/flipperServer';
import {TestDevice} from '../test-utils/TestDevice';
test('Devices can disconnect', async () => {
const deviceplugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
devicePlugin(client: DevicePluginClient) {
const destroy = jest.fn();
client.onDestroy(destroy);
const counter = createState(0);
return {
counter,
destroy,
get isConnected() {
return client.device.isConnected;
},
};
},
supportsDevice() {
return true;
},
Component() {
return null;
},
},
);
const {device} = await createMockFlipperWithPlugin(deviceplugin);
device.sandyPluginStates.get(deviceplugin.id)!.instanceApi.counter.set(1);
expect(
device.sandyPluginStates.get(deviceplugin.id)!.instanceApi.isConnected,
).toBe(true);
expect(device.isArchived).toBe(false);
expect(device.connected.get()).toBe(true);
device.disconnect();
expect(device.isArchived).toBe(false);
expect(device.connected.get()).toBe(false);
const instance = device.sandyPluginStates.get(deviceplugin.id)!;
expect(instance.instanceApi.isConnected).toBe(false);
expect(instance).toBeTruthy();
expect(instance.instanceApi.counter.get()).toBe(1); // state preserved
expect(instance.instanceApi.destroy).toBeCalledTimes(0);
device.destroy();
expect(device.isArchived).toBe(false);
expect(device.connected.get()).toBe(false);
expect(instance.instanceApi.destroy).toBeCalledTimes(1);
expect(device.sandyPluginStates.get(deviceplugin.id)).toBeUndefined();
});
test('New device with same serial removes & cleans the old one', async () => {
const deviceplugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({pluginType: 'device'}),
{
devicePlugin(client: DevicePluginClient) {
const destroy = jest.fn();
client.onDestroy(destroy);
return {
destroy,
};
},
supportsDevice() {
return true;
},
Component() {
return null;
},
},
);
const {device, store} = await createMockFlipperWithPlugin(deviceplugin);
const instance = device.sandyPluginStates.get(deviceplugin.id)!;
expect(device.isArchived).toBe(false);
expect(device.connected.get()).toBe(true);
expect(instance.instanceApi.destroy).toBeCalledTimes(0);
expect(store.getState().connections.devices).toEqual([device]);
// submit a new device with same serial
const device2 = new TestDevice(
device.serial,
'physical',
'MockAndroidDevice',
'Android',
);
expect(() => {
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device2,
});
}).toThrow('still connected');
device.destroy();
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device2,
});
device2.loadDevicePlugins(
store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins,
);
expect(device.isArchived).toBe(false);
expect(device.connected.get()).toBe(false);
expect(instance.instanceApi.destroy).toBeCalledTimes(1);
expect(
device2.sandyPluginStates.get(deviceplugin.id)!.instanceApi.destroy,
).toBeCalledTimes(0);
expect(store.getState().connections.devices.length).toBe(1);
expect(store.getState().connections.devices[0]).toBe(device2);
});
test('clients can disconnect but preserve state', async () => {
const plugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin(client: PluginClient) {
const connect = jest.fn();
const disconnect = jest.fn();
const destroy = jest.fn();
client.onConnect(connect);
client.onDestroy(destroy);
client.onDisconnect(disconnect);
const counter = createState(0);
return {
connect,
disconnect,
counter,
destroy,
get isConnected() {
return client.isConnected;
},
};
},
Component() {
return null;
},
},
);
const {client} = await createMockFlipperWithPlugin(plugin, {
asBackgroundPlugin: true,
});
let instance = client.sandyPluginStates.get(plugin.id)!;
instance.instanceApi.counter.set(1);
expect(instance.instanceApi.destroy).toBeCalledTimes(0);
expect(instance.instanceApi.connect).toBeCalledTimes(1);
expect(instance.instanceApi.disconnect).toBeCalledTimes(0);
expect(instance.instanceApi.isConnected).toBe(true);
expect(client.connected.get()).toBe(true);
client.disconnect();
expect(client.connected.get()).toBe(false);
instance = client.sandyPluginStates.get(plugin.id)!;
expect(instance).toBeTruthy();
expect(instance.instanceApi.counter.get()).toBe(1); // state preserved
expect(instance.instanceApi.isConnected).toBe(false);
expect(instance.instanceApi.destroy).toBeCalledTimes(0);
expect(instance.instanceApi.connect).toBeCalledTimes(1);
expect(instance.instanceApi.disconnect).toBeCalledTimes(1);
client.destroy();
expect(instance.instanceApi.destroy).toBeCalledTimes(1);
expect(instance.instanceApi.connect).toBeCalledTimes(1);
expect(instance.instanceApi.disconnect).toBeCalledTimes(1);
expect(client.sandyPluginStates.get(plugin.id)).toBeUndefined();
});
test('new clients replace old ones', async () => {
const plugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin(client: PluginClient) {
const connect = jest.fn();
const disconnect = jest.fn();
const destroy = jest.fn();
client.onConnect(connect);
client.onDestroy(destroy);
client.onDisconnect(disconnect);
const counter = createState(0);
return {
connect,
disconnect,
counter,
destroy,
};
},
Component() {
return null;
},
},
);
const {client, store, device, createClient, logger} =
await createMockFlipperWithPlugin(plugin, {
asBackgroundPlugin: true,
});
const instance = client.sandyPluginStates.get(plugin.id)!;
instance.instanceApi.counter.set(1);
expect(instance.instanceApi.destroy).toBeCalledTimes(0);
expect(instance.instanceApi.connect).toBeCalledTimes(1);
expect(instance.instanceApi.disconnect).toBeCalledTimes(0);
const client2 = await createClient(device, 'AnotherApp', client.query, true);
await handleClientConnected(
{
exec: (async () => {
return {
success: {}, // {plugins: []},
};
}) as any,
},
store,
logger,
client2,
);
expect(client2.connected.get()).toBe(true);
const instance2 = client2.sandyPluginStates.get(plugin.id)!;
expect(instance2).toBeTruthy();
expect(instance2.instanceApi.counter.get()).toBe(0);
expect(instance2.instanceApi.destroy).toBeCalledTimes(0);
expect(instance2.instanceApi.connect).toBeCalledTimes(1);
expect(instance2.instanceApi.disconnect).toBeCalledTimes(0);
expect(client.connected.get()).toBe(false);
expect(instance.instanceApi.counter.get()).toBe(1);
expect(instance.instanceApi.destroy).toBeCalledTimes(1);
expect(instance.instanceApi.connect).toBeCalledTimes(1);
expect(instance.instanceApi.disconnect).toBeCalledTimes(1);
});

View File

@@ -0,0 +1,153 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Markdown} from '../ui';
import {readFileSync} from 'fs';
import React, {Component} from 'react';
import path from 'path';
import {reportUsage} from 'flipper-common';
import {getChangelogPath} from '../utils/pathUtils';
import {Modal} from 'antd';
import {theme} from 'flipper-plugin';
const changelogKey = 'FlipperChangelogStatus';
type ChangelogStatus = {
lastHeader: string;
};
let getChangelogFromDisk = (): string => {
const changelogFromDisk: string = readFileSync(
path.join(getChangelogPath(), 'CHANGELOG.md'),
'utf8',
).trim();
getChangelogFromDisk = () => changelogFromDisk;
return changelogFromDisk;
};
const changelogSectionStyle = {
padding: 10,
maxHeight: '60vh',
overflow: 'scroll',
marginBottom: 10,
background: theme.backgroundDefault,
borderRadius: 4,
width: '100%',
};
type Props = {
onHide: () => void;
recent?: boolean;
};
export default class ChangelogSheet extends Component<Props, {}> {
componentDidMount() {
if (!this.props.recent) {
// opened through the menu
reportUsage('changelog:opened');
}
}
componentWillUnmount(): void {
if (this.props.recent) {
markChangelogRead(window.localStorage, getChangelogFromDisk());
}
if (!this.props.recent) {
reportUsage('changelog:closed');
}
}
render() {
return (
<Modal
visible
title="Changelog"
onCancel={this.props.onHide}
footer={null}>
<Markdown
source={
this.props.recent
? getRecentChangelog(window.localStorage, getChangelogFromDisk())
: getChangelogFromDisk()
}
style={changelogSectionStyle}
/>
</Modal>
);
}
}
function getChangelogStatus(
localStorage: Storage,
): ChangelogStatus | undefined {
return JSON.parse(localStorage.getItem(changelogKey) || '{}');
}
function getFirstHeader(changelog: string): string {
const match = changelog.match(/(^|\n)(#.*?)\n/);
if (match) {
return match[2];
}
return '';
}
export function hasNewChangesToShow(
localStorage: Storage | undefined,
changelog: string = getChangelogFromDisk(),
): boolean {
if (!localStorage) {
return false;
}
const status = getChangelogStatus(localStorage);
if (!status || !status.lastHeader) {
return true;
}
const firstHeader = getFirstHeader(changelog);
if (firstHeader && firstHeader !== status.lastHeader) {
return true;
}
return false;
}
export /*for test*/ function getRecentChangelog(
localStorage: Storage | undefined,
changelog: string,
): string {
if (!localStorage) {
return 'Changelog not available';
}
const status = getChangelogStatus(localStorage);
if (!status || !status.lastHeader) {
return changelog.trim();
}
const lastHeaderIndex = changelog.indexOf(status.lastHeader);
if (lastHeaderIndex === -1) {
return changelog.trim();
} else {
return changelog.substr(0, lastHeaderIndex).trim();
}
}
export /*for test*/ function markChangelogRead(
localStorage: Storage | undefined,
changelog: string,
) {
if (!localStorage) {
return;
}
const firstHeader = getFirstHeader(changelog);
if (!firstHeader) {
return;
}
const status: ChangelogStatus = {
lastHeader: firstHeader,
};
localStorage.setItem(changelogKey, JSON.stringify(status));
}

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {useMemo} from 'react';
import React from 'react';
import {Console, Hook} from 'console-feed';
import type {Methods} from 'console-feed/lib/definitions/Methods';
import type {Styles} from 'console-feed/lib/definitions/Styles';
import {createState, useValue} from 'flipper-plugin';
import {useLocalStorageState} from 'flipper-plugin';
import {theme, Toolbar, Layout} from 'flipper-plugin';
import {useIsDarkMode} from '../utils/useIsDarkMode';
import {Button, Dropdown, Menu, Checkbox} from 'antd';
import {DownOutlined} from '@ant-design/icons';
import {DeleteOutlined} from '@ant-design/icons';
const MAX_LOG_ITEMS = 1000;
export const logsAtom = createState<any[]>([]);
export const errorCounterAtom = createState(0);
export function enableConsoleHook() {
Hook(
window.console,
(log) => {
if (log.method === 'debug') {
return; // See below, skip debug messages which are generated very aggressively by Flipper
}
const newLogs = logsAtom.get().slice(-MAX_LOG_ITEMS);
newLogs.push(log);
logsAtom.set(newLogs);
if (log.method === 'error' || log.method === 'assert') {
errorCounterAtom.set(errorCounterAtom.get() + 1);
}
},
false,
);
}
function clearLogs() {
logsAtom.set([]);
errorCounterAtom.set(0);
}
const allLogLevels: Methods[] = [
'log',
// 'debug', We typically don't want to allow users to enable the debug logs, as they are used very intensively by flipper itself,
// making Flipper / console-feed. For debug level logging, use the Chrome devtools.
'info',
'warn',
'error',
'table',
'clear',
'time',
'timeEnd',
'count',
'assert',
];
const defaultLogLevels: Methods[] = ['warn', 'error', 'table', 'assert'];
export function ConsoleLogs() {
const isDarkMode = useIsDarkMode();
const logs = useValue(logsAtom);
const [logLevels, setLogLevels] = useLocalStorageState<Methods[]>(
'console-logs-loglevels',
defaultLogLevels,
);
const styles = useMemo(buildTheme, []);
return (
<Layout.Top>
<Toolbar wash>
<Button onClick={clearLogs} icon={<DeleteOutlined />}>
Clear Logs
</Button>
<Dropdown
overlay={
<Menu>
{allLogLevels.map((l) => (
<Menu.Item
key={l}
onClick={() => {
setLogLevels((state) =>
state.includes(l)
? state.filter((level) => level !== l)
: [l, ...state],
);
}}>
<Checkbox checked={logLevels.includes(l)}>{l}</Checkbox>
</Menu.Item>
))}
</Menu>
}>
<Button>
Log Levels
<DownOutlined />
</Button>
</Dropdown>
</Toolbar>
<Layout.ScrollContainer vertical>
<Console
logs={logs}
filter={logLevels}
variant={isDarkMode ? 'dark' : 'light'}
styles={styles}
/>
</Layout.ScrollContainer>
</Layout.Top>
);
}
function buildTheme(): Styles {
return {
// See: https://github.com/samdenty/console-feed/blob/master/src/definitions/Styles.d.ts
BASE_BACKGROUND_COLOR: 'transparent',
BASE_COLOR: theme.textColorPrimary,
LOG_COLOR: theme.textColorPrimary,
LOG_BACKGROUND: 'transparent',
LOG_INFO_BACKGROUND: 'transparent',
LOG_COMMAND_BACKGROUND: 'transparent',
LOG_RESULT_BACKGROUND: 'transparent',
LOG_WARN_BACKGROUND: theme.warningColor,
LOG_ERROR_BACKGROUND: theme.errorColor,
LOG_INFO_COLOR: theme.textColorPrimary,
LOG_COMMAND_COLOR: theme.textColorSecondary,
LOG_RESULT_COLOR: theme.textColorSecondary,
LOG_WARN_COLOR: 'white',
LOG_ERROR_COLOR: 'white',
LOG_INFO_BORDER: theme.dividerColor,
LOG_COMMAND_BORDER: theme.dividerColor,
LOG_RESULT_BORDER: theme.dividerColor,
LOG_WARN_BORDER: theme.dividerColor,
LOG_ERROR_BORDER: theme.dividerColor,
LOG_BORDER: theme.dividerColor,
};
}

View File

@@ -0,0 +1,420 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
FlexColumn,
styled,
Text,
FlexRow,
Glyph,
LoadingIndicator,
colors,
Spacer,
Button,
FlexBox,
Checkbox,
} from '../ui';
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {State as Store} from '../reducers';
import {
HealthcheckResult,
HealthcheckReportCategory,
HealthcheckReport,
startHealthchecks,
finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
resetAcknowledgedProblems,
} from '../reducers/healthchecks';
import runHealthchecks, {
HealthcheckSettings,
HealthcheckEventsHandler,
} from '../utils/runHealthchecks';
import {getFlipperLib} from 'flipper-plugin';
import {reportUsage} from 'flipper-common';
type StateFromProps = {
healthcheckReport: HealthcheckReport;
} & HealthcheckSettings;
type DispatchFromProps = {
acknowledgeProblems: () => void;
resetAcknowledgedProblems: () => void;
} & HealthcheckEventsHandler;
const Container = styled(FlexColumn)({
padding: 20,
width: 600,
});
const HealthcheckDisplayContainer = styled(FlexRow)({
alignItems: 'center',
marginBottom: 5,
});
const HealthcheckListContainer = styled(FlexColumn)({
marginBottom: 20,
width: 300,
});
const Title = styled(Text)({
marginBottom: 18,
marginRight: 10,
fontWeight: 100,
fontSize: '40px',
});
const CategoryContainer = styled(FlexColumn)({
marginBottom: 5,
marginLeft: 20,
marginRight: 20,
});
const SideContainer = styled(FlexBox)({
marginBottom: 20,
padding: 20,
backgroundColor: colors.highlightBackground,
border: '1px solid #b3b3b3',
width: 250,
});
const SideContainerText = styled(Text)({
display: 'block',
wordWrap: 'break-word',
overflow: 'auto',
});
const HealthcheckLabel = styled(Text)({
paddingLeft: 5,
});
const SkipReasonLabel = styled(Text)({
paddingLeft: 21,
fontStyle: 'italic',
});
const CenteredContainer = styled.label({
display: 'flex',
alignItems: 'center',
});
type OwnProps = {
onHide: () => void;
};
function CenteredCheckbox(props: {
checked: boolean;
text: string;
onChange: (checked: boolean) => void;
}) {
const {checked, onChange, text} = props;
return (
<CenteredContainer>
<Checkbox checked={checked} onChange={onChange} />
{text}
</CenteredContainer>
);
}
function HealthcheckIcon(props: {checkResult: HealthcheckResult}) {
const {checkResult: check} = props;
switch (props.checkResult.status) {
case 'IN_PROGRESS':
return <LoadingIndicator size={16} title={props.checkResult.message} />;
case 'SKIPPED':
return (
<Glyph
size={16}
name={'question'}
color={colors.gray}
title={props.checkResult.message}
/>
);
case 'SUCCESS':
return (
<Glyph
size={16}
name={'checkmark'}
color={colors.green}
title={props.checkResult.message}
/>
);
case 'FAILED':
return (
<Glyph
size={16}
name={'cross'}
color={colors.red}
title={props.checkResult.message}
variant={check.isAcknowledged ? 'outline' : 'filled'}
/>
);
default:
return (
<Glyph
size={16}
name={'caution'}
color={colors.yellow}
title={props.checkResult.message}
/>
);
}
}
function HealthcheckDisplay(props: {
label: string;
result: HealthcheckResult;
selected?: boolean;
onClick?: () => void;
}) {
return (
<FlexColumn shrink>
<HealthcheckDisplayContainer shrink title={props.result.message}>
<HealthcheckIcon checkResult={props.result} />
<HealthcheckLabel
bold={props.selected}
underline={!!props.onClick}
cursor={props.onClick && 'pointer'}
onClick={props.onClick}>
{props.label}
</HealthcheckLabel>
</HealthcheckDisplayContainer>
</FlexColumn>
);
}
function SideMessageDisplay(props: {children: React.ReactNode}) {
return <SideContainerText selectable>{props.children}</SideContainerText>;
}
function ResultMessage(props: {result: HealthcheckResult}) {
if (status === 'IN_PROGRESS') {
return <p>Doctor is running healthchecks...</p>;
} else if (hasProblems(props.result)) {
return (
<p>
Doctor has discovered problems with your installation. Please click to
an item to get its details.
</p>
);
} else {
return (
<p>
All good! Doctor has not discovered any issues with your installation.
</p>
);
}
}
function hasProblems(result: HealthcheckResult) {
const {status} = result;
return status === 'FAILED' || status === 'WARNING';
}
function hasNewProblems(result: HealthcheckResult) {
return hasProblems(result) && !result.isAcknowledged;
}
type State = {
acknowledgeCheckboxVisible: boolean;
acknowledgeOnClose?: boolean;
selectedCheckKey?: string;
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
class DoctorSheet extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
acknowledgeCheckboxVisible: false,
};
}
componentDidMount() {
reportUsage('doctor:report:opened');
}
static getDerivedStateFromProps(props: Props, state: State): State | null {
if (
!state.acknowledgeCheckboxVisible &&
hasProblems(props.healthcheckReport.result)
) {
return {
...state,
acknowledgeCheckboxVisible: true,
acknowledgeOnClose:
state.acknowledgeOnClose === undefined
? !hasNewProblems(props.healthcheckReport.result)
: state.acknowledgeOnClose,
};
}
if (
state.acknowledgeCheckboxVisible &&
!hasProblems(props.healthcheckReport.result)
) {
return {
...state,
acknowledgeCheckboxVisible: false,
};
}
return null;
}
componentWillUnmount(): void {
if (this.state.acknowledgeOnClose) {
if (hasNewProblems(this.props.healthcheckReport.result)) {
reportUsage('doctor:report:closed:newProblems:acknowledged');
}
reportUsage('doctor:report:closed:acknowleged');
this.props.acknowledgeProblems();
} else {
if (hasNewProblems(this.props.healthcheckReport.result)) {
reportUsage('doctor:report:closed:newProblems:notAcknowledged');
}
reportUsage('doctor:report:closed:notAcknowledged');
this.props.resetAcknowledgedProblems();
}
}
onAcknowledgeOnCloseChanged(acknowledge: boolean): void {
this.setState((prevState) => {
return {
...prevState,
acknowledgeOnClose: acknowledge,
};
});
}
openHelpUrl(helpUrl?: string): void {
helpUrl && getFlipperLib().openLink(helpUrl);
}
async runHealthchecks(): Promise<void> {
await runHealthchecks(this.props);
}
getCheckMessage(checkKey: string): string {
for (const cat of Object.values(this.props.healthcheckReport.categories)) {
const check = Object.values(cat.checks).find(
(chk) => chk.key === checkKey,
);
if (check) {
return check.result.message || '';
}
}
return '';
}
render() {
return (
<Container>
<Title>Doctor</Title>
<FlexRow>
<HealthcheckListContainer>
{Object.values(this.props.healthcheckReport.categories).map(
(category: HealthcheckReportCategory) => {
return (
<CategoryContainer key={category.key}>
<HealthcheckDisplay
label={category.label}
result={category.result}
/>
{category.result.status !== 'SKIPPED' && (
<CategoryContainer>
{Object.values(category.checks).map((check) => (
<HealthcheckDisplay
key={check.key}
selected={check.key === this.state.selectedCheckKey}
label={check.label}
result={check.result}
onClick={() =>
this.setState({
...this.state,
selectedCheckKey:
this.state.selectedCheckKey === check.key
? undefined
: check.key,
})
}
/>
))}
</CategoryContainer>
)}
{category.result.status === 'SKIPPED' && (
<CategoryContainer>
<SkipReasonLabel>
{category.result.message}
</SkipReasonLabel>
</CategoryContainer>
)}
</CategoryContainer>
);
},
)}
</HealthcheckListContainer>
<Spacer />
<SideContainer shrink>
<SideMessageDisplay>
<SideContainerText selectable>
{this.state.selectedCheckKey && (
<p>{this.getCheckMessage(this.state.selectedCheckKey)}</p>
)}
{!this.state.selectedCheckKey && (
<ResultMessage result={this.props.healthcheckReport.result} />
)}
</SideContainerText>
</SideMessageDisplay>
</SideContainer>
</FlexRow>
<FlexRow>
<Spacer />
{this.state.acknowledgeCheckboxVisible && (
<CenteredCheckbox
checked={!!this.state.acknowledgeOnClose}
onChange={this.onAcknowledgeOnCloseChanged.bind(this)}
text={
'Do not show warning about these problems on Flipper startup'
}
/>
)}
<Button compact padded onClick={this.props.onHide}>
Close
</Button>
<Button
disabled={
this.props.healthcheckReport.result.status === 'IN_PROGRESS'
}
type="primary"
compact
padded
onClick={() => this.runHealthchecks()}>
Re-run
</Button>
</FlexRow>
</Container>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({healthchecks: {healthcheckReport}, settingsState}) => ({
healthcheckReport,
settings: settingsState,
}),
{
startHealthchecks,
finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
resetAcknowledgedProblems,
},
)(DoctorSheet);

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {connect} from 'react-redux';
import React, {Component} from 'react';
import {State as Store} from '../reducers';
import ListView from './ListView';
import {FlexColumn, styled} from '../ui';
import {getExportablePlugins} from '../selectors/connections';
type OwnProps = {
onHide: () => void;
selectedPlugins: Array<string>;
setSelectedPlugins: (plugins: string[]) => void;
};
type StateFromProps = {
availablePluginsToExport: Array<{id: string; label: string}>;
};
type Props = OwnProps & StateFromProps;
const Container = styled(FlexColumn)({
maxHeight: 700,
padding: 8,
});
class ExportDataPluginSheet extends Component<Props, {}> {
render() {
return (
<Container>
<ListView
type="multiple"
title="Select the plugins for which you want to export the data"
leftPadding={8}
onChange={(selectedArray) => {
this.props.setSelectedPlugins(selectedArray);
}}
elements={this.props.availablePluginsToExport}
selectedElements={new Set(this.props.selectedPlugins)}
onHide={() => {}}
/>
</Container>
);
}
}
export default connect<StateFromProps, {}, OwnProps, Store>((state) => {
const availablePluginsToExport = getExportablePlugins(state);
return {
availablePluginsToExport,
};
})(ExportDataPluginSheet);

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Layout} from '../ui';
import React from 'react';
import {Tab, Tabs} from 'flipper-plugin';
import {ConsoleLogs} from './ConsoleLogs';
import {FlipperMessages} from './FlipperMessages';
export function FlipperDevTools() {
return (
<Layout.Container grow>
<Tabs grow>
<Tab tab="Console">
<ConsoleLogs />
</Tab>
<Tab tab="Messages">
<FlipperMessages />
</Tab>
</Tabs>
</Layout.Container>
);
}

View File

@@ -0,0 +1,205 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DataInspector,
DataTable,
DataTableColumn,
Layout,
createState,
createDataSource,
theme,
styled,
useValue,
} from 'flipper-plugin';
import {Button} from 'antd';
import {
DeleteOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import React, {useCallback, useState} from 'react';
export type MessageInfo = {
time?: Date;
device?: string;
app: string;
flipperInternalMethod?: string;
plugin?: string;
pluginMethod?: string;
payload?: any;
direction:
| 'toClient:call'
| 'toClient:send'
| 'toFlipper:message'
| 'toFlipper:response';
};
export interface MessageRow extends MessageInfo {
time: Date;
}
const Placeholder = styled(Layout.Container)({
center: true,
color: theme.textColorPlaceholder,
fontSize: 18,
});
function createRow(message: MessageInfo): MessageRow {
return {
...message,
time: message.time == null ? new Date() : message.time,
};
}
const COLUMN_CONFIG: DataTableColumn<MessageRow>[] = [
{
key: 'time',
title: 'Time',
},
{
key: 'device',
title: 'Device',
},
{
key: 'app',
title: 'App',
},
{
key: 'flipperInternalMethod',
title: 'Flipper Internal Method',
},
{
key: 'plugin',
title: 'Plugin',
},
{
key: 'pluginMethod',
title: 'Method',
},
{
key: 'direction',
title: 'Direction',
},
];
const flipperDebugMessages = createDataSource<MessageRow>([], {
limit: 1024 * 10,
persist: 'messages',
});
const flipperDebugMessagesEnabled = createState(false);
export function registerFlipperDebugMessage(message: MessageInfo) {
if (flipperDebugMessagesEnabled.get()) {
flipperDebugMessages.append(createRow(message));
}
}
export function isFlipperMessageDebuggingEnabled(): boolean {
return flipperDebugMessagesEnabled.get();
}
// exposed for testing
export function setFlipperMessageDebuggingEnabled(value: boolean) {
flipperDebugMessagesEnabled.set(value);
}
// exposed for testing
export function clearFlipperDebugMessages() {
flipperDebugMessages.clear();
}
// exposed for testing ONLY!
export function getFlipperDebugMessages() {
return flipperDebugMessages.records();
}
function Sidebar({selection}: {selection: undefined | MessageRow}) {
const renderExtra = (extra: any) => (
<DataInspector data={extra} expandRoot={false} />
);
return (
<Layout.ScrollContainer pad>
{selection != null ? (
renderExtra(selection.payload)
) : (
<Placeholder grow pad="large">
Select a message to view details
</Placeholder>
)}
</Layout.ScrollContainer>
);
}
const PauseResumeButton = () => {
const paused = !useValue(flipperDebugMessagesEnabled);
return (
<Button
title={`Click to enable tracing flipper messages`}
danger={!paused}
onClick={() => {
flipperDebugMessagesEnabled.update((v) => !v);
}}>
{paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
</Button>
);
};
export function FlipperMessages() {
const [selection, setSelection] = useState<MessageRow | undefined>();
const paused = !useValue(flipperDebugMessagesEnabled);
const clearTableButton = (
<Button
title="Clear logs"
onClick={() => {
clearFlipperDebugMessages();
setSelection(undefined);
}}>
<DeleteOutlined />
</Button>
);
const renderEmpty = useCallback(
() => (
<Layout.Container center pad gap style={{width: '100%', marginTop: 200}}>
{paused ? (
<>
Click to enable debugging Flipper messages between the Flipper
application and connected clients: <PauseResumeButton />
</>
) : (
'Waiting for data...'
)}
</Layout.Container>
),
[paused],
);
return (
<Layout.Right resizable width={400}>
<DataTable<MessageRow>
dataSource={flipperDebugMessages}
columns={COLUMN_CONFIG}
onSelect={setSelection}
enableAutoScroll
onRenderEmpty={renderEmpty}
extraActions={
<>
<PauseResumeButton />
{clearTableButton}
</>
}
/>
<Sidebar selection={selection} />
</Layout.Right>
);
}

View File

@@ -0,0 +1,92 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useEffect, useRef} from 'react';
import {fpsEmitter} from '../dispatcher/tracking';
const width = 36;
const height = 36;
const graphHeight = 20;
export default function FpsGraph({sampleRate = 200}: {sampleRate?: number}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const fps: number[] = new Array<number>(width).fill(0, 0, width);
let lastFps = 0;
let lastDraw = Date.now();
const handler = (xfps: number) => {
// at any interval, take the lowest to better show slow downs
lastFps = Math.min(lastFps, xfps);
};
const interval = setInterval(() => {
const ctx = canvasRef.current!.getContext('2d')!;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = '#ddd';
const now = Date.now();
let missedFrames = 0;
// check if we missed some measurements, in that case the CPU was fully choked!
for (let i = 0; i < Math.floor((now - lastDraw) / sampleRate) - 1; i++) {
fps.push(0);
fps.shift();
missedFrames++;
}
lastDraw = now;
// latest measurement
fps.push(lastFps);
fps.shift();
ctx.font = 'lighter 10px arial';
ctx.strokeText(
'' +
(missedFrames
? // if we were chocked, show FPS based on frames missed
Math.floor((1000 / sampleRate) * missedFrames)
: lastFps) +
' fps',
0,
height - 4,
);
ctx.moveTo(0, height);
ctx.beginPath();
ctx.lineWidth = 1;
fps.forEach((num, idx) => {
ctx.lineTo(idx, graphHeight - (Math.min(60, num) / 60) * graphHeight);
});
ctx.strokeStyle = missedFrames ? '#ff0000' : '#ddd';
ctx.stroke();
lastFps = 60;
}, sampleRate);
fpsEmitter.on('fps', handler);
return () => {
clearInterval(interval);
fpsEmitter.off('fps', handler);
};
}, []);
return (
<div style={{width, height}}>
<canvas
ref={canvasRef}
width={width}
height={height}
title="Current framerate in FPS"
/>
</div>
);
}

View File

@@ -0,0 +1,256 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
Text,
FlexColumn,
styled,
FlexRow,
Button,
Spacer,
Checkbox,
Radio,
View,
Tooltip,
Glyph,
} from '../ui';
import React, {Component} from 'react';
import {theme} from 'flipper-plugin';
export type SelectionType = 'multiple' | 'single';
type SubType =
| {
selectedElements: Set<string>;
type: 'multiple';
}
| {
selectedElement: string;
type: 'single';
};
export type Element = {
label: string;
id: string;
unselectable?: {toolTipMessage: string};
};
type Props = {
onSubmit?: () => void;
onChange: (elements: Array<string>) => void;
onHide: () => any;
elements: Array<Element>;
title?: string;
leftPadding?: number;
} & SubType;
const Title = styled(Text)({
margin: 6,
});
type State = {
selectedElements: Set<string>;
};
const Container = styled(FlexColumn)({
padding: '8 0',
});
const Line = styled(View)({
backgroundColor: theme.dividerColor,
height: 1,
width: 'auto',
flexShrink: 0,
});
const RowComponentContainer = styled(FlexColumn)({
overflow: 'scroll',
height: 'auto',
backgroundColor: theme.backgroundDefault,
maxHeight: 500,
});
const Padder = styled.div<{
paddingLeft?: number;
paddingRight?: number;
paddingBottom?: number;
paddingTop?: number;
}>(({paddingLeft, paddingRight, paddingBottom, paddingTop}) => ({
paddingLeft: paddingLeft || 0,
paddingRight: paddingRight || 0,
paddingBottom: paddingBottom || 0,
paddingTop: paddingTop || 0,
}));
type RowComponentProps = {
id: string;
label: string;
selected: boolean;
onChange: (name: string, selected: boolean) => void;
disabled: boolean;
toolTipMessage?: string;
type: SelectionType;
leftPadding?: number;
};
class RowComponent extends Component<RowComponentProps> {
render() {
const {
id,
label,
selected,
onChange,
disabled,
toolTipMessage,
type,
leftPadding,
} = this.props;
return (
<FlexColumn>
<Tooltip
title={disabled ? toolTipMessage : null}
options={{position: 'toRight'}}>
<Padder
paddingRight={0}
paddingTop={8}
paddingBottom={8}
paddingLeft={leftPadding || 0}>
<FlexRow style={{alignItems: 'center'}}>
<Text color={disabled ? theme.disabledColor : undefined}>
{label}
</Text>
<Spacer />
{disabled && (
<Glyph
name="caution-triangle"
color={theme.dividerColor}
size={12}
variant="filled"
style={{marginRight: 5}}
/>
)}
{type === 'multiple' && (
<Checkbox
disabled={disabled}
checked={selected}
onChange={(selected) => {
onChange(id, selected);
}}
/>
)}
{type === 'single' && (
<Radio
disabled={disabled}
checked={selected}
onChange={(selected) => {
onChange(id, selected);
}}
/>
)}
</FlexRow>
</Padder>
<Line />
</Tooltip>
</FlexColumn>
);
}
}
/**
* @deprecated use Ant Design instead
*/
export default class ListView extends Component<Props, State> {
state: State = {selectedElements: new Set([])};
static getDerivedStateFromProps(props: Props, _state: State) {
if (props.type === 'multiple') {
return {selectedElements: props.selectedElements};
} else if (props.type === 'single') {
return {selectedElements: new Set([props.selectedElement])};
}
return null;
}
handleChange = (id: string, selected: boolean) => {
let selectedElements: Set<string> = new Set([]);
if (this.props.type === 'single') {
if (!selected) {
this.setState({selectedElements: selectedElements});
this.props.onChange([...selectedElements]);
} else {
selectedElements.add(id);
this.setState({selectedElements: selectedElements});
this.props.onChange([...selectedElements]);
}
} else {
if (selected) {
selectedElements = new Set([...this.state.selectedElements, id]);
this.props.onChange([...selectedElements]);
} else {
selectedElements = new Set([...this.state.selectedElements]);
selectedElements.delete(id);
this.props.onChange([...selectedElements]);
}
}
};
render() {
const {onSubmit, type, leftPadding} = this.props;
return (
<Container>
<FlexColumn>
{this.props.title && <Title>{this.props.title}</Title>}
<RowComponentContainer>
{this.props.elements.map(({id, label, unselectable}) => {
return (
<RowComponent
id={id}
label={label}
key={id}
type={type}
selected={this.state.selectedElements.has(id)}
onChange={this.handleChange}
disabled={unselectable != null}
toolTipMessage={unselectable?.toolTipMessage}
leftPadding={leftPadding}
/>
);
})}
</RowComponentContainer>
</FlexColumn>
{onSubmit && (
<Padder paddingTop={8} paddingBottom={2}>
<FlexRow>
<Spacer />
<Padder paddingRight={8}>
<Button compact padded onClick={this.props.onHide}>
Close
</Button>
</Padder>
<Tooltip
title={
this.state.selectedElements.size <= 0
? `Please select atleast one plugin`
: null
}
options={{position: 'toRight'}}>
<Button
compact
padded
type="primary"
onClick={onSubmit}
disabled={this.state.selectedElements.size <= 0}>
Submit
</Button>
</Tooltip>
</FlexRow>
</Padder>
)}
</Container>
);
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useCallback, useEffect, useState} from 'react';
import {MetroReportableEvent} from 'flipper-common';
import {useStore} from '../utils/useStore';
import {Button as AntButton} from 'antd';
import {MenuOutlined, ReloadOutlined} from '@ant-design/icons';
import {theme} from 'flipper-plugin';
import BaseDevice from '../devices/BaseDevice';
export default function MetroButton() {
const device = useStore((state) =>
state.connections.devices.find(
(device) => device.os === 'Metro' && device.connected.get(),
),
) as BaseDevice | undefined;
const sendCommand = useCallback(
(command: string) => {
device?.sendMetroCommand(command);
},
[device],
);
const [progress, setProgress] = useState(1);
const [_hasBuildError, setHasBuildError] = useState(false);
useEffect(() => {
if (!device) {
return;
}
function metroEventListener(event: MetroReportableEvent) {
if (event.type === 'bundle_build_started') {
setHasBuildError(false);
setProgress(0);
} else if (event.type === 'bundle_build_failed') {
setHasBuildError(true);
setProgress(1);
} else if (event.type === 'bundle_build_done') {
setHasBuildError(false);
setProgress(1);
} else if (event.type === 'bundle_transform_progressed') {
setProgress(event.transformedFileCount / event.totalFileCount);
}
}
const handle = device.addLogListener((l) => {
if (l.tag !== 'client_log') {
try {
metroEventListener(JSON.parse(l.message));
} catch (e) {
console.warn('Failed to parse metro message: ', l, e);
}
}
});
return () => {
device.removeLogListener(handle);
};
}, [device]);
if (!device) {
return null;
}
return (
<>
<AntButton
icon={<ReloadOutlined />}
title="Reload React Native App"
type="ghost"
onClick={() => {
sendCommand('reload');
}}
loading={progress < 1}
style={{color: _hasBuildError ? theme.errorColor : undefined}}
/>
<AntButton
icon={<MenuOutlined />}
title="Open the React Native Dev Menu on the device"
type="ghost"
onClick={() => {
sendCommand('devMenu');
}}
/>
</>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {useEffect, useRef, useState} from 'react';
import {onBytesReceived} from '../dispatcher/tracking';
const height = 16;
const width = 36;
export default function NetworkGraph() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const lastTime = useRef(performance.now());
const lastBytes = useRef(0);
const pluginStats = useRef<Record<string, number>>({});
const [hoverText, setHoverText] = useState('');
useEffect(() => {
return onBytesReceived((plugin, bytes) => {
lastBytes.current += bytes;
if (!pluginStats.current[plugin]) {
pluginStats.current[plugin] = bytes;
} else {
pluginStats.current[plugin] += bytes;
}
});
}, []);
useEffect(() => {
const interval = setInterval(() => {
const deltaTime = performance.now() - lastTime.current;
lastTime.current = performance.now();
const deltaBytes = lastBytes.current;
lastBytes.current = 0;
// cause kiloBytesPerSecond === bytes per millisecond
const kiloBytesPerSecond = Math.round(deltaBytes / deltaTime);
const ctx = canvasRef.current!.getContext('2d')!;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = kiloBytesPerSecond >= 1000 ? '#f00' : '#ddd';
ctx.font = 'lighter 10px arial';
ctx.strokeText(`${kiloBytesPerSecond} kB/s`, 0, height - 4);
setHoverText(
'Total data traffic per plugin:\n\n' +
Object.entries(pluginStats.current)
.sort(([_p, bytes], [_p2, bytes2]) => bytes2 - bytes)
.map(([key, bytes]) => `${key}: ${Math.round(bytes / 1000)}kb`)
.join('\n'),
);
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
return (
<div style={{width, height}}>
<canvas ref={canvasRef} width={width} height={height} title={hoverText} />
</div>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component} from 'react';
import {updateSettings, Action} from '../reducers/settings';
import {connect} from 'react-redux';
import {State as Store} from '../reducers';
import {Settings} from '../reducers/settings';
import {flush} from '../utils/persistor';
import ToggledSection from './settings/ToggledSection';
import {isEqual} from 'lodash';
import {reportUsage} from 'flipper-common';
import {Modal, Button} from 'antd';
import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin';
import {getRenderHostInstance} from '../RenderHost';
const WIZARD_FINISHED_LOCAL_STORAGE_KEY = 'platformSelectWizardFinished';
type OwnProps = {
onHide: () => void;
platform: NodeJS.Platform;
};
type StateFromProps = {
settings: Settings;
};
type DispatchFromProps = {
updateSettings: (settings: Settings) => Action;
};
type State = {
updatedSettings: Settings;
forcedRestartSettings: Partial<Settings>;
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
class PlatformSelectWizard extends Component<Props, State> {
state: State = {
updatedSettings: {...this.props.settings},
forcedRestartSettings: {},
};
componentDidMount() {
reportUsage('platformwizard:opened');
}
componentWillUnmount() {
reportUsage('platformwizard:closed');
}
applyChanges = async (settingsPristine: boolean) => {
this.props.updateSettings(this.state.updatedSettings);
markWizardAsCompleted();
this.props.onHide();
return flush().then(() => {
if (!settingsPristine) {
reportUsage('platformwizard:action:changed');
getRenderHostInstance().restartFlipper();
} else {
reportUsage('platformwizard:action:noop');
}
});
};
render() {
const {enableAndroid, enableIOS} = this.state.updatedSettings;
const settingsPristine = isEqual(
this.props.settings,
this.state.updatedSettings,
);
const contents = (
<Layout.Container gap>
<Layout.Container style={{width: '100%', paddingBottom: 15}}>
<>
Please select the targets you intend to debug, so that we can
optimise the configuration for the selected targets.
</>
</Layout.Container>
<ToggledSection
label="Android Developer"
toggled={enableAndroid}
onChange={(v) => {
this.setState({
updatedSettings: {
...this.state.updatedSettings,
enableAndroid: v,
},
});
}}></ToggledSection>
<ToggledSection
label="iOS Developer"
toggled={enableIOS && this.props.platform === 'darwin'}
onChange={(v) => {
this.setState({
updatedSettings: {...this.state.updatedSettings, enableIOS: v},
});
}}></ToggledSection>
</Layout.Container>
);
const footerText = settingsPristine ? 'Looks fine' : 'Apply and Restart';
const footer = (
<>
<Button
type="primary"
onClick={() => this.applyChanges(settingsPristine)}>
{footerText}
</Button>
</>
);
return (
<Modal
visible
onCancel={() => {
this.props.onHide();
markWizardAsCompleted();
}}
width={570}
title="Select Platform Configuration"
footer={footer}>
{contents}
</Modal>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({settingsState}) => ({
settings: settingsState,
}),
{updateSettings},
)(withTrackingScope(PlatformSelectWizard));
export function hasPlatformWizardBeenDone(
localStorage: Storage | undefined,
): boolean {
return (
!localStorage ||
localStorage.getItem(WIZARD_FINISHED_LOCAL_STORAGE_KEY) !== 'true'
);
}
function markWizardAsCompleted() {
window.localStorage.setItem(WIZARD_FINISHED_LOCAL_STORAGE_KEY, 'true');
}

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DownloadOutlined,
LoadingOutlined,
PlusOutlined,
} from '@ant-design/icons';
import {Alert, Button} from 'antd';
import {
BundledPluginDetails,
DownloadablePluginDetails,
} from 'flipper-plugin-lib';
import React, {useMemo} from 'react';
import {useCallback} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {PluginDefinition} from '../plugin';
import {startPluginDownload} from '../reducers/pluginDownloads';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {
getActiveClient,
getPluginDownloadStatusMap,
} from '../selectors/connections';
import {Layout} from '../ui';
import {ActivePluginListItem} from '../utils/pluginUtils';
export function PluginActions({
activePlugin,
type,
}: {
activePlugin: ActivePluginListItem;
type: 'link' | 'primary';
}) {
switch (activePlugin.status) {
case 'disabled': {
return <EnableButton plugin={activePlugin.definition} type={type} />;
}
case 'uninstalled': {
return <InstallButton plugin={activePlugin.details} type={type} />;
}
case 'unavailable': {
return type === 'primary' ? (
<UnavailabilityAlert reason={activePlugin.reason} />
) : null;
}
default:
return null;
}
}
function EnableButton({
plugin,
type,
}: {
plugin: PluginDefinition;
type: 'link' | 'primary';
}) {
const dispatch = useDispatch();
const client = useSelector(getActiveClient);
const enableOrDisablePlugin = useCallback(() => {
dispatch(switchPlugin({plugin, selectedApp: client?.query?.app}));
}, [dispatch, plugin, client]);
return (
<Button
type={type}
icon={<PlusOutlined />}
onClick={enableOrDisablePlugin}
style={{flexGrow: type == 'primary' ? 1 : 0}}>
Enable Plugin
</Button>
);
}
function UnavailabilityAlert({reason}: {reason: string}) {
return (
<Layout.Container center>
<Alert message={reason} type="warning" />
</Layout.Container>
);
}
function InstallButton({
plugin,
type = 'primary',
}: {
plugin: DownloadablePluginDetails | BundledPluginDetails;
type: 'link' | 'primary';
}) {
const dispatch = useDispatch();
const installPlugin = useCallback(() => {
if (plugin.isBundled) {
dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
} else {
dispatch(startPluginDownload({plugin, startedByUser: true}));
}
}, [plugin, dispatch]);
const downloads = useSelector(getPluginDownloadStatusMap);
const downloadStatus = useMemo(
() => downloads.get(plugin.id),
[downloads, plugin],
);
return (
<Button
type={type}
disabled={!!downloadStatus}
icon={
downloadStatus ? (
<LoadingOutlined size={16} />
) : (
<DownloadOutlined size={16} />
)
}
onClick={installPlugin}
style={{
flexGrow: type === 'primary' ? 1 : 0,
}}>
Install Plugin
</Button>
);
}

View File

@@ -0,0 +1,154 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import Icon, {MacCommandOutlined} from '@ant-design/icons';
import {css} from '@emotion/css';
import {Button, Menu, MenuItemProps, Row, Tooltip} from 'antd';
import {
NormalizedMenuEntry,
NUX,
TrackingScope,
useTrackedCallback,
} from 'flipper-plugin';
import React, {useEffect} from 'react';
import {getRenderHostInstance} from '../RenderHost';
import {getActivePlugin} from '../selectors/connections';
import {useStore} from '../utils/useStore';
function MagicIcon() {
return (
// https://www.svgrepo.com/svg/59702/magic
<svg
width="1em"
height="1em"
viewBox="0 0 464.731 464.731"
fill="currentColor">
<title>Magic</title>
<path
d="M463.056,441.971l-45.894-43.145l29.759-55.521c0.8-1.508,0.379-3.398-1.029-4.395
c-1.388-1.011-3.305-0.832-4.487,0.424l-43.146,45.895l-55.533-29.746c-1.515-0.803-3.399-0.377-4.395,1.027
c-1.017,1.392-0.815,3.309,0.438,4.488l45.911,43.162l-29.747,55.518c-0.816,1.525-0.378,3.401,1.01,4.412
c1.41,0.996,3.326,0.816,4.502-0.438l43.149-45.912l55.507,29.746c1.506,0.802,3.393,0.378,4.393-1.027
C464.506,445.072,464.308,443.136,463.056,441.971z"
/>
<path
d="M369.086,94.641l-20.273,37.826c-1.04,1.918-0.479,4.307,1.285,5.588c1.783,1.271,4.215,1.029,5.71-0.559
l29.417-31.269l37.78,20.26c1.921,1.024,4.323,0.484,5.589-1.285c1.271-1.783,1.048-4.215-0.555-5.709l-31.245-29.385
l20.274-37.814c1.028-1.918,0.466-4.307-1.297-5.59c-1.766-1.268-4.216-1.025-5.713,0.558l-29.381,31.257l-37.814-20.273
c-1.936-1.026-4.325-0.467-5.589,1.301c-1.273,1.766-1.042,4.214,0.544,5.711L369.086,94.641z"
/>
<path
d="M123.956,360.06l-44.659,6.239l-17.611-41.484c-0.906-2.113-3.217-3.232-5.423-2.631
c-2.226,0.623-3.626,2.78-3.313,5.051l6.239,44.639L17.69,389.489c-2.1,0.908-3.23,3.217-2.614,5.424
c0.609,2.219,2.767,3.629,5.032,3.31l44.657-6.241l17.611,41.5c0.896,2.118,3.218,3.236,5.425,2.629
c2.206-0.617,3.626-2.765,3.312-5.043l-6.238-44.658l41.5-17.617c2.099-0.904,3.234-3.217,2.612-5.423
C128.383,361.147,126.221,359.745,123.956,360.06z"
/>
<path
d="M4.908,45.161l34.646,9.537l-0.23,35.832c-0.012,2.01,1.449,3.704,3.447,3.99
c1.976,0.271,3.851-0.969,4.377-2.901l9.521-34.565l35.923,0.225c2.01,0.016,3.702-1.447,3.992-3.441
c0.271-1.982-0.97-3.853-2.905-4.383l-34.627-9.547l0.213-35.881c0.018-2.01-1.466-3.701-3.441-3.988
c-1.983-0.273-3.856,0.965-4.383,2.901l-9.533,34.608L5.996,37.324c-1.991,0-3.701,1.463-3.974,3.441
C1.751,42.747,2.992,44.633,4.908,45.161z"
/>
<path
d="M278.019,234.519l139.775-18.477c1.586-0.21,2.762-1.555,2.762-3.143c0-1.587-1.176-2.928-2.762-3.142
L278.019,191.28l20.476-57.755c0.857-2.446,0.235-5.183-1.603-7.009c-1.828-1.844-4.567-2.445-7.01-1.586l-57.697,20.484
L213.708,5.688c-0.194-1.588-1.554-2.764-3.14-2.764c-1.584,0-2.935,1.176-3.146,2.764l-18.457,139.744l-57.772-20.502
c-2.448-0.875-5.181-0.258-7.014,1.586c-1.84,1.826-2.46,4.563-1.586,7.009l20.489,57.772l-139.73,18.46
c-1.584,0.214-2.762,1.555-2.762,3.142c0,1.588,1.178,2.933,2.762,3.143l139.73,18.461l-20.489,57.742
c-0.874,2.447-0.254,5.182,1.586,7.01c1.833,1.842,4.565,2.462,7.014,1.582l57.772-20.467l18.457,139.743
c0.212,1.583,1.563,2.764,3.146,2.764c1.586,0,2.945-1.181,3.14-2.764l18.477-139.743l57.727,20.486
c2.441,0.876,5.181,0.256,7.009-1.589c1.845-1.825,2.461-4.562,1.584-7.007L278.019,234.519z"
/>
</svg>
);
}
const menu = css`
border: none;
`;
const submenu = css`
.ant-menu-submenu-title {
width: 32px;
height: 32px !important;
line-height: 32px !important;
padding: 0;
margin: 0;
}
.ant-menu-submenu-arrow {
display: none;
}
`;
function PluginActionMenuItem({
label,
action,
handler,
accelerator,
// Some props like `eventKey` are auto-generated by ant-design
// We need to pass them through to MenuItem
...antdProps
}: NormalizedMenuEntry & MenuItemProps) {
const trackedHandler = useTrackedCallback(action, handler, [action, handler]);
useEffect(() => {
if (accelerator) {
const unregister = getRenderHostInstance().registerShortcut(
accelerator,
trackedHandler,
);
return unregister;
}
}, [trackedHandler, accelerator]);
return (
<Menu.Item onClick={trackedHandler} {...antdProps}>
<Row justify="space-between" align="middle">
{label}
{accelerator ? (
<Tooltip title={accelerator} placement="right">
<MacCommandOutlined />
</Tooltip>
) : null}
</Row>
</Menu.Item>
);
}
export function PluginActionsMenu() {
const menuEntries = useStore((state) => state.connections.pluginMenuEntries);
const activePlugin = useStore(getActivePlugin);
if (!menuEntries.length || !activePlugin) {
return null;
}
return (
<TrackingScope scope={`PluginActionsButton:${activePlugin.details.id}`}>
<NUX title="Use custom plugin actions and shortcuts" placement="right">
<Menu mode="vertical" className={menu} selectable={false}>
<Menu.SubMenu
popupOffset={[15, 0]}
key="pluginActions"
title={
<Button
icon={<Icon component={MagicIcon} />}
title="Plugin actions"
type="ghost"
/>
}
className={submenu}>
{menuEntries.map((entry) => (
<PluginActionMenuItem key={entry.action} {...entry} />
))}
</Menu.SubMenu>
</Menu>
</NUX>
</TrackingScope>
);
}

View File

@@ -0,0 +1,355 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {
Component,
ReactElement,
useCallback,
useEffect,
useState,
} from 'react';
import {
FlexColumn,
FlexRow,
Button,
Checkbox,
styled,
Input,
Link,
} from '../ui';
import {LeftRailButton} from '../sandy-chrome/LeftRail';
import GK from '../fb-stubs/GK';
import * as UserFeedback from '../fb-stubs/UserFeedback';
import {FeedbackPrompt} from '../fb-stubs/UserFeedback';
import {StarOutlined} from '@ant-design/icons';
import {Popover, Rate} from 'antd';
import {useStore} from '../utils/useStore';
import {isLoggedIn} from '../fb-stubs/user';
import {useValue} from 'flipper-plugin';
import {reportPlatformFailures} from 'flipper-common';
type NextAction = 'select-rating' | 'leave-comment' | 'finished';
class PredefinedComment extends Component<{
comment: string;
selected: boolean;
onClick: (_: unknown) => unknown;
}> {
static Container = styled.div<{selected: boolean}>((props) => {
return {
border: '1px solid #f2f3f5',
cursor: 'pointer',
borderRadius: 24,
backgroundColor: props.selected ? '#ecf3ff' : '#f2f3f5',
marginBottom: 4,
marginRight: 4,
padding: '4px 8px',
color: props.selected ? 'rgb(56, 88, 152)' : undefined,
borderColor: props.selected ? '#3578e5' : undefined,
':hover': {
borderColor: '#3578e5',
},
};
});
render() {
return (
<PredefinedComment.Container
onClick={this.props.onClick}
selected={this.props.selected}>
{this.props.comment}
</PredefinedComment.Container>
);
}
}
const Row = styled(FlexRow)({
marginTop: 5,
marginBottom: 5,
justifyContent: 'center',
textAlign: 'center',
color: '#9a9a9a',
flexWrap: 'wrap',
});
const DismissRow = styled(Row)({
marginBottom: 0,
marginTop: 10,
});
const DismissButton = styled.span({
'&:hover': {
textDecoration: 'underline',
cursor: 'pointer',
},
});
const Spacer = styled(FlexColumn)({
flexGrow: 1,
});
function dismissRow(dismiss: () => void) {
return (
<DismissRow key="dismiss">
<Spacer />
<DismissButton onClick={dismiss}>Dismiss</DismissButton>
<Spacer />
</DismissRow>
);
}
type FeedbackComponentState = {
rating: number | null;
hoveredRating: number;
allowUserInfoSharing: boolean;
nextAction: NextAction;
predefinedComments: {[key: string]: boolean};
comment: string;
};
class FeedbackComponent extends Component<
{
submitRating: (rating: number) => void;
submitComment: (
rating: number,
comment: string,
selectedPredefinedComments: Array<string>,
allowUserInfoSharing: boolean,
) => void;
close: () => void;
dismiss: () => void;
promptData: FeedbackPrompt;
},
FeedbackComponentState
> {
state: FeedbackComponentState = {
rating: null,
hoveredRating: 0,
allowUserInfoSharing: true,
nextAction: 'select-rating' as NextAction,
predefinedComments: this.props.promptData.predefinedComments.reduce(
(acc, cv) => ({...acc, [cv]: false}),
{},
),
comment: '',
};
onSubmitRating(newRating: number) {
const nextAction = newRating <= 2 ? 'leave-comment' : 'finished';
this.setState({rating: newRating, nextAction: nextAction});
this.props.submitRating(newRating);
if (nextAction === 'finished') {
setTimeout(this.props.close, 5000);
}
}
onCommentSubmitted(comment: string) {
this.setState({nextAction: 'finished'});
const selectedPredefinedComments: Array<string> = Object.entries(
this.state.predefinedComments,
)
.map((x) => ({comment: x[0], enabled: x[1]}))
.filter((x) => x.enabled)
.map((x) => x.comment);
const currentRating = this.state.rating;
if (currentRating) {
this.props.submitComment(
currentRating,
comment,
selectedPredefinedComments,
this.state.allowUserInfoSharing,
);
} else {
console.error('Illegal state: Submitting comment with no rating set.');
}
setTimeout(this.props.close, 1000);
}
onAllowUserSharingChanged(allowed: boolean) {
this.setState({allowUserInfoSharing: allowed});
}
render() {
let body: Array<ReactElement>;
switch (this.state.nextAction) {
case 'select-rating':
body = [
<Row key="bodyText">{this.props.promptData.bodyText}</Row>,
<Row key="stars" style={{margin: 'auto'}}>
<Rate onChange={(newRating) => this.onSubmitRating(newRating)} />
</Row>,
dismissRow(this.props.dismiss),
];
break;
case 'leave-comment':
const predefinedComments = Object.entries(
this.state.predefinedComments,
).map((c: [string, unknown], idx: number) => (
<PredefinedComment
key={idx}
comment={c[0]}
selected={Boolean(c[1])}
onClick={() =>
this.setState({
predefinedComments: {
...this.state.predefinedComments,
[c[0]]: !c[1],
},
})
}
/>
));
body = [
<Row key="predefinedComments">{predefinedComments}</Row>,
<Row key="inputRow">
<Input
style={{height: 30, width: '100%'}}
placeholder={this.props.promptData.commentPlaceholder}
value={this.state.comment}
onChange={(e) => this.setState({comment: e.target.value})}
onKeyDown={(e) =>
e.key == 'Enter' && this.onCommentSubmitted(this.state.comment)
}
autoFocus
/>
</Row>,
<Row key="contactCheckbox">
<Checkbox
checked={this.state.allowUserInfoSharing}
onChange={this.onAllowUserSharingChanged.bind(this)}
/>
{'Tool owner can contact me '}
</Row>,
<Row key="submit">
<Button onClick={() => this.onCommentSubmitted(this.state.comment)}>
Submit
</Button>
</Row>,
dismissRow(this.props.dismiss),
];
break;
case 'finished':
body = [
<Row key="thanks">
Thanks for the feedback! You can now help
<Link href="https://www.internalfb.com/intern/papercuts/?application=flipper">
prioritize bugs and features for Flipper in Papercuts
</Link>
</Row>,
dismissRow(this.props.dismiss),
];
break;
default: {
console.error('Illegal state: nextAction: ' + this.state.nextAction);
return null;
}
}
return (
<FlexColumn
style={{
width: 400,
paddingLeft: 20,
paddingRight: 20,
paddingTop: 10,
paddingBottom: 10,
}}>
<Row key="heading" style={{color: 'black', fontSize: 20}}>
{this.state.nextAction === 'finished'
? this.props.promptData.postSubmitHeading
: this.props.promptData.preSubmitHeading}
</Row>
{body}
</FlexColumn>
);
}
}
export function SandyRatingButton() {
const [promptData, setPromptData] =
useState<UserFeedback.FeedbackPrompt | null>(null);
const [isShown, setIsShown] = useState(false);
const [hasTriggered, setHasTriggered] = useState(false);
const sessionId = useStore((store) => store.application.sessionId);
const loggedIn = useValue(isLoggedIn());
const triggerPopover = useCallback(() => {
if (!hasTriggered) {
setIsShown(true);
setHasTriggered(true);
}
}, [hasTriggered]);
useEffect(() => {
if (GK.get('flipper_enable_star_ratiings') && !hasTriggered && loggedIn) {
reportPlatformFailures(
UserFeedback.getPrompt().then((prompt) => {
setPromptData(prompt);
setTimeout(triggerPopover, 30000);
}),
'RatingButton:getPrompt',
).catch((e) => {
console.warn('Failed to load ratings prompt:', e);
});
}
}, [triggerPopover, hasTriggered, loggedIn]);
const onClick = () => {
const willBeShown = !isShown;
setIsShown(willBeShown);
setHasTriggered(true);
if (!willBeShown) {
UserFeedback.dismiss(sessionId);
}
};
const submitRating = (rating: number) => {
UserFeedback.submitRating(rating, sessionId);
};
const submitComment = (
rating: number,
comment: string,
selectedPredefinedComments: Array<string>,
allowUserInfoSharing: boolean,
) => {
UserFeedback.submitComment(
rating,
comment,
selectedPredefinedComments,
allowUserInfoSharing,
sessionId,
);
};
if (!promptData) {
return null;
}
if (!promptData.shouldPopup || (hasTriggered && !isShown)) {
return null;
}
return (
<Popover
visible={isShown}
content={
<FeedbackComponent
submitRating={submitRating}
submitComment={submitComment}
close={() => {
setIsShown(false);
}}
dismiss={onClick}
promptData={promptData}
/>
}
placement="right"
trigger="click">
<LeftRailButton
icon={<StarOutlined />}
title="Rate Flipper"
onClick={onClick}
small
/>
</Popover>
);
}

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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 as AntButton, message} from 'antd';
import React, {useState, useEffect, useCallback} from 'react';
import path from 'path';
import fs from 'fs-extra';
import open from 'open';
import {capture, getCaptureLocation, getFileName} from '../utils/screenshot';
import {CameraOutlined, VideoCameraOutlined} from '@ant-design/icons';
import {useStore} from '../utils/useStore';
async function openFile(path: string | null) {
if (!path) {
return;
}
let fileStat;
try {
fileStat = await fs.stat(path);
} catch (err) {
message.error(`Couldn't open captured file: ${path}: ${err}`);
return;
}
// Rather randomly chosen. Some FSs still reserve 8 bytes for empty files.
// If this doesn't reliably catch "corrupt" files, you might want to increase this.
if (fileStat.size <= 8) {
message.error(
'Screencap file retrieved from device appears to be corrupt. Your device may not support screen recording. Sometimes restarting your device can help.',
0,
);
return;
}
try {
await open(path);
} catch (e) {
console.warn(`Opening ${path} failed with error ${e}.`);
}
}
export default function ScreenCaptureButtons() {
const selectedDevice = useStore((state) => state.connections.selectedDevice);
const [isTakingScreenshot, setIsTakingScreenshot] = useState(false);
const [isRecordingAvailable, setIsRecordingAvailable] = useState(false);
const [isRecording, setIsRecording] = useState(false);
useEffect(() => {
let canceled = false;
selectedDevice?.screenCaptureAvailable().then((result) => {
if (!canceled) {
setIsRecordingAvailable(result);
}
});
return () => {
canceled = true;
};
}, [selectedDevice]);
const handleScreenshot = useCallback(() => {
setIsTakingScreenshot(true);
return capture(selectedDevice!)
.then(openFile)
.catch((e) => {
console.error('Taking screenshot failed:', e);
message.error('Taking screenshot failed:' + e);
})
.finally(() => {
setIsTakingScreenshot(false);
});
}, [selectedDevice]);
const handleRecording = useCallback(() => {
if (!selectedDevice) {
return;
}
if (!isRecording) {
setIsRecording(true);
const videoPath = path.join(getCaptureLocation(), getFileName('mp4'));
return selectedDevice.startScreenCapture(videoPath).catch((e) => {
console.error('Failed to start recording', e);
message.error('Failed to start recording' + e);
setIsRecording(false);
});
} else {
return selectedDevice
.stopScreenCapture()
.then(openFile)
.catch((e) => {
console.error('Failed to start recording', e);
message.error('Failed to start recording' + e);
})
.finally(() => {
setIsRecording(false);
});
}
}, [selectedDevice, isRecording]);
return (
<>
<AntButton
icon={<CameraOutlined />}
title="Take Screenshot"
type="ghost"
onClick={handleScreenshot}
disabled={!selectedDevice}
loading={isTakingScreenshot}
/>
<AntButton
icon={<VideoCameraOutlined />}
title="Make Screen Recording"
type={isRecording ? 'primary' : 'ghost'}
onClick={handleRecording}
disabled={!selectedDevice || !isRecordingAvailable}
danger={isRecording}
/>
</>
);
}

View File

@@ -0,0 +1,389 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component, useContext} from 'react';
import {Radio} from 'antd';
import {updateSettings, Action} from '../reducers/settings';
import {
Action as LauncherAction,
LauncherSettings,
updateLauncherSettings,
} from '../reducers/launcherSettings';
import {connect} from 'react-redux';
import {State as Store} from '../reducers';
import {Settings, DEFAULT_ANDROID_SDK_PATH} from '../reducers/settings';
import {flush} from '../utils/persistor';
import ToggledSection from './settings/ToggledSection';
import {FilePathConfigField, ConfigText} from './settings/configFields';
import KeyboardShortcutInput from './settings/KeyboardShortcutInput';
import {isEqual, isMatch, isEmpty} from 'lodash';
import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel';
import {reportUsage} from 'flipper-common';
import {Modal, message, Button} from 'antd';
import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin';
import {getRenderHostInstance} from '../RenderHost';
type OwnProps = {
onHide: () => void;
platform: NodeJS.Platform;
noModal?: boolean; // used for testing
};
type StateFromProps = {
settings: Settings;
launcherSettings: LauncherSettings;
};
type DispatchFromProps = {
updateSettings: (settings: Settings) => Action;
updateLauncherSettings: (settings: LauncherSettings) => LauncherAction;
};
type State = {
updatedSettings: Settings;
updatedLauncherSettings: LauncherSettings;
forcedRestartSettings: Partial<Settings>;
forcedRestartLauncherSettings: Partial<LauncherSettings>;
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
class SettingsSheet extends Component<Props, State> {
state: State = {
updatedSettings: {...this.props.settings},
updatedLauncherSettings: {...this.props.launcherSettings},
forcedRestartSettings: {},
forcedRestartLauncherSettings: {},
};
componentDidMount() {
reportUsage('settings:opened');
}
applyChanges = async () => {
this.props.updateSettings(this.state.updatedSettings);
this.props.updateLauncherSettings(this.state.updatedLauncherSettings);
this.props.onHide();
return flush().then(() => {
getRenderHostInstance().restartFlipper(true);
});
};
applyChangesWithoutRestart = async () => {
this.props.updateSettings(this.state.updatedSettings);
this.props.updateLauncherSettings(this.state.updatedLauncherSettings);
await flush();
this.props.onHide();
};
renderSandyContainer(
contents: React.ReactElement,
footer: React.ReactElement,
) {
return (
<Modal
visible
onCancel={this.props.onHide}
width={570}
title="Settings"
footer={footer}
bodyStyle={{
overflow: 'scroll',
maxHeight: 'calc(100vh - 250px)',
}}>
{contents}
</Modal>
);
}
render() {
const {
enableAndroid,
androidHome,
enableIOS,
enablePhysicalIOS,
enablePrefetching,
idbPath,
reactNative,
darkMode,
suppressPluginErrors,
} = this.state.updatedSettings;
const settingsPristine =
isEqual(this.props.settings, this.state.updatedSettings) &&
isEqual(this.props.launcherSettings, this.state.updatedLauncherSettings);
const forcedRestart =
(!isEmpty(this.state.forcedRestartSettings) &&
!isMatch(this.props.settings, this.state.forcedRestartSettings)) ||
(!isEmpty(this.state.forcedRestartLauncherSettings) &&
!isMatch(
this.props.launcherSettings,
this.state.forcedRestartLauncherSettings,
));
const contents = (
<Layout.Container gap>
<ToggledSection
label="Android Developer"
toggled={enableAndroid}
onChange={(v) => {
this.setState({
updatedSettings: {
...this.state.updatedSettings,
enableAndroid: v,
},
});
}}>
<FilePathConfigField
label="Android SDK location"
resetValue={DEFAULT_ANDROID_SDK_PATH}
defaultValue={androidHome}
onChange={(v) => {
this.setState({
updatedSettings: {
...this.state.updatedSettings,
androidHome: v,
},
});
}}
/>
</ToggledSection>
<ToggledSection
label="iOS Developer"
toggled={enableIOS && this.props.platform === 'darwin'}
onChange={(v) => {
this.setState({
updatedSettings: {...this.state.updatedSettings, enableIOS: v},
});
}}>
{' '}
{this.props.platform === 'darwin' && (
<ConfigText
content={'Use "xcode-select" to switch between Xcode versions'}
/>
)}
{this.props.platform !== 'darwin' && (
<ConfigText
content={
'iOS development has limited functionality on non-MacOS devices'
}
/>
)}
<ToggledSection
label="Enable physical iOS devices"
toggled={enablePhysicalIOS}
frozen={false}
onChange={(v) => {
this.setState({
updatedSettings: {
...this.state.updatedSettings,
enablePhysicalIOS: v,
},
});
}}>
<FilePathConfigField
label="IDB binary location"
defaultValue={idbPath}
isRegularFile
onChange={(v) => {
this.setState({
updatedSettings: {...this.state.updatedSettings, idbPath: v},
});
}}
/>
</ToggledSection>
</ToggledSection>
<LauncherSettingsPanel
isPrefetchingEnabled={enablePrefetching}
onEnablePrefetchingChange={(v) => {
this.setState({
updatedSettings: {
...this.state.updatedSettings,
enablePrefetching: v,
},
});
}}
isLocalPinIgnored={this.state.updatedLauncherSettings.ignoreLocalPin}
onIgnoreLocalPinChange={(v) => {
this.setState({
updatedLauncherSettings: {
...this.state.updatedLauncherSettings,
ignoreLocalPin: v,
},
});
}}
releaseChannel={this.state.updatedLauncherSettings.releaseChannel}
onReleaseChannelChange={(v) => {
this.setState({
updatedLauncherSettings: {
...this.state.updatedLauncherSettings,
releaseChannel: v,
},
forcedRestartLauncherSettings: {
...this.state.forcedRestartLauncherSettings,
releaseChannel: v,
},
});
}}
/>
<ToggledSection
label="Suppress error notifications send from client plugins"
toggled={suppressPluginErrors}
onChange={(enabled) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
suppressPluginErrors: enabled,
},
}));
}}
/>
<Layout.Container style={{paddingLeft: 15, paddingBottom: 10}}>
Theme Selection
<Radio.Group
value={darkMode}
onChange={(event) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
darkMode: event.target.value,
},
}));
}}>
<Radio.Button value="dark">Dark</Radio.Button>
<Radio.Button value="light">Light</Radio.Button>
<Radio.Button value="system">Use System Setting</Radio.Button>
</Radio.Group>
</Layout.Container>
<ToggledSection
label="React Native keyboard shortcuts"
toggled={reactNative.shortcuts.enabled}
onChange={(enabled) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
enabled,
},
},
},
}));
}}>
<KeyboardShortcutInput
label="Reload application"
value={reactNative.shortcuts.reload}
onChange={(reload) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
reload,
},
},
},
}));
}}
/>
<KeyboardShortcutInput
label="Open developer menu"
value={reactNative.shortcuts.openDevMenu}
onChange={(openDevMenu) => {
this.setState((prevState) => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
openDevMenu,
},
},
},
}));
}}
/>
</ToggledSection>
<Layout.Right center>
<span>Reset all new user tooltips</span>
<ResetTooltips />
</Layout.Right>
<Layout.Right center>
<span>Reset all local storage based state</span>
<ResetLocalState />
</Layout.Right>
</Layout.Container>
);
const footer = (
<>
<Button onClick={this.props.onHide}>Cancel</Button>
<Button
disabled={settingsPristine || forcedRestart}
onClick={this.applyChangesWithoutRestart}>
Apply
</Button>
<Button
disabled={settingsPristine}
type="primary"
onClick={this.applyChanges}>
Apply and Restart
</Button>
</>
);
return this.props.noModal ? (
<>
{contents}
{footer}
</>
) : (
this.renderSandyContainer(contents, footer)
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({settingsState, launcherSettingsState}) => ({
settings: settingsState,
launcherSettings: launcherSettingsState,
}),
{updateSettings, updateLauncherSettings},
)(withTrackingScope(SettingsSheet));
function ResetTooltips() {
const nuxManager = useContext(_NuxManagerContext);
return (
<Button
onClick={() => {
nuxManager.resetHints();
}}>
Reset hints
</Button>
);
}
function ResetLocalState() {
return (
<Button
danger
onClick={() => {
window.localStorage.clear();
message.success('Local storage state cleared');
}}>
Reset all state
</Button>
);
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {PureComponent} from 'react';
import {Text, styled, Info, VBox} from '../ui';
type Props = {
errors: Array<Error>;
title: string;
type: 'info' | 'spinning' | 'warning' | 'error';
};
const ErrorMessage = styled(Text)({
display: 'block',
marginTop: 6,
wordBreak: 'break-all',
whiteSpace: 'pre-line',
lineHeight: 1.35,
});
const Title = styled(Text)({
marginBottom: 6,
});
export function formatError(e: Error): string {
const estr = e.toString();
if (estr === '[object Object]') {
try {
return JSON.stringify(e);
} catch (e) {
return '<unrepresentable error>';
}
}
return estr;
}
export default class Popover extends PureComponent<Props> {
render() {
if (this.props.errors.length === 0) {
return null;
}
return (
<VBox scrollable maxHeight={300}>
<Info type={this.props.type}>
<Title bold>{this.props.title}</Title>
{this.props.errors.map((e: Error, index) => (
<ErrorMessage code key={index}>
{formatError(e)}
</ErrorMessage>
))}
</Info>
</VBox>
);
}
}

View File

@@ -0,0 +1,211 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlexColumn, Button, styled, Text, FlexRow, Spacer} from '../ui';
import React, {Component} from 'react';
import {reportPlatformFailures} from 'flipper-common';
import {performance} from 'perf_hooks';
import {Logger} from 'flipper-common';
import {IdlerImpl} from '../utils/Idler';
import {
exportStoreToFile,
EXPORT_FLIPPER_TRACE_EVENT,
displayFetchMetadataErrors,
} from '../utils/exportData';
import ShareSheetErrorList from './ShareSheetErrorList';
import ShareSheetPendingDialog from './ShareSheetPendingDialog';
import {ReactReduxContext, ReactReduxContextValue} from 'react-redux';
import {MiddlewareAPI} from '../reducers/index';
import {Modal} from 'antd';
const Container = styled(FlexColumn)({
padding: 20,
width: 500,
});
const ErrorMessage = styled(Text)({
display: 'block',
marginTop: 6,
wordBreak: 'break-all',
whiteSpace: 'pre-line',
lineHeight: 1.35,
});
const Title = styled(Text)({
marginBottom: 6,
});
const InfoText = styled(Text)({
lineHeight: 1.35,
marginBottom: 15,
});
type Props = {
onHide: () => void;
file: string;
logger: Logger;
};
type State = {
fetchMetaDataErrors: {
[plugin: string]: Error;
} | null;
result:
| {
kind: 'success';
}
| {
kind: 'error';
error: Error;
}
| {
kind: 'pending';
};
statusUpdate: string | null;
};
export default class ShareSheetExportFile extends Component<Props, State> {
static contextType: React.Context<ReactReduxContextValue> = ReactReduxContext;
state: State = {
fetchMetaDataErrors: null,
result: {kind: 'pending'},
statusUpdate: null,
};
get store(): MiddlewareAPI {
return this.context.store;
}
idler = new IdlerImpl();
async componentDidMount() {
const mark = 'shareSheetExportFile';
performance.mark(mark);
try {
if (!this.props.file) {
return;
}
const {fetchMetaDataErrors} = await reportPlatformFailures(
exportStoreToFile(
this.props.file,
this.store,
false,
this.idler,
(msg: string) => {
this.setState({statusUpdate: msg});
},
),
`${EXPORT_FLIPPER_TRACE_EVENT}:UI_FILE`,
);
this.setState({
fetchMetaDataErrors,
result: fetchMetaDataErrors
? {error: JSON.stringify(fetchMetaDataErrors) as any, kind: 'error'}
: {kind: 'success'},
});
this.props.logger.trackTimeSince(mark, 'export:file-success');
} catch (err) {
const result: {
kind: 'error';
error: Error;
} = {
kind: 'error',
error: err,
};
// Show the error in UI.
this.setState({result});
this.props.logger.trackTimeSince(mark, 'export:file-error', result);
console.error('Failed to export to file: ', err);
}
}
renderSuccess() {
const {title, errorArray} = displayFetchMetadataErrors(
this.state.fetchMetaDataErrors,
);
return (
<Container>
<FlexColumn>
<Title bold>Data Exported Successfully</Title>
<InfoText>
When sharing your Flipper data, consider that the captured data
might contain sensitive information like access tokens used in
network requests.
</InfoText>
<ShareSheetErrorList
errors={errorArray}
title={title}
type={'warning'}
/>
</FlexColumn>
<FlexRow>
<Spacer />
<Button compact padded onClick={() => this.cancelAndHide()}>
Close
</Button>
</FlexRow>
</Container>
);
}
renderError(result: {kind: 'error'; error: Error}) {
return (
<Container>
<Title bold>Error</Title>
<ErrorMessage code>
{result.error.message || 'File could not be saved.'}
</ErrorMessage>
<FlexRow>
<Spacer />
<Button compact padded onClick={() => this.cancelAndHide()}>
Close
</Button>
</FlexRow>
</Container>
);
}
renderPending(statusUpdate: string | null) {
return (
<ShareSheetPendingDialog
width={500}
statusUpdate={statusUpdate}
statusMessage="Creating Flipper Export..."
onCancel={() => this.cancelAndHide()}
/>
);
}
cancelAndHide = () => {
this.props.onHide();
this.idler.cancel();
};
renderStatus() {
const {result, statusUpdate} = this.state;
switch (result.kind) {
case 'success':
return this.renderSuccess();
case 'error':
return this.renderError(result);
case 'pending':
return this.renderPending(statusUpdate);
}
}
render() {
return (
<Modal visible onCancel={this.cancelAndHide} footer={null}>
{this.renderStatus()}
</Modal>
);
}
}

View File

@@ -0,0 +1,227 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlexColumn, styled, Text, FlexRow, Spacer, Input} from '../ui';
import React, {Component} from 'react';
import {ReactReduxContext, ReactReduxContextValue} from 'react-redux';
import {Logger} from 'flipper-common';
import {IdlerImpl} from '../utils/Idler';
import {
shareFlipperData,
DataExportResult,
DataExportError,
} from '../fb-stubs/user';
import {
exportStore,
EXPORT_FLIPPER_TRACE_EVENT,
displayFetchMetadataErrors,
} from '../utils/exportData';
import ShareSheetErrorList from './ShareSheetErrorList';
import {reportPlatformFailures} from 'flipper-common';
import {performance} from 'perf_hooks';
import ShareSheetPendingDialog from './ShareSheetPendingDialog';
import {getLogger} from 'flipper-common';
import {resetSupportFormV2State} from '../reducers/supportForm';
import {MiddlewareAPI} from '../reducers/index';
import {getFlipperLib, Layout} from 'flipper-plugin';
import {Button, Modal} from 'antd';
export const SHARE_FLIPPER_TRACE_EVENT = 'share-flipper-link';
const Copy = styled(Input)({
marginRight: 0,
marginBottom: 15,
});
const InfoText = styled(Text)({
lineHeight: 1.35,
marginBottom: 15,
});
const Title = styled(Text)({
marginBottom: 6,
});
const ErrorMessage = styled(Text)({
display: 'block',
marginTop: 6,
wordBreak: 'break-all',
whiteSpace: 'pre-line',
lineHeight: 1.35,
});
type Props = {
onHide: () => any;
logger: Logger;
};
type State = {
fetchMetaDataErrors: {
[plugin: string]: Error;
} | null;
result: DataExportError | DataExportResult | null;
statusUpdate: string | null;
};
export default class ShareSheetExportUrl extends Component<Props, State> {
static contextType: React.Context<ReactReduxContextValue> = ReactReduxContext;
state: State = {
fetchMetaDataErrors: null,
result: null,
statusUpdate: null,
};
get store(): MiddlewareAPI {
return this.context.store;
}
idler = new IdlerImpl();
async componentDidMount() {
const mark = 'shareSheetExportUrl';
performance.mark(mark);
try {
const statusUpdate = (msg: string) => {
this.setState({statusUpdate: msg});
};
const {serializedString, fetchMetaDataErrors} =
await reportPlatformFailures(
exportStore(this.store, false, this.idler, statusUpdate),
`${EXPORT_FLIPPER_TRACE_EVENT}:UI_LINK`,
);
const uploadMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:upload`;
performance.mark(uploadMarker);
statusUpdate('Uploading Flipper Export...');
const result = await reportPlatformFailures(
shareFlipperData(serializedString),
`${SHARE_FLIPPER_TRACE_EVENT}`,
);
if ((result as DataExportError).error != undefined) {
const res = result as DataExportError;
const err = new Error(res.error);
err.stack = res.stacktrace;
throw err;
}
getLogger().trackTimeSince(uploadMarker, uploadMarker, {
plugins: this.store.getState().plugins.selectedPlugins,
});
const flipperUrl = (result as DataExportResult).flipperUrl;
if (flipperUrl) {
getFlipperLib().writeTextToClipboard(String(flipperUrl));
new Notification('Shareable Flipper Export created', {
body: 'URL copied to clipboard',
requireInteraction: true,
});
}
this.setState({fetchMetaDataErrors, result});
this.store.dispatch(resetSupportFormV2State());
this.props.logger.trackTimeSince(mark, 'export:url-success');
} catch (e) {
const result: DataExportError = {
error_class: 'EXPORT_ERROR',
error: e,
stacktrace: '',
};
if (e instanceof Error) {
result.error = e.message;
result.stacktrace = e.stack || '';
}
// Show the error in UI.
this.setState({result});
this.props.logger.trackTimeSince(mark, 'export:url-error', result);
console.error('Failed to export to flipper trace', e);
}
}
componentDidUpdate() {
const {result} = this.state;
if (!result || !(result as DataExportResult).flipperUrl) {
return;
}
}
cancelAndHide = () => {
this.props.onHide();
this.idler.cancel();
};
renderPending(statusUpdate: string | null) {
return (
<Modal visible onCancel={this.cancelAndHide} footer={null}>
<ShareSheetPendingDialog
width={500}
statusUpdate={statusUpdate}
statusMessage="Uploading Flipper Export..."
onCancel={this.cancelAndHide}
/>
</Modal>
);
}
render() {
const {result, statusUpdate, fetchMetaDataErrors} = this.state;
if (!result) {
return this.renderPending(statusUpdate);
}
const {title, errorArray} = displayFetchMetadataErrors(fetchMetaDataErrors);
return (
<Modal visible onCancel={this.cancelAndHide} footer={null}>
<Layout.Container>
<>
<FlexColumn>
{(result as DataExportResult).flipperUrl ? (
<>
<Title bold>Data Upload Successful</Title>
<InfoText>
Flipper's data was successfully uploaded. This URL can be
used to share with other Flipper users. Opening it will
import the data from your export.
</InfoText>
<Copy
value={(result as DataExportResult).flipperUrl}
readOnly
/>
<InfoText>
When sharing your Flipper link, consider that the captured
data might contain sensitve information like access tokens
used in network requests.
</InfoText>
<ShareSheetErrorList
errors={errorArray}
title={title}
type={'warning'}
/>
</>
) : (
<>
<Title bold>
{(result as DataExportError).error_class || 'Error'}
</Title>
<ErrorMessage code>
{(result as DataExportError).error ||
'The data could not be uploaded'}
</ErrorMessage>
</>
)}
</FlexColumn>
<FlexRow>
<Spacer />
<Button type="primary" onClick={this.cancelAndHide}>
Close
</Button>
</FlexRow>
</>
</Layout.Container>
</Modal>
);
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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, Typography} from 'antd';
import {Layout, Spinner} from 'flipper-plugin';
import React from 'react';
const {Text} = Typography;
export default function (props: {
statusMessage: string;
statusUpdate: string | null;
hideNavButtons?: boolean;
onCancel?: () => void;
width?: number;
}) {
return (
<Layout.Container style={{width: props.width, textAlign: 'center'}}>
<Spinner size={30} />
{props.statusUpdate && props.statusUpdate.length > 0 ? (
<Text strong>{props.statusUpdate}</Text>
) : (
<Text strong>{props.statusMessage}</Text>
)}
{!props.hideNavButtons && props.onCancel && (
<Layout.Right>
<div />
<Button
onClick={() => {
props.onCancel && props.onCancel();
}}>
Cancel
</Button>
</Layout.Right>
)}
</Layout.Container>
);
}

View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {notification, Typography} from 'antd';
import isProduction from '../utils/isProduction';
import {reportPlatformFailures} from 'flipper-common';
import React, {useEffect, useState} from 'react';
import fbConfig from '../fb-stubs/config';
import {useStore} from '../utils/useStore';
import {getAppVersion} from '../utils/info';
import {checkForUpdate} from '../fb-stubs/checkForUpdate';
import ReleaseChannel from '../ReleaseChannel';
export type VersionCheckResult =
| {
kind: 'update-available';
url: string;
version: string;
}
| {
kind: 'up-to-date';
}
| {
kind: 'error';
msg: string;
};
export default function UpdateIndicator() {
const [versionCheckResult, setVersionCheckResult] =
useState<VersionCheckResult>({kind: 'up-to-date'});
const launcherMsg = useStore((state) => state.application.launcherMsg);
// Effect to show notification if details change
useEffect(() => {
switch (versionCheckResult.kind) {
case 'up-to-date':
break;
case 'update-available':
console.log(
`Flipper update available: ${versionCheckResult.version} at ${versionCheckResult.url}`,
);
notification.info({
placement: 'bottomLeft',
key: 'flipperupdatecheck',
message: 'Update available',
description: getUpdateAvailableMessage(versionCheckResult),
duration: null, // no auto close
});
break;
case 'error':
console.warn(
`Failed to check for Flipper update: ${versionCheckResult.msg}`,
);
break;
}
}, [versionCheckResult]);
// trigger the update check, unless there is a launcher message already
useEffect(() => {
const version = getAppVersion();
if (launcherMsg && launcherMsg.message) {
if (launcherMsg.severity === 'error') {
notification.error({
placement: 'bottomLeft',
key: 'launchermsg',
message: 'Launch problem',
description: launcherMsg.message,
duration: null,
});
} else {
notification.warning({
placement: 'bottomLeft',
key: 'launchermsg',
message: 'Flipper version warning',
description: launcherMsg.message,
duration: null,
});
}
} else if (version && isProduction()) {
reportPlatformFailures(
checkForUpdate(version).then((res) => {
if (res.kind === 'error') {
console.warn('Version check failure: ', res);
setVersionCheckResult({
kind: 'error',
msg: res.msg,
});
} else {
setVersionCheckResult(res);
}
}),
'publicVersionCheck',
);
}
}, [launcherMsg]);
return null;
}
export function getUpdateAvailableMessage(versionCheckResult: {
url: string;
version: string;
}): React.ReactNode {
return (
<>
Flipper version {versionCheckResult.version} is now available.
{fbConfig.isFBBuild ? (
fbConfig.getReleaseChannel() === ReleaseChannel.INSIDERS ? (
<> Restart Flipper to update to the latest version.</>
) : (
<>
{' '}
Run <code>arc pull</code> (optionally with <code>--latest</code>) in{' '}
<code>~/fbsource</code> and restart Flipper to update to the latest
version.
</>
)
) : (
<>
{' '}
Click to{' '}
<Typography.Link href={versionCheckResult.url}>
download
</Typography.Link>
.
</>
)}
</>
);
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component} from 'react';
import BaseDevice from '../devices/BaseDevice';
import {Button, Glyph, colors} from '../ui';
import path from 'path';
import os from 'os';
type OwnProps = {
recordingFinished: (path: string | null) => void;
};
type StateFromProps = {
selectedDevice: BaseDevice | null | undefined;
};
type DispatchFromProps = {};
type State = {
recording: boolean;
recordingEnabled: boolean;
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
export default class VideoRecordingButton extends Component<Props, State> {
state: State = {
recording: false,
recordingEnabled: true,
};
startRecording = async () => {
const {selectedDevice} = this.props;
if (!selectedDevice) {
return;
}
const flipperDirectory = path.join(os.homedir(), '.flipper');
const fileName = `screencap-${new Date()
.toISOString()
.replace(/:/g, '')}.mp4`;
const videoPath = path.join(flipperDirectory, fileName);
this.setState({
recording: true,
});
selectedDevice.startScreenCapture(videoPath).catch((e) => {
console.error('Screen recording failed:', e);
this.setState({
recording: false,
});
});
};
stopRecording = async () => {
const {selectedDevice} = this.props;
if (!selectedDevice) {
return;
}
const path = await selectedDevice.stopScreenCapture();
this.setState({
recording: false,
});
this.props.recordingFinished(path);
};
onRecordingClicked = () => {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
};
render() {
const {recordingEnabled} = this.state;
const {selectedDevice} = this.props;
return (
<Button
compact
onClick={this.onRecordingClicked}
pulse={this.state.recording}
selected={this.state.recording}
title="Make Screen Recording"
disabled={!selectedDevice || !recordingEnabled}
type={this.state.recording ? 'danger' : 'primary'}>
<Glyph
name={this.state.recording ? 'stop-playback' : 'camcorder'}
color={this.state.recording ? colors.red : colors.white}
variant="filled"
style={{marginRight: 8}}
/>
{this.state.recording ? 'Recording...' : 'Start Recording'}
</Button>
);
}
}

View File

@@ -0,0 +1,93 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
hasNewChangesToShow,
getRecentChangelog,
markChangelogRead,
} from '../ChangelogSheet';
class StubStorage {
data: Record<string, string> = {};
setItem(key: string, value: string) {
this.data[key] = value;
}
getItem(key: string) {
return this.data[key];
}
}
const changelog = `
# Version 2.0
* Nice feature one
* Important fix
# Version 1.0
* Not very exciting actually
`;
describe('ChangelogSheet', () => {
let storage!: Storage;
beforeEach(() => {
storage = new StubStorage() as any;
});
test('without storage, should show changes', () => {
expect(hasNewChangesToShow(undefined, changelog)).toBe(false);
expect(getRecentChangelog(storage, changelog)).toEqual(changelog.trim());
expect(hasNewChangesToShow(storage, changelog)).toBe(true);
});
test('with last header, should not show changes', () => {
markChangelogRead(storage, changelog);
expect(storage.data).toMatchInlineSnapshot(`
Object {
"FlipperChangelogStatus": "{\\"lastHeader\\":\\"# Version 2.0\\"}",
}
`);
expect(hasNewChangesToShow(storage, changelog)).toBe(false);
const newChangelog = `
# Version 3.0
* Cool!
# Version 2.5
* This is visible as well
${changelog}
`;
expect(hasNewChangesToShow(storage, newChangelog)).toBe(true);
expect(getRecentChangelog(storage, newChangelog)).toMatchInlineSnapshot(`
"# Version 3.0
* Cool!
# Version 2.5
* This is visible as well"
`);
markChangelogRead(storage, newChangelog);
expect(storage.data).toMatchInlineSnapshot(`
Object {
"FlipperChangelogStatus": "{\\"lastHeader\\":\\"# Version 3.0\\"}",
}
`);
expect(hasNewChangesToShow(storage, newChangelog)).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {formatError} from '../ShareSheetErrorList';
test('normal error is formatted', () => {
const e = new Error('something went wrong');
expect(formatError(e)).toEqual('Error: something went wrong');
});
test('objects are formatted', () => {
const e: any = {iam: 'not an error'};
expect(formatError(e)).toEqual('{"iam":"not an error"}');
});
test('recursive data structures are not formatted', () => {
const e: any = {b: null};
e.b = e;
expect(formatError(e)).toEqual('<unrepresentable error>');
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ShareSheetPendingDialog from '../ShareSheetPendingDialog';
import React from 'react';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
const mockStore = configureStore([])({application: {sessionId: 'mysession'}});
import {Provider} from 'react-redux';
test('ShareSheetPendingDialog is rendered with status update', () => {
const component = (
<Provider store={mockStore}>
<ShareSheetPendingDialog
onCancel={() => {}}
statusMessage="wubba lubba dub dub"
statusUpdate="Update"
/>
</Provider>
);
expect(renderer.create(component).toJSON()).toMatchSnapshot();
});
test('ShareSheetPendingDialog is rendered without status update', () => {
const component = (
<Provider store={mockStore}>
<ShareSheetPendingDialog
onCancel={() => {}}
statusMessage="wubba lubba dub dub"
statusUpdate={null}
/>
</Provider>
);
expect(renderer.create(component).toJSON()).toMatchSnapshot();
});

View File

@@ -0,0 +1,135 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShareSheetPendingDialog is rendered with status update 1`] = `
<div
className="css-gzchr8-Container e1hsqii15"
style={
Object {
"textAlign": "center",
"width": undefined,
}
}
>
<div
className="ant-spin ant-spin-spinning"
>
<span
aria-label="loading"
className="anticon anticon-loading anticon-spin ant-spin-dot"
role="img"
style={
Object {
"fontSize": 30,
}
}
>
<svg
aria-hidden="true"
data-icon="loading"
fill="currentColor"
focusable="false"
height="1em"
viewBox="0 0 1024 1024"
width="1em"
>
<path
d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"
/>
</svg>
</span>
</div>
<span
className="ant-typography"
style={
Object {
"WebkitLineClamp": undefined,
}
}
>
<strong>
Update
</strong>
</span>
<div
className="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div />
<button
className="ant-btn"
onClick={[Function]}
type="button"
>
<span>
Cancel
</span>
</button>
</div>
</div>
`;
exports[`ShareSheetPendingDialog is rendered without status update 1`] = `
<div
className="css-gzchr8-Container e1hsqii15"
style={
Object {
"textAlign": "center",
"width": undefined,
}
}
>
<div
className="ant-spin ant-spin-spinning"
>
<span
aria-label="loading"
className="anticon anticon-loading anticon-spin ant-spin-dot"
role="img"
style={
Object {
"fontSize": 30,
}
}
>
<svg
aria-hidden="true"
data-icon="loading"
fill="currentColor"
focusable="false"
height="1em"
viewBox="0 0 1024 1024"
width="1em"
>
<path
d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"
/>
</svg>
</span>
</div>
<span
className="ant-typography"
style={
Object {
"WebkitLineClamp": undefined,
}
}
>
<strong>
wubba lubba dub dub
</strong>
</span>
<div
className="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div />
<button
className="ant-btn"
onClick={[Function]}
type="button"
>
<span>
Cancel
</span>
</button>
</div>
</div>
`;

View File

@@ -0,0 +1,167 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import * as React from 'react';
import {act, render} from '@testing-library/react';
import {
clearFlipperDebugMessages,
FlipperMessages,
getFlipperDebugMessages,
MessageRow,
registerFlipperDebugMessage,
setFlipperMessageDebuggingEnabled,
} from '../FlipperMessages';
const fixRowTimestamps = (r: MessageRow): MessageRow => ({
...r,
time: new Date(Date.UTC(0, 0, 0, 0, 0, 0)),
});
beforeEach(() => {
clearFlipperDebugMessages();
setFlipperMessageDebuggingEnabled(true);
});
afterEach(() => {
clearFlipperDebugMessages();
setFlipperMessageDebuggingEnabled(false);
});
test('It can store rows', () => {
registerFlipperDebugMessage({
app: 'Flipper',
direction: 'toFlipper:message',
});
registerFlipperDebugMessage({
app: 'FB4A',
direction: 'toClient:call',
device: 'Android Phone',
payload: {hello: 'world'},
});
setFlipperMessageDebuggingEnabled(false);
registerFlipperDebugMessage({
app: 'FB4A',
direction: 'toClient:call',
device: 'Android PhoneTEst',
payload: {hello: 'world'},
});
expect(getFlipperDebugMessages().map(fixRowTimestamps))
.toMatchInlineSnapshot(`
Array [
Object {
"app": "Flipper",
"direction": "toFlipper:message",
"time": 1899-12-31T00:00:00.000Z,
},
Object {
"app": "FB4A",
"device": "Android Phone",
"direction": "toClient:call",
"payload": Object {
"hello": "world",
},
"time": 1899-12-31T00:00:00.000Z,
},
]
`);
});
test('It can clear', () => {
registerFlipperDebugMessage({
app: 'Flipper',
direction: 'toFlipper:message',
});
clearFlipperDebugMessages();
expect(getFlipperDebugMessages()).toEqual([]);
});
test('It can render empty', async () => {
const renderer = render(<FlipperMessages />);
// Default message without any highlighted rows.
expect(
await renderer.findByText('Select a message to view details'),
).not.toBeNull();
renderer.unmount();
});
test('It can render rows', async () => {
const renderer = render(<FlipperMessages />);
act(() => {
registerFlipperDebugMessage({
time: new Date(0, 0, 0, 0, 0, 0),
app: 'Flipper',
direction: 'toFlipper:message',
});
registerFlipperDebugMessage({
time: new Date(0, 0, 0, 0, 0, 0),
app: 'FB4A',
direction: 'toClient:send',
device: 'Android Phone',
flipperInternalMethod: 'unique-string',
payload: {hello: 'world'},
});
});
expect((await renderer.findByText('unique-string')).parentElement)
.toMatchInlineSnapshot(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
>
00:00:00.000
</div>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
>
Android Phone
</div>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
>
FB4A
</div>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
>
unique-string
</div>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
/>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
/>
<div
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
width="14%"
>
toClient:send
</div>
</div>
`);
renderer.unmount();
});

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {useSelector} from 'react-redux';
import {getActivePlugin} from '../../selectors/connections';
import {ActivePluginListItem} from '../../utils/pluginUtils';
import {Layout} from '../../ui';
import {CenteredContainer} from '../../sandy-chrome/CenteredContainer';
import {Typography} from 'antd';
import {PluginActions} from '../PluginActions';
import {CoffeeOutlined} from '@ant-design/icons';
const {Text, Title} = Typography;
export function PluginInfo() {
const activePlugin = useSelector(getActivePlugin);
if (activePlugin) {
return <PluginMarketplace activePlugin={activePlugin} />;
} else {
return null;
}
}
function PluginMarketplace({
activePlugin,
}: {
activePlugin: ActivePluginListItem;
}) {
return (
<CenteredContainer>
<Layout.Container center gap style={{maxWidth: 350}}>
<CoffeeOutlined style={{fontSize: '24px'}} />
<Title level={4}>
Plugin '{activePlugin.details.title}' is {activePlugin.status}
</Title>
{activePlugin.status === 'unavailable' ? (
<Text style={{textAlign: 'center'}}>{activePlugin.reason}.</Text>
) : null}
<Layout.Horizontal gap>
<PluginActions activePlugin={activePlugin} type="link" />
</Layout.Horizontal>
</Layout.Container>
</CenteredContainer>
);
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export async function showLoginDialog(
_initialToken: string = '',
): Promise<boolean> {
return false;
}

View File

@@ -0,0 +1,249 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {PluginDetails} from 'flipper-plugin-lib';
import {Layout} from 'flipper-plugin';
import Client from '../../Client';
import {TableBodyRow} from '../../ui/components/table/types';
import React, {Component} from 'react';
import {connect} from 'react-redux';
import {Text, ManagedTable, styled, colors} from '../../ui';
import StatusIndicator from '../../ui/components/StatusIndicator';
import {State as Store} from '../../reducers';
import {PluginDefinition} from '../../plugin';
const InfoText = styled(Text)({
lineHeight: '130%',
marginBottom: 8,
});
const Ellipsis = styled(Text)({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
const TableContainer = styled.div({
marginTop: 10,
height: 480,
});
const Lamp = (props: {on: boolean}) => (
<StatusIndicator statusColor={props.on ? colors.lime : colors.red} />
);
type StateFromProps = {
gatekeepedPlugins: Array<PluginDetails>;
disabledPlugins: Array<PluginDetails>;
failedPlugins: Array<[PluginDetails, string]>;
clients: Map<string, Client>;
selectedDevice: string | null | undefined;
devicePlugins: PluginDefinition[];
clientPlugins: PluginDefinition[];
};
type DispatchFromProps = {};
type OwnProps = {};
const COLUMNS = {
lamp: {
value: '',
},
name: {
value: 'Name',
},
version: {
value: 'Version',
},
status: {
value: 'Status',
},
gk: {
value: 'GK',
},
clients: {
value: 'Supported by',
},
source: {
value: 'Source',
},
};
const COLUMNS_SIZES = {
lamp: 20,
name: 'flex',
version: 60,
status: 110,
gk: 120,
clients: 90,
source: 140,
};
type Props = OwnProps & StateFromProps & DispatchFromProps;
class PluginDebugger extends Component<Props> {
buildRow(
name: string,
version: string,
loaded: boolean,
status: string,
GKname: string | null | undefined,
pluginPath: string,
): TableBodyRow {
return {
key: name.toLowerCase(),
columns: {
lamp: {value: <Lamp on={loaded} />},
name: {value: <Ellipsis>{name}</Ellipsis>},
version: {value: <Ellipsis>{version}</Ellipsis>},
status: {
value: status ? <Ellipsis title={status}>{status}</Ellipsis> : null,
},
gk: {
value: GKname && (
<Ellipsis code title={GKname}>
{GKname}
</Ellipsis>
),
},
clients: {
value: this.getSupportedClients(name),
},
source: {
value: (
<Ellipsis code title={pluginPath}>
{pluginPath}
</Ellipsis>
),
},
},
};
}
getSupportedClients(id: string): string {
return Array.from(this.props.clients.values())
.reduce((acc: Array<string>, cv: Client) => {
if (cv.plugins.has(id)) {
acc.push(cv.query.app);
}
return acc;
}, [])
.join(', ');
}
getRows(): Array<TableBodyRow> {
const rows: Array<TableBodyRow> = [];
const externalPluginPath = (p: any) => (p.isBundled ? 'bundled' : p.entry);
this.props.gatekeepedPlugins.forEach((plugin) =>
rows.push(
this.buildRow(
plugin.name,
plugin.version,
false,
'GK disabled',
plugin.gatekeeper,
externalPluginPath(plugin),
),
),
);
this.props.devicePlugins.forEach((plugin) =>
rows.push(
this.buildRow(
plugin.id,
plugin.version,
true,
'',
plugin.gatekeeper,
externalPluginPath(plugin),
),
),
);
this.props.clientPlugins.forEach((plugin) =>
rows.push(
this.buildRow(
plugin.id,
plugin.version,
true,
'',
plugin.gatekeeper,
externalPluginPath(plugin),
),
),
);
this.props.disabledPlugins.forEach((plugin) =>
rows.push(
this.buildRow(
plugin.name,
plugin.version,
false,
'disabled',
null,
externalPluginPath(plugin),
),
),
);
this.props.failedPlugins.forEach(([plugin, status]) =>
rows.push(
this.buildRow(
plugin.name,
plugin.version,
false,
status,
null,
externalPluginPath(plugin),
),
),
);
return rows.sort((a, b) => (a.key < b.key ? -1 : 1));
}
render() {
return (
<Layout.Container pad>
<InfoText>The table lists all plugins known to Flipper.</InfoText>
<TableContainer>
<ManagedTable
columns={COLUMNS}
rows={this.getRows()}
highlightableRows={false}
columnSizes={COLUMNS_SIZES}
/>
</TableContainer>
</Layout.Container>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({
plugins: {
devicePlugins,
clientPlugins,
gatekeepedPlugins,
disabledPlugins,
failedPlugins,
},
connections: {clients, selectedDevice},
}) => ({
devicePlugins: Array.from(devicePlugins.values()),
clientPlugins: Array.from(clientPlugins.values()),
gatekeepedPlugins,
clients,
disabledPlugins,
failedPlugins,
selectedDevice: selectedDevice && selectedDevice.serial,
}),
)(PluginDebugger);

View File

@@ -0,0 +1,331 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Layout, theme} from 'flipper-plugin';
import {LoadingIndicator, TableRows, ManagedTable, Glyph} from '../../ui';
import React, {useCallback, useState, useEffect} from 'react';
import {reportPlatformFailures, reportUsage} from 'flipper-common';
import reloadFlipper from '../../utils/reloadFlipper';
import {registerInstalledPlugins} from '../../reducers/plugins';
import {
UpdateResult,
getInstalledPlugins,
getUpdatablePlugins,
removePlugin,
UpdatablePluginDetails,
InstalledPluginDetails,
} from 'flipper-plugin-lib';
import {installPluginFromNpm} from 'flipper-plugin-lib';
import {State as AppState} from '../../reducers';
import {connect} from 'react-redux';
import {Dispatch, Action} from 'redux';
import PluginPackageInstaller from './PluginPackageInstaller';
import {Toolbar} from 'flipper-plugin';
import {Alert, Button, Input, Tooltip, Typography} from 'antd';
const {Text, Link} = Typography;
const TAG = 'PluginInstaller';
const columnSizes = {
name: '25%',
version: '10%',
description: 'flex',
install: '15%',
};
const columns = {
name: {
value: 'Name',
},
version: {
value: 'Version',
},
description: {
value: 'Description',
},
install: {
value: '',
},
};
type PropsFromState = {
installedPlugins: Map<string, InstalledPluginDetails>;
};
type DispatchFromProps = {
refreshInstalledPlugins: () => void;
};
type OwnProps = {
autoHeight: boolean;
};
type Props = OwnProps & PropsFromState & DispatchFromProps;
const defaultProps: OwnProps = {
autoHeight: false,
};
const PluginInstaller = function ({
refreshInstalledPlugins,
installedPlugins,
autoHeight,
}: Props) {
const [restartRequired, setRestartRequired] = useState(false);
const [query, setQuery] = useState('');
const onInstall = useCallback(async () => {
refreshInstalledPlugins();
setRestartRequired(true);
}, [refreshInstalledPlugins]);
const rows = useNPMSearch(query, onInstall, installedPlugins);
const restartApp = useCallback(() => {
reloadFlipper();
}, []);
return (
<Layout.Container gap height={500}>
{restartRequired && (
<Alert
onClick={restartApp}
type="error"
message="To apply the changes, Flipper needs to reload. Click here to reload!"
style={{cursor: 'pointer'}}
/>
)}
<Toolbar>
<Input.Search
onChange={(e) => setQuery(e.target.value)}
value={query}
placeholder="Search Flipper plugins..."
/>
</Toolbar>
<ManagedTable
rowLineHeight={28}
floating={false}
multiline
columnSizes={columnSizes}
columns={columns}
highlightableRows={false}
highlightedRows={new Set()}
autoHeight={autoHeight}
rows={rows}
horizontallyScrollable
/>
<PluginPackageInstaller onInstall={onInstall} />
</Layout.Container>
);
};
function InstallButton(props: {
name: string;
version: string;
onInstall: () => void;
updateStatus: UpdateResult;
}) {
type InstallAction =
| {kind: 'Install'; error?: string}
| {kind: 'Waiting'}
| {kind: 'Remove'; error?: string}
| {kind: 'Update'; error?: string};
const catchError =
(actionKind: 'Install' | 'Remove' | 'Update', fn: () => Promise<void>) =>
async () => {
try {
await fn();
} catch (err) {
console.error(
`Installation process of kind ${actionKind} failed with:`,
err,
);
setAction({kind: actionKind, error: err.toString()});
}
};
const mkInstallCallback = (action: 'Install' | 'Update') =>
catchError(action, async () => {
reportUsage(
action === 'Install' ? `${TAG}:install` : `${TAG}:update`,
undefined,
props.name,
);
setAction({kind: 'Waiting'});
await installPluginFromNpm(props.name);
props.onInstall();
setAction({kind: 'Remove'});
});
const performInstall = useCallback(mkInstallCallback('Install'), [
props.name,
props.version,
]);
const performUpdate = useCallback(mkInstallCallback('Update'), [
props.name,
props.version,
]);
const performRemove = useCallback(
catchError('Remove', async () => {
reportUsage(`${TAG}:remove`, undefined, props.name);
setAction({kind: 'Waiting'});
await removePlugin(props.name);
props.onInstall();
setAction({kind: 'Install'});
}),
[props.name],
);
const [action, setAction] = useState<InstallAction>(
props.updateStatus.kind === 'update-available'
? {kind: 'Update'}
: props.updateStatus.kind === 'not-installed'
? {kind: 'Install'}
: {kind: 'Remove'},
);
if (action.kind === 'Waiting') {
return <LoadingIndicator size={16} />;
}
if ((action.kind === 'Install' || action.kind === 'Remove') && action.error) {
}
const button = (
<Button
size="small"
type={action.kind !== 'Remove' ? 'primary' : undefined}
onClick={() => {
switch (action.kind) {
case 'Install':
reportPlatformFailures(performInstall(), `${TAG}:install`);
break;
case 'Remove':
reportPlatformFailures(performRemove(), `${TAG}:remove`);
break;
case 'Update':
reportPlatformFailures(performUpdate(), `${TAG}:update`);
break;
}
}}>
{action.kind}
</Button>
);
if (action.error) {
const glyph = (
<Glyph color={theme.warningColor} size={16} name="caution-triangle" />
);
return (
<Layout.Horizontal gap>
<Tooltip
placement="leftBottom"
title={`Something went wrong: ${action.error}`}
children={glyph}
/>
{button}
</Layout.Horizontal>
);
} else {
return button;
}
}
function useNPMSearch(
query: string,
onInstall: () => void,
installedPlugins: Map<string, InstalledPluginDetails>,
): TableRows {
useEffect(() => {
reportUsage(`${TAG}:open`);
}, []);
const [searchResults, setSearchResults] = useState<UpdatablePluginDetails[]>(
[],
);
const createRow = useCallback(
(h: UpdatablePluginDetails) => ({
key: h.name,
columns: {
name: {
value: <Text ellipsis>{h.name.replace(/^flipper-plugin-/, '')}</Text>,
},
version: {
value: <Text ellipsis>{h.version}</Text>,
align: 'flex-end' as 'flex-end',
},
description: {
value: (
<Layout.Horizontal center gap>
<Text ellipsis>{h.description}</Text>
<Link href={`https://yarnpkg.com/en/package/${h.name}`}>
<Glyph
color={theme.textColorActive}
name="info-circle"
size={16}
/>
</Link>
</Layout.Horizontal>
),
},
install: {
value: (
<InstallButton
name={h.name}
version={h.version}
onInstall={onInstall}
updateStatus={h.updateStatus}
/>
),
align: 'center' as 'center',
},
},
}),
[onInstall],
);
useEffect(() => {
(async () => {
let canceled = false;
const updatablePlugins = await reportPlatformFailures(
getUpdatablePlugins(query),
`${TAG}:queryIndex`,
);
if (canceled) {
return;
}
setSearchResults(updatablePlugins);
// Clean up: if query changes while we're searching, abandon results.
return () => {
canceled = true;
};
})();
}, [query, installedPlugins]);
const rows = searchResults.map(createRow);
return rows;
}
PluginInstaller.defaultProps = defaultProps;
export default connect<PropsFromState, DispatchFromProps, OwnProps, AppState>(
({plugins: {installedPlugins}}) => ({
installedPlugins,
}),
(dispatch: Dispatch<Action<any>>) => ({
refreshInstalledPlugins: async () => {
const plugins = await getInstalledPlugins();
dispatch(registerInstalledPlugins(plugins));
},
}),
)(PluginInstaller);

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {Tab, Tabs} from 'flipper-plugin';
import PluginDebugger from './PluginDebugger';
import PluginInstaller from './PluginInstaller';
import {Modal} from 'antd';
export default function (props: {onHide: () => any}) {
return (
<Modal width={800} visible onCancel={props.onHide} footer={null}>
<Tabs>
<Tab tab="Plugin Status">
<PluginDebugger />
</Tab>
<Tab tab="Install Plugins">
<PluginInstaller autoHeight />
</Tab>
</Tabs>
</Modal>
);
}

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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,
FlexRow,
Tooltip,
Glyph,
colors,
LoadingIndicator,
} from '../../ui';
import styled from '@emotion/styled';
import {default as FileSelector} from '../../ui/components/FileSelector';
import React, {useState} from 'react';
import {installPluginFromFile} from 'flipper-plugin-lib';
import {Toolbar} from 'flipper-plugin';
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 2,
});
const Spinner = styled(LoadingIndicator)({
margin: 'auto',
marginLeft: 16,
});
const ButtonContainer = styled(FlexRow)({
width: 76,
});
const ErrorGlyphContainer = styled(FlexRow)({
width: 20,
});
export default function PluginPackageInstaller({
onInstall,
}: {
onInstall: () => Promise<void>;
}) {
const [path, setPath] = useState('');
const [isPathValid, setIsPathValid] = useState(false);
const [error, setError] = useState<Error>();
const [inProgress, setInProgress] = useState(false);
const onClick = async () => {
setError(undefined);
setInProgress(true);
try {
await installPluginFromFile(path);
await onInstall();
} catch (e) {
setError(e);
console.error('PluginPackageInstaller install error:', e);
} finally {
setInProgress(false);
}
};
const button = inProgress ? (
<Spinner size={16} />
) : (
<Button
compact
type="primary"
disabled={!isPathValid}
title={
isPathValid
? 'Click to install the specified plugin package'
: 'Cannot install plugin package by the specified path'
}
onClick={onClick}>
Install
</Button>
);
return (
<Toolbar>
<FileSelector
placeholderText="Specify path to a Flipper package or just drag and drop it here..."
onPathChanged={(e) => {
setPath(e.path);
setIsPathValid(e.isValid);
setError(undefined);
}}
/>
<ButtonContainer>
<FlexRow>
{button}
<ErrorGlyphContainer>
{error && (
<Tooltip
options={{position: 'toRight'}}
title={`Something went wrong: ${error}`}>
<CenteredGlyph
color={colors.orange}
size={16}
name="caution-triangle"
/>
</Tooltip>
)}
</ErrorGlyphContainer>
</FlexRow>
</ButtonContainer>
</Toolbar>
);
}

View File

@@ -0,0 +1,117 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
jest.mock('flipper-plugin-lib');
import {default as PluginInstaller} from '../PluginInstaller';
import React from 'react';
import {render, waitFor} from '@testing-library/react';
import configureStore from 'redux-mock-store';
import {Provider} from 'react-redux';
import type {PluginDetails} from 'flipper-plugin-lib';
import {getUpdatablePlugins, UpdatablePluginDetails} from 'flipper-plugin-lib';
import {Store} from '../../../reducers';
import {mocked} from 'ts-jest/utils';
const getUpdatablePluginsMock = mocked(getUpdatablePlugins);
function getStore(installedPlugins: PluginDetails[] = []): Store {
return configureStore([])({
application: {sessionId: 'mysession'},
plugins: {installedPlugins},
}) as Store;
}
const samplePluginDetails1: UpdatablePluginDetails = {
name: 'flipper-plugin-hello',
entry: './test/index.js',
version: '0.1.0',
specVersion: 2,
pluginType: 'client',
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample1',
source: 'src/index.js',
id: 'Hello',
title: 'Hello',
description: 'World?',
isBundled: false,
isActivatable: true,
updateStatus: {
kind: 'not-installed',
version: '0.1.0',
},
};
const samplePluginDetails2: UpdatablePluginDetails = {
name: 'flipper-plugin-world',
entry: './test/index.js',
version: '0.2.0',
specVersion: 2,
pluginType: 'client',
main: 'dist/bundle.js',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample2',
source: 'src/index.js',
id: 'World',
title: 'World',
description: 'Hello?',
isBundled: false,
isActivatable: true,
updateStatus: {
kind: 'not-installed',
version: '0.2.0',
},
};
const SEARCH_RESULTS = [samplePluginDetails1, samplePluginDetails2];
afterEach(() => {
getUpdatablePluginsMock.mockClear();
});
test('load PluginInstaller list', async () => {
getUpdatablePluginsMock.mockReturnValue(Promise.resolve(SEARCH_RESULTS));
const component = (
<Provider store={getStore()}>
<PluginInstaller
// Bit ugly to have this as an effectively test-only option, but
// without, we rely on height information from Electron which we don't
// have, causing no items to be rendered.
autoHeight
/>
</Provider>
);
const {container, getByText} = render(component);
await waitFor(() => getByText('hello'));
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
expect(container).toMatchSnapshot();
});
test('load PluginInstaller list with one plugin installed', async () => {
getUpdatablePluginsMock.mockReturnValue(
Promise.resolve([
{...samplePluginDetails1, updateStatus: {kind: 'up-to-date'}},
samplePluginDetails2,
]),
);
const store = getStore([samplePluginDetails1]);
const component = (
<Provider store={store}>
<PluginInstaller
// Bit ugly to have this as an effectively test-only option, but
// without, we rely on height information from Electron which we don't
// have, causing no items to be rendered.
autoHeight
/>
</Provider>
);
const {container, getByText} = render(component);
await waitFor(() => getByText('hello'));
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
expect(container).toMatchSnapshot();
});

View File

@@ -0,0 +1,669 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`load PluginInstaller list 1`] = `
<div>
<div
class="css-1v0y38i-Container e1hsqii15"
height="500"
>
<div
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
>
<span
class="ant-input-group-wrapper ant-input-search"
>
<span
class="ant-input-wrapper ant-input-group"
>
<input
class="ant-input"
placeholder="Search Flipper plugins..."
type="text"
value=""
/>
<span
class="ant-input-group-addon"
>
<button
class="ant-btn ant-btn-icon-only ant-input-search-button"
type="button"
>
<span
aria-label="search"
class="anticon anticon-search"
role="img"
>
<svg
aria-hidden="true"
data-icon="search"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
/>
</svg>
</span>
</button>
</span>
</span>
</span>
</div>
<div
class="css-bgfc37-View-FlexBox-FlexColumn-Container emab7y20"
tabindex="0"
>
<div
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
>
<div
class="css-1otvu18-View-FlexBox-FlexRow-TableHeadContainer eig1lcc1"
>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="name"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Name
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="version"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Version
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="description"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Description
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="install"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
</div>
</div>
</div>
</div>
</div>
<div
class="css-p5h61d-View-FlexBox-FlexColumn-Container emab7y20"
>
<div
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
>
<div
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
data-key="flipper-plugin-hello"
>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
hello
</span>
</div>
<div
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
0.1.0
</span>
</div>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<div
class="css-s1wsbn-Container-Horizontal e1hsqii14"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
World?
</span>
<a
class="ant-typography"
href="https://yarnpkg.com/en/package/flipper-plugin-hello"
>
<div
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
color="var(--light-color-button-active)"
size="16"
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</a>
</div>
</div>
<div
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<button
class="ant-btn ant-btn-primary ant-btn-sm"
type="button"
>
<span>
Install
</span>
</button>
</div>
</div>
<div
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
data-key="flipper-plugin-world"
>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
world
</span>
</div>
<div
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
0.2.0
</span>
</div>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<div
class="css-s1wsbn-Container-Horizontal e1hsqii14"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
Hello?
</span>
<a
class="ant-typography"
href="https://yarnpkg.com/en/package/flipper-plugin-world"
>
<div
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
color="var(--light-color-button-active)"
size="16"
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</a>
</div>
</div>
<div
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<button
class="ant-btn ant-btn-primary ant-btn-sm"
type="button"
>
<span>
Install
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
>
<div
class="css-1spj5hr-View-FlexBox-FlexRow-Container ev83mp62"
>
<input
class="css-sli06x-Input-FileInputBox ev83mp60"
placeholder="Specify path to a Flipper package or just drag and drop it here..."
type="text"
value=""
/>
<div
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
>
<img
alt="dots-3-circle"
class="ev83mp63 css-6iptsk-ColoredIconBlack-CenteredGlyph ekc8qeh1"
size="16"
src="https://facebook.com/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
title="Open file selection dialog"
/>
</div>
<div
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
>
<div
class="css-auhar3-TooltipContainer e1m67rki0"
>
<div
class="ev83mp63 css-1qsl9s4-ColoredIconCustom-CenteredGlyph ekc8qeh0"
color="#D79651"
size="16"
src="https://facebook.com/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</div>
</div>
</div>
<div
class="css-5ukfaz-View-FlexBox-FlexRow-ButtonContainer eguixfz1"
>
<div
class="css-wospjg-View-FlexBox-FlexRow ek54xq0"
>
<button
class="ant-btn ant-btn-primary"
disabled=""
type="button"
>
<span>
Install
</span>
</button>
<div
class="css-170i4ha-View-FlexBox-FlexRow-ErrorGlyphContainer eguixfz0"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`load PluginInstaller list with one plugin installed 1`] = `
<div>
<div
class="css-1v0y38i-Container e1hsqii15"
height="500"
>
<div
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
>
<span
class="ant-input-group-wrapper ant-input-search"
>
<span
class="ant-input-wrapper ant-input-group"
>
<input
class="ant-input"
placeholder="Search Flipper plugins..."
type="text"
value=""
/>
<span
class="ant-input-group-addon"
>
<button
class="ant-btn ant-btn-icon-only ant-input-search-button"
type="button"
>
<span
aria-label="search"
class="anticon anticon-search"
role="img"
>
<svg
aria-hidden="true"
data-icon="search"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
/>
</svg>
</span>
</button>
</span>
</span>
</span>
</div>
<div
class="css-bgfc37-View-FlexBox-FlexColumn-Container emab7y20"
tabindex="0"
>
<div
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
>
<div
class="css-1otvu18-View-FlexBox-FlexRow-TableHeadContainer eig1lcc1"
>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="name"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Name
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="version"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Version
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="description"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
Description
</div>
</div>
</div>
<div
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
title="install"
width="0"
>
<div
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
>
</div>
</div>
</div>
</div>
</div>
<div
class="css-p5h61d-View-FlexBox-FlexColumn-Container emab7y20"
>
<div
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
>
<div
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
data-key="flipper-plugin-hello"
>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
hello
</span>
</div>
<div
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
0.1.0
</span>
</div>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<div
class="css-s1wsbn-Container-Horizontal e1hsqii14"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
World?
</span>
<a
class="ant-typography"
href="https://yarnpkg.com/en/package/flipper-plugin-hello"
>
<div
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
color="var(--light-color-button-active)"
size="16"
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</a>
</div>
</div>
<div
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<button
class="ant-btn ant-btn-sm"
type="button"
>
<span>
Remove
</span>
</button>
</div>
</div>
<div
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
data-key="flipper-plugin-world"
>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
world
</span>
</div>
<div
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
0.2.0
</span>
</div>
<div
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<div
class="css-s1wsbn-Container-Horizontal e1hsqii14"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
>
Hello?
</span>
<a
class="ant-typography"
href="https://yarnpkg.com/en/package/flipper-plugin-world"
>
<div
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
color="var(--light-color-button-active)"
size="16"
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</a>
</div>
</div>
<div
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
title=""
width="0"
>
<button
class="ant-btn ant-btn-primary ant-btn-sm"
type="button"
>
<span>
Install
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
>
<div
class="css-1spj5hr-View-FlexBox-FlexRow-Container ev83mp62"
>
<input
class="css-sli06x-Input-FileInputBox ev83mp60"
placeholder="Specify path to a Flipper package or just drag and drop it here..."
type="text"
value=""
/>
<div
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
>
<img
alt="dots-3-circle"
class="ev83mp63 css-6iptsk-ColoredIconBlack-CenteredGlyph ekc8qeh1"
size="16"
src="https://facebook.com/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
title="Open file selection dialog"
/>
</div>
<div
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
>
<div
class="css-auhar3-TooltipContainer e1m67rki0"
>
<div
class="ev83mp63 css-1qsl9s4-ColoredIconCustom-CenteredGlyph ekc8qeh0"
color="#D79651"
size="16"
src="https://facebook.com/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
/>
</div>
</div>
</div>
<div
class="css-5ukfaz-View-FlexBox-FlexRow-ButtonContainer eguixfz1"
>
<div
class="css-wospjg-View-FlexBox-FlexRow ek54xq0"
>
<button
class="ant-btn ant-btn-primary"
disabled=""
type="button"
>
<span>
Install
</span>
</button>
<div
class="css-170i4ha-View-FlexBox-FlexRow-ErrorGlyphContainer eguixfz0"
/>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,262 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlexColumn, styled, FlexRow, Text, Glyph, colors} from '../../ui';
import React, {useRef, useState, useEffect} from 'react';
import {theme} from 'flipper-plugin';
type PressedKeys = {
metaKey: boolean;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
character: string;
};
const KEYCODES = {
DELETE: 8,
ALT: 18,
SHIFT: 16,
CTRL: 17,
LEFT_COMMAND: 91, // Left ⌘ / Windows Key / Chromebook Search key
RIGHT_COMMAND: 93, // Right ⌘ / Windows Menu
};
const ACCELERATORS = {
COMMAND: 'Command',
ALT: 'Alt',
CONTROL: 'Control',
SHIFT: 'Shift',
};
const Container = styled(FlexRow)({
paddingTop: 5,
paddingLeft: 10,
paddingRight: 10,
});
const Label = styled(Text)({
flex: 1,
alignSelf: 'center',
});
const ShortcutKeysContainer = styled(FlexRow)<{invalid: boolean}>(
{
flex: 1,
backgroundColor: theme.backgroundDefault,
border: '1px solid',
borderRadius: 4,
display: 'flex',
height: 28,
padding: 2,
},
(props) => ({
borderColor: props.invalid ? theme.errorColor : theme.dividerColor,
}),
);
const ShortcutKeyContainer = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: `1px solid ${theme.dividerColor}`,
backgroundColor: theme.backgroundWash,
padding: 3,
margin: '0 1px',
borderRadius: 3,
width: 23,
textAlign: 'center',
});
const ShortcutKey = styled.span({
color: theme.textColorPrimary,
});
const HiddenInput = styled.input({
opacity: 0,
width: 0,
height: 0,
position: 'absolute',
});
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 10,
});
const KeyboardShortcutInput = (props: {
label: string;
value: string;
onChange?: (value: string) => void;
}) => {
const getInitialStateFromProps = (): PressedKeys => ({
metaKey: Boolean(props.value && props.value.includes(ACCELERATORS.COMMAND)),
altKey: Boolean(props.value && props.value.includes(ACCELERATORS.ALT)),
ctrlKey: Boolean(props.value && props.value.includes(ACCELERATORS.CONTROL)),
shiftKey: Boolean(props.value && props.value.includes(ACCELERATORS.SHIFT)),
character:
props.value &&
props.value.replace(
new RegExp(
`${ACCELERATORS.COMMAND}|${ACCELERATORS.ALT}|Or|${ACCELERATORS.CONTROL}|${ACCELERATORS.SHIFT}|\\+`,
'g',
),
'',
),
});
const [initialPressedKeys] = useState<PressedKeys>(
getInitialStateFromProps(),
);
const [pressedKeys, setPressedKeys] =
useState<PressedKeys>(initialPressedKeys);
const [isShortcutValid, setIsShortcutValid] = useState<boolean | undefined>(
undefined,
);
useEffect(() => {
if (!isShortcutValid) {
return;
}
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
const accelerator = [
metaKey && ACCELERATORS.COMMAND,
altKey && ACCELERATORS.ALT,
ctrlKey && ACCELERATORS.CONTROL,
shiftKey && ACCELERATORS.SHIFT,
character,
].filter(Boolean);
if (typeof props.onChange === 'function') {
props.onChange(accelerator.join('+'));
}
}, [isShortcutValid, pressedKeys, props]);
const inputRef = useRef<HTMLInputElement>(null);
let typingTimeout: NodeJS.Timeout;
const handleFocusInput = () => {
if (inputRef.current !== null) {
inputRef.current.focus();
}
};
const isCharacterSpecial = (keycode: number) =>
Object.values(KEYCODES).includes(keycode);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.which === 9) {
return;
}
event.preventDefault();
const {metaKey, altKey, ctrlKey, shiftKey} = event;
const character = isCharacterSpecial(event.which)
? ''
: String.fromCharCode(event.which);
setPressedKeys({
metaKey,
altKey,
ctrlKey,
shiftKey,
character,
});
setIsShortcutValid(undefined);
};
const handleKeyUp = () => {
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
clearTimeout(typingTimeout);
typingTimeout = setTimeout(
() =>
setIsShortcutValid(
([metaKey, altKey, ctrlKey, shiftKey].includes(true) &&
character !== '') ||
[metaKey, altKey, ctrlKey, shiftKey, character].every(
(value) => !value,
),
),
500,
);
};
const handleUpdatePressedKeys = (keys: PressedKeys) => {
setPressedKeys(keys);
handleKeyUp();
handleFocusInput();
setIsShortcutValid(undefined);
};
const renderKeys = () => {
const keys = [
pressedKeys.metaKey && '⌘',
pressedKeys.altKey && '⌥',
pressedKeys.ctrlKey && '⌃',
pressedKeys.shiftKey && '⇧',
pressedKeys.character,
].filter(Boolean);
return keys.map((key, index) => (
<ShortcutKeyContainer key={index}>
<ShortcutKey>{key}</ShortcutKey>
</ShortcutKeyContainer>
));
};
return (
<Container>
<Label>{props.label}</Label>
<ShortcutKeysContainer
invalid={isShortcutValid === false}
onClick={handleFocusInput}>
{renderKeys()}
<HiddenInput
ref={inputRef}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
/>
</ShortcutKeysContainer>
<FlexRow>
<FlexColumn onClick={() => handleUpdatePressedKeys(initialPressedKeys)}>
<CenteredGlyph
color={theme.primaryColor}
name="undo"
variant="outline"
/>
</FlexColumn>
<FlexColumn
onClick={() =>
handleUpdatePressedKeys({
metaKey: false,
altKey: false,
ctrlKey: false,
shiftKey: false,
character: '',
})
}>
<CenteredGlyph
color={theme.errorColor}
name="cross"
variant="outline"
/>
</FlexColumn>
</FlexRow>
</Container>
);
};
export default KeyboardShortcutInput;

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlexColumn, styled, FlexRow, ToggleButton} from '../../ui';
import React from 'react';
import {theme} from 'flipper-plugin';
const IndentedSection = styled(FlexColumn)({
paddingLeft: 50,
paddingBottom: 10,
});
const GrayedOutOverlay = styled.div({
background: theme.backgroundDefault,
borderRadius: 4,
opacity: 0.6,
height: '100%',
position: 'absolute',
left: 0,
right: 0,
});
export default function ToggledSection(props: {
label: string;
toggled: boolean;
onChange?: (value: boolean) => void;
children?: React.ReactNode;
// Whether to disallow interactions with this toggle
frozen?: boolean;
}) {
return (
<FlexColumn>
<FlexRow>
<ToggleButton
label={props.label}
onClick={() => props.onChange && props.onChange(!props.toggled)}
toggled={props.toggled}
/>
{props.frozen && <GrayedOutOverlay />}
</FlexRow>
<IndentedSection>
{props.children}
{props.toggled || props.frozen ? null : <GrayedOutOverlay />}
</IndentedSection>
</FlexColumn>
);
}

View File

@@ -0,0 +1,153 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
FlexColumn,
styled,
Text,
FlexRow,
Input,
colors,
Glyph,
} from '../../ui';
import React, {useState} from 'react';
import {promises as fs} from 'fs';
import {theme} from 'flipper-plugin';
import {getRenderHostInstance} from '../../RenderHost';
export const ConfigFieldContainer = styled(FlexRow)({
paddingLeft: 10,
paddingRight: 10,
marginBottom: 5,
paddingTop: 5,
});
export const InfoText = styled(Text)({
lineHeight: 1.35,
paddingTop: 5,
});
const FileInputBox = styled(Input)<{isValid: boolean}>(({isValid}) => ({
marginRight: 0,
flexGrow: 1,
fontFamily: 'monospace',
color: isValid ? undefined : colors.red,
marginLeft: 10,
marginTop: 'auto',
marginBottom: 'auto',
}));
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 10,
});
const GrayedOutOverlay = styled.div({
backgroundColor: '#EFEEEF',
borderRadius: 4,
opacity: 0.6,
height: '100%',
position: 'absolute',
left: 0,
right: 0,
});
export function FilePathConfigField(props: {
label: string;
resetValue?: string;
defaultValue: string;
onChange: (path: string) => void;
frozen?: boolean;
// Defaults to allowing directories only, this changes to expect regular files.
isRegularFile?: boolean;
}) {
const renderHost = getRenderHostInstance();
const [value, setValue] = useState(props.defaultValue);
const [isValid, setIsValid] = useState(true);
fs.stat(value)
.then((stat) => props.isRegularFile !== stat.isDirectory())
.then((valid) => {
if (valid !== isValid) {
setIsValid(valid);
}
})
.catch((_) => setIsValid(false));
return (
<ConfigFieldContainer>
<InfoText>{props.label}</InfoText>
<FileInputBox
placeholder={props.label}
value={value}
isValid={isValid}
onChange={(e) => {
setValue(e.target.value);
props.onChange(e.target.value);
fs.stat(e.target.value)
.then((stat) => stat.isDirectory())
.then((valid) => {
if (valid !== isValid) {
setIsValid(valid);
}
})
.catch((_) => setIsValid(false));
}}
/>
{renderHost.showSelectDirectoryDialog && (
<FlexColumn
onClick={() => {
renderHost
.showSelectDirectoryDialog?.()
.then((path) => {
if (path) {
setValue(path);
props.onChange(path);
}
})
.catch((e) => {
console.warn('Failed to select dir', e);
});
}}>
<CenteredGlyph
color={theme.primaryColor}
name="dots-3-circle"
variant="outline"
/>
</FlexColumn>
)}
{props.resetValue && (
<FlexColumn
title={`Reset to default path ${props.resetValue}`}
onClick={() => {
setValue(props.resetValue!);
props.onChange(props.resetValue!);
}}>
<CenteredGlyph
color={theme.primaryColor}
name="undo"
variant="outline"
/>
</FlexColumn>
)}
{isValid ? null : (
<CenteredGlyph name="caution-triangle" color={colors.yellow} />
)}
{props.frozen && <GrayedOutOverlay />}
</ConfigFieldContainer>
);
}
export function ConfigText(props: {content: string; frozen?: boolean}) {
return (
<ConfigFieldContainer>
<InfoText>{props.content}</InfoText>
{props.frozen && <GrayedOutOverlay />}
</ConfigFieldContainer>
);
}

View File

@@ -0,0 +1,164 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Group, SUPPORTED_GROUPS} from './reducers/supportForm';
import {getLogger, Logger} from 'flipper-common';
import {Store} from './reducers/index';
import {importDataToStore} from './utils/exportData';
import {selectPlugin, getAllClients} from './reducers/connections';
import {Dialog} from 'flipper-plugin';
import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink';
import {message} from 'antd';
import {showLoginDialog} from './chrome/fb-stubs/SignInSheet';
import {track} from './deeplinkTracking';
const UNKNOWN = 'Unknown deeplink';
/**
* Handle a flipper:// deeplink. Will throw if the URL pattern couldn't be recognised
*/
export async function handleDeeplink(
store: Store,
logger: Logger,
query: string,
): Promise<void> {
const trackInteraction = track.bind(null, logger, query);
const unknownError = () => {
trackInteraction({
state: 'ERROR',
errorMessage: UNKNOWN,
});
throw new Error(UNKNOWN);
};
const uri = new URL(query);
trackInteraction({
state: 'INIT',
});
if (uri.protocol !== 'flipper:') {
throw unknownError();
}
if (uri.href === 'flipper://' || uri.pathname === '//welcome') {
// We support an empty protocol for just opening Flipper from anywhere
// or alternatively flipper://welcome to open the welcome screen.
return;
}
if (uri.href.startsWith('flipper://open-plugin')) {
return handleOpenPluginDeeplink(store, query, trackInteraction);
}
if (uri.pathname.match(/^\/*import\/*$/)) {
const url = uri.searchParams.get('url');
if (url) {
const handle = Dialog.loading({
message: 'Importing Flipper trace...',
});
return fetch(url)
.then((res) => res.text())
.then((data) => importDataToStore(url, data, store))
.catch((e: Error) => {
console.warn('Failed to download Flipper trace', e);
message.error({
duration: 0,
content: 'Failed to download Flipper trace: ' + e,
});
})
.finally(() => {
handle.close();
});
}
throw unknownError();
} else if (uri.pathname.match(/^\/*support-form\/*$/)) {
const formParam = uri.searchParams.get('form');
const grp = deeplinkFormParamToGroups(formParam);
if (grp) {
grp.handleSupportFormDeeplinks(store);
return;
}
throw unknownError();
} else if (uri.pathname.match(/^\/*login\/*$/)) {
const token = uri.searchParams.get('token');
showLoginDialog(token ?? '');
return;
}
const match = uriComponents(query);
if (match.length > 1) {
// deprecated, use the open-plugin format instead, which is more flexible
// and will guide the user through any necessary set up steps
// flipper://<client>/<pluginId>/<payload>
console.warn(
`Deprecated deeplink format: '${query}', use 'flipper://open-plugin?plugin-id=${
match[1]
}&client=${match[0]}&payload=${encodeURIComponent(match[2])}' instead.`,
);
const deepLinkPayload = match[2];
const deepLinkParams = new URLSearchParams(deepLinkPayload);
const deviceParam = deepLinkParams.get('device');
// if there is a device Param, find a matching device
const selectedDevice = deviceParam
? store
.getState()
.connections.devices.find((v) => v.title === deviceParam)
: undefined;
// if a client is specified, find it, withing the device if applicable
const selectedClient = getAllClients(store.getState().connections).find(
(c) =>
c.query.app === match[0] &&
(selectedDevice == null || c.device === selectedDevice),
);
store.dispatch(
selectPlugin({
selectedAppId: selectedClient?.id,
selectedDevice: selectedClient ? selectedClient.device : selectedDevice,
selectedPlugin: match[1],
deepLinkPayload,
}),
);
return;
} else {
throw unknownError();
}
}
function deeplinkFormParamToGroups(
formParam: string | null,
): Group | undefined {
if (!formParam) {
return undefined;
}
return SUPPORTED_GROUPS.find((grp) => {
return grp.deeplinkSuffix.toLowerCase() === formParam.toLowerCase();
});
}
export const uriComponents = (url: string): Array<string> => {
if (!url) {
return [];
}
const match: Array<string> | undefined | null = url.match(
/^flipper:\/\/([^\/]*)\/([^\/\?]*)\/?(.*)$/,
);
if (match) {
return match.map(decodeURIComponent).slice(1).filter(Boolean);
}
return [];
};
export function openDeeplinkDialog(store: Store) {
Dialog.prompt({
title: 'Open deeplink',
message: 'Enter a deeplink:',
defaultValue: 'flipper://',
onConfirm: async (deeplink) => {
await handleDeeplink(store, getLogger(), deeplink);
return deeplink;
},
});
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Logger} from 'flipper-common';
export type OpenPluginParams = {
pluginId: string;
client: string | undefined;
devices: string[];
payload: string | undefined;
};
export type DeeplinkInteractionState =
| 'INIT' // Sent every time a user enters a deeplink flow
| 'ERROR' // Something went wrong (parsing, etc.) comes with more metadata attached
| 'PLUGIN_LIGHTHOUSE_BAIL' // User did not connect to VPN/Lighthouse when asked
| 'PLUGIN_STATUS_BAIL' // User did not install the plugin (has `extra` attribute with more information)
| 'PLUGIN_DEVICE_BAIL' // User did not launch a new device
| 'PLUGIN_CLIENT_BAIL' // User did not launch a supported app
| 'PLUGIN_DEVICE_SELECTION_BAIL' // User closed dialogue asking to select one of many devices
| 'PLUGIN_CLIENT_SELECTION_BAIL' // User closed dialogue asking to select one of many apps
| 'PLUGIN_DEVICE_UNSUPPORTED' // The device did not match the requirements specified in the deeplink URL
| 'PLUGIN_CLIENT_UNSUPPORTED' // The already opened app did not match the requirements specified in the deeplink URL
| 'PLUGIN_OPEN_SUCCESS'; // Everything is awesome
export type DeeplinkInteraction = {
state: DeeplinkInteractionState;
errorMessage?: string;
plugin?: OpenPluginParams;
extra?: object;
};
export function track(
logger: Logger,
query: string,
interaction: DeeplinkInteraction,
) {
logger.track(
'usage',
'deeplink',
{
...interaction,
query,
},
interaction.plugin?.pluginId,
);
}

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export {default as styled} from '@emotion/styled';
export {keyframes} from '@emotion/css';
export {produce} from 'immer';
export * from './ui/index';
export {textContent, sleep} from 'flipper-plugin';
export * from './utils/jsonTypes';
export {default as GK, loadGKs, loadDistilleryGK} from './fb-stubs/GK';
export {default as createPaste} from './fb-stubs/createPaste';
export {
internGraphGETAPIRequest,
internGraphPOSTAPIRequest,
graphQLQuery,
isLoggedIn,
getUser,
} from './fb-stubs/user';
export {FlipperPlugin, FlipperDevicePlugin, BaseAction} from './plugin';
export {PluginClient, Props, KeyboardActions} from './plugin';
export {default as Client} from './Client';
export {reportUsage} from 'flipper-common';
export {default as promiseTimeout} from './utils/promiseTimeout';
export {bufferToBlob} from './utils/screenshot';
export {getPluginKey} from './utils/pluginKey';
export {Notification, Idler} from 'flipper-plugin';
export {IdlerImpl} from './utils/Idler';
export {Store, State as ReduxState} from './reducers/index';
export {default as BaseDevice} from './devices/BaseDevice';
export {default as isProduction} from './utils/isProduction';
export {DetailSidebar} from 'flipper-plugin';
export {default as Device} from './devices/BaseDevice';
export {default as ArchivedDevice} from './devices/ArchivedDevice';
export {DeviceOS as OS} from 'flipper-plugin';
export {default as Button} from './ui/components/Button';
export {default as ToggleButton} from './ui/components/ToggleSwitch';
export {default as ButtonGroup} from './ui/components/ButtonGroup';
export {colors, brandColors} from './ui/components/colors';
export {default as Glyph} from './ui/components/Glyph';
export {default as LoadingIndicator} from './ui/components/LoadingIndicator';
export {
TableColumns,
TableRows,
TableBodyColumn,
TableBodyRow,
TableHighlightedRows,
TableRowSortOrder,
TableColumnOrder,
TableColumnSizes,
} from './ui/components/table/types';
export {default as ManagedTable} from './ui/components/table/ManagedTable';
export {ManagedTableProps} from './ui/components/table/ManagedTable';
export {
DataInspectorExpanded,
DataDescriptionType,
MarkerTimeline,
} from 'flipper-plugin';
export {DataInspector as ManagedDataInspector} from 'flipper-plugin';
export {HighlightManager} from 'flipper-plugin';
export {default as Tabs} from './ui/components/Tabs';
export {default as Tab} from './ui/components/Tab';
export {default as Input} from './ui/components/Input';
export {default as Textarea} from './ui/components/Textarea';
export {default as Select} from './ui/components/Select';
export {default as Checkbox} from './ui/components/Checkbox';
export {default as Orderable} from './ui/components/Orderable';
export {Component, PureComponent} from 'react';
export {default as ContextMenu} from './ui/components/ContextMenu';
export {FileListFiles} from './ui/components/FileList';
export {default as FileList} from './ui/components/FileList';
export {default as View} from './ui/components/View';
export {default as Sidebar} from './ui/components/Sidebar';
export {default as FlexBox} from './ui/components/FlexBox';
export {default as FlexRow} from './ui/components/FlexRow';
export {default as FlexColumn} from './ui/components/FlexColumn';
export {default as FlexCenter} from './ui/components/FlexCenter';
export {Toolbar} from 'flipper-plugin';
export {Spacer} from './ui/components/Toolbar';
export {default as ToolbarIcon} from './ui/components/ToolbarIcon';
export {default as Panel} from './ui/components/Panel';
export {default as Text} from './ui/components/Text';
export {default as Link} from './ui/components/Link';
export {default as Tooltip} from './ui/components/Tooltip';
export {default as StatusIndicator} from './ui/components/StatusIndicator';
export {default as HorizontalRule} from './ui/components/HorizontalRule';
export {default as Label} from './ui/components/Label';
export {default as Heading} from './ui/components/Heading';
export * from './utils/pathUtils';
export {Filter} from './ui/components/filter/types';
export {default as StackTrace} from './ui/components/StackTrace';
export {
SearchBox,
SearchInput,
SearchIcon,
SearchableProps,
default as Searchable,
} from './ui/components/searchable/Searchable';
export {
default as SearchableTable,
filterRowsFactory,
} from './ui/components/searchable/SearchableTable';
export {
ElementsInspector,
ElementsInspectorElement as Element,
// TODO: clean up or create namespace
ElementsInspectorProps,
ElementAttribute,
ElementData,
ElementSearchResultSet,
ElementID,
} from 'flipper-plugin';
export {ElementFramework} from './ui/components/elements-inspector/ElementFramework';
export {InspectorSidebar} from './ui/components/elements-inspector/sidebar';
export {default as FileSelector} from './ui/components/FileSelector';
export {getFlipperMediaCDN, appendAccessTokenToUrl} from './fb-stubs/user';
export {Rect} from './utils/geometry';
export {Logger} from 'flipper-common';
export {getLogger} from 'flipper-common';
export {callVSCode} from './utils/vscodeUtils';
export {IDEFileResolver, IDEType} from './fb-stubs/IDEFileResolver';
export {renderMockFlipperWithPlugin} from './test-utils/createMockFlipperWithPlugin';
export {Tracked} from 'flipper-plugin'; // To be able to use it in legacy plugins
export {RequireLogin} from './ui/components/RequireLogin';
export {TestDevice} from './test-utils/TestDevice';
export {connect} from 'react-redux';

View File

@@ -0,0 +1,85 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import BaseDevice from './BaseDevice';
import type {DeviceOS, DeviceType} from 'flipper-plugin';
import {DeviceShell} from './BaseDevice';
import {SupportFormRequestDetailsState} from '../reducers/supportForm';
export default class ArchivedDevice extends BaseDevice {
isArchived = true;
constructor(options: {
serial: string;
deviceType: DeviceType;
title: string;
os: DeviceOS;
screenshotHandle?: string | null;
source?: string;
supportRequestDetails?: SupportFormRequestDetailsState;
}) {
super(
{
close() {},
exec(command, ..._args: any[]) {
throw new Error(
`[Archived device] Cannot invoke command ${command} on an archived device`,
);
},
on(event) {
console.warn(
`Cannot subscribe to server events from an Archived device: ${event}`,
);
},
off() {},
},
{
deviceType: options.deviceType,
title: options.title,
os: options.os,
serial: options.serial,
icon: 'box',
},
);
this.connected.set(false);
this.source = options.source || '';
this.supportRequestDetails = options.supportRequestDetails;
this.archivedScreenshotHandle = options.screenshotHandle ?? null;
}
archivedScreenshotHandle: string | null;
displayTitle(): string {
return `${this.title} ${this.source ? '(Imported)' : '(Offline)'}`;
}
supportRequestDetails?: SupportFormRequestDetailsState;
spawnShell(): DeviceShell | undefined | null {
return null;
}
getArchivedScreenshotHandle(): string | null {
return this.archivedScreenshotHandle;
}
/**
* @override
*/
async startLogging() {
// No-op
}
/**
* @override
*/
async stopLogging() {
// No-op
}
}

View File

@@ -0,0 +1,350 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import stream from 'stream';
import {
Device,
_SandyDevicePluginInstance,
_SandyPluginDefinition,
DeviceLogListener,
Idler,
createState,
getFlipperLib,
} from 'flipper-plugin';
import {
DeviceLogEntry,
DeviceOS,
DeviceType,
DeviceDescription,
FlipperServer,
} from 'flipper-common';
import {DeviceSpec, PluginDetails} from 'flipper-plugin-lib';
import {getPluginKey} from '../utils/pluginKey';
import {Base64} from 'js-base64';
export type DeviceShell = {
stdout: stream.Readable;
stderr: stream.Readable;
stdin: stream.Writable;
};
type PluginDefinition = _SandyPluginDefinition;
type PluginMap = Map<string, PluginDefinition>;
export type DeviceExport = {
os: DeviceOS;
title: string;
deviceType: DeviceType;
serial: string;
pluginStates: Record<string, any>;
};
export default class BaseDevice implements Device {
description: DeviceDescription;
flipperServer: FlipperServer;
isArchived = false;
hasDevicePlugins = false; // true if there are device plugins for this device (not necessarily enabled)
constructor(flipperServer: FlipperServer, description: DeviceDescription) {
this.flipperServer = flipperServer;
this.description = description;
}
get isConnected(): boolean {
return this.connected.get();
}
// operating system of this device
get os() {
return this.description.os;
}
// human readable name for this device
get title(): string {
return this.description.title;
}
// type of this device
get deviceType() {
return this.description.deviceType;
}
// serial number for this device
get serial() {
return this.description.serial;
}
// additional device specs used for plugin compatibility checks
get specs(): DeviceSpec[] {
return this.description.specs ?? [];
}
// possible src of icon to display next to the device title
get icon() {
return this.description.icon;
}
logListeners: Map<Symbol, DeviceLogListener> = new Map();
readonly connected = createState(true);
// if imported, stores the original source location
source = '';
// TODO: ideally we don't want BasePlugin to know about the concept of plugins
sandyPluginStates: Map<string, _SandyDevicePluginInstance> = new Map<
string,
_SandyDevicePluginInstance
>();
supportsOS(os: DeviceOS) {
return os.toLowerCase() === this.os.toLowerCase();
}
displayTitle(): string {
return this.connected.get() ? this.title : `${this.title} (Offline)`;
}
async exportState(
idler: Idler,
onStatusMessage: (msg: string) => void,
selectedPlugins: string[],
): Promise<Record<string, any>> {
const pluginStates: Record<string, any> = {};
for (const instance of this.sandyPluginStates.values()) {
if (
selectedPlugins.includes(instance.definition.id) &&
instance.isPersistable()
) {
pluginStates[instance.definition.id] = await instance.exportState(
idler,
onStatusMessage,
);
}
}
return pluginStates;
}
toJSON() {
return {
os: this.os,
title: this.title,
deviceType: this.deviceType,
serial: this.serial,
};
}
private deviceLogEventHandler = (payload: {
serial: string;
entry: DeviceLogEntry;
}) => {
if (payload.serial === this.serial && this.logListeners.size > 0) {
this.addLogEntry(payload.entry);
}
};
addLogEntry(entry: DeviceLogEntry) {
this.logListeners.forEach((listener) => {
// prevent breaking other listeners, if one listener doesn't work.
try {
listener(entry);
} catch (e) {
console.error(`Log listener exception:`, e);
}
});
}
async startLogging() {
await this.flipperServer.exec('device-start-logging', this.serial);
this.flipperServer.on('device-log', this.deviceLogEventHandler);
}
stopLogging() {
this.flipperServer.off('device-log', this.deviceLogEventHandler);
return this.flipperServer.exec('device-stop-logging', this.serial);
}
addLogListener(callback: DeviceLogListener): Symbol {
if (this.logListeners.size === 0) {
this.startLogging();
}
const id = Symbol();
this.logListeners.set(id, callback);
return id;
}
removeLogListener(id: Symbol) {
this.logListeners.delete(id);
if (this.logListeners.size === 0) {
this.stopLogging();
}
}
async navigateToLocation(location: string) {
return this.flipperServer.exec('device-navigate', this.serial, location);
}
async screenshotAvailable(): Promise<boolean> {
if (this.isArchived) {
return false;
}
return this.flipperServer.exec('device-supports-screenshot', this.serial);
}
async screenshot(): Promise<Buffer> {
if (this.isArchived) {
return Buffer.from([]);
}
return Buffer.from(
Base64.toUint8Array(
await this.flipperServer.exec('device-take-screenshot', this.serial),
),
);
}
async screenCaptureAvailable(): Promise<boolean> {
if (this.isArchived) {
return false;
}
return this.flipperServer.exec(
'device-supports-screencapture',
this.serial,
);
}
async startScreenCapture(destination: string): Promise<void> {
return this.flipperServer.exec(
'device-start-screencapture',
this.serial,
destination,
);
}
async stopScreenCapture(): Promise<string | null> {
return this.flipperServer.exec('device-stop-screencapture', this.serial);
}
async executeShell(command: string): Promise<string> {
return this.flipperServer.exec('device-shell-exec', this.serial, command);
}
async sendMetroCommand(command: string): Promise<void> {
return this.flipperServer.exec('metro-command', this.serial, command);
}
async forwardPort(local: string, remote: string): Promise<boolean> {
return this.flipperServer.exec(
'device-forward-port',
this.serial,
local,
remote,
);
}
async clearLogs() {
return this.flipperServer.exec('device-clear-logs', this.serial);
}
supportsPlugin(plugin: PluginDefinition | PluginDetails) {
let pluginDetails: PluginDetails;
if (plugin instanceof _SandyPluginDefinition) {
pluginDetails = plugin.details;
if (!pluginDetails.pluginType && !pluginDetails.supportedDevices) {
// TODO T84453692: this branch is to support plugins defined with the legacy approach. Need to remove this branch after some transition period when
// all the plugins will be migrated to the new approach with static compatibility metadata in package.json.
if (plugin instanceof _SandyPluginDefinition) {
return (
plugin.isDevicePlugin &&
(plugin.asDevicePluginModule().supportsDevice?.(this as any) ??
false)
);
} else {
return (plugin as any).supportsDevice(this);
}
}
} else {
pluginDetails = plugin;
}
return (
pluginDetails.pluginType === 'device' &&
(!pluginDetails.supportedDevices ||
pluginDetails.supportedDevices?.some(
(d) =>
(!d.os || d.os === this.os) &&
(!d.type || d.type === this.deviceType) &&
(d.archived === undefined || d.archived === this.isArchived) &&
(!d.specs || d.specs.every((spec) => this.specs.includes(spec))),
))
);
}
loadDevicePlugins(
devicePlugins: PluginMap,
enabledDevicePlugins: Set<string>,
pluginStates?: Record<string, any>,
) {
if (!devicePlugins) {
return;
}
const plugins = Array.from(devicePlugins.values()).filter((p) =>
enabledDevicePlugins?.has(p.id),
);
for (const plugin of plugins) {
this.loadDevicePlugin(plugin, pluginStates?.[plugin.id]);
}
}
loadDevicePlugin(plugin: PluginDefinition, initialState?: any) {
if (!this.supportsPlugin(plugin)) {
return;
}
this.hasDevicePlugins = true;
if (plugin instanceof _SandyPluginDefinition) {
try {
this.sandyPluginStates.set(
plugin.id,
new _SandyDevicePluginInstance(
getFlipperLib(),
plugin,
this,
// break circular dep, one of those days again...
getPluginKey(undefined, {serial: this.serial}, plugin.id),
initialState,
),
);
} catch (e) {
console.error(`Failed to start device plugin '${plugin.id}': `, e);
}
}
}
unloadDevicePlugin(pluginId: string) {
const instance = this.sandyPluginStates.get(pluginId);
if (instance) {
instance.destroy();
this.sandyPluginStates.delete(pluginId);
}
}
disconnect() {
this.logListeners.clear();
this.stopLogging();
this.connected.set(false);
}
destroy() {
this.disconnect();
this.sandyPluginStates.forEach((instance) => {
instance.destroy();
});
this.sandyPluginStates.clear();
}
}

View File

@@ -0,0 +1,312 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import * as DeviceTestPluginModule from '../../test-utils/DeviceTestPlugin';
import {TestUtils, _SandyPluginDefinition} from 'flipper-plugin';
import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {TestDevice} from '../../test-utils/TestDevice';
import ArchivedDevice from '../../devices/ArchivedDevice';
const physicalDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'physicalDevicePlugin',
name: 'flipper-plugin-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'physical',
archived: false,
},
{
os: 'Android',
type: 'physical',
},
],
});
const physicalDevicePlugin = new _SandyPluginDefinition(
physicalDevicePluginDetails,
DeviceTestPluginModule,
);
const iosPhysicalDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'iosPhysicalDevicePlugin',
name: 'flipper-plugin-ios-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'physical',
},
],
});
const iosPhysicalDevicePlugin = new _SandyPluginDefinition(
iosPhysicalDevicePluginDetails,
DeviceTestPluginModule,
);
const iosEmulatorlDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'iosEmulatorDevicePlugin',
name: 'flipper-plugin-ios-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'iOS',
type: 'emulator',
},
],
});
const iosEmulatorDevicePlugin = new _SandyPluginDefinition(
iosEmulatorlDevicePluginDetails,
DeviceTestPluginModule,
);
const androiKaiosPhysicalDevicePluginDetails =
TestUtils.createMockPluginDetails({
id: 'androidPhysicalDevicePlugin',
name: 'flipper-plugin-android-physical-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
type: 'physical',
specs: ['KaiOS'],
},
],
});
const androidKaiosPhysicalDevicePlugin = new _SandyPluginDefinition(
androiKaiosPhysicalDevicePluginDetails,
DeviceTestPluginModule,
);
const androidEmulatorlDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'androidEmulatorDevicePlugin',
name: 'flipper-plugin-android-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
type: 'emulator',
},
],
});
const androidEmulatorDevicePlugin = new _SandyPluginDefinition(
androidEmulatorlDevicePluginDetails,
DeviceTestPluginModule,
);
const androidOnlyDevicePluginDetails = TestUtils.createMockPluginDetails({
id: 'androidEmulatorDevicePlugin',
name: 'flipper-plugin-android-emulator-device',
version: '0.0.1',
pluginType: 'device',
supportedDevices: [
{
os: 'Android',
},
],
});
const androidOnlyDevicePlugin = new _SandyPluginDefinition(
androidOnlyDevicePluginDetails,
DeviceTestPluginModule,
);
test('ios physical device compatibility', () => {
const device = new TestDevice('serial', 'physical', 'test device', 'iOS');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('archived device compatibility', () => {
const device = new ArchivedDevice({
serial: 'serial',
deviceType: 'physical',
title: 'test device',
os: 'iOS',
screenshotHandle: null,
});
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('android emulator device compatibility', () => {
const device = new TestDevice('serial', 'emulator', 'test device', 'Android');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeTruthy();
});
test('android KaiOS device compatibility', () => {
const device = new TestDevice(
'serial',
'physical',
'test device',
'Android',
['KaiOS'],
);
expect(device.supportsPlugin(physicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeTruthy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
});
test('android dummy device compatibility', () => {
const device = new TestDevice('serial', 'dummy', 'test device', 'Android');
expect(device.supportsPlugin(physicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(iosEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidKaiosPhysicalDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidEmulatorDevicePlugin)).toBeFalsy();
expect(device.supportsPlugin(androidOnlyDevicePlugin)).toBeTruthy();
});
test('log listeners are resumed and suspended automatically - 1', async () => {
const message = {
date: new Date(),
message: 'test',
pid: 0,
tid: 1,
type: 'info',
tag: 'tag',
} as const;
const device = new TestDevice('serial', 'physical', 'test device', 'Android');
device.startLogging = jest.fn();
device.stopLogging = jest.fn();
const DevicePlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client) {
const entries: any[] = [];
let disposer: any;
function start() {
disposer = client.onDeviceLogEntry((entry) => {
entries.push(entry);
});
}
function stop() {
disposer?.();
}
start();
return {start, stop, entries};
},
});
await createMockFlipperWithPlugin(DevicePlugin, {
device,
});
const instance = device.sandyPluginStates.get(DevicePlugin.id);
expect(instance).toBeDefined();
const entries = instance?.instanceApi.entries as any[];
// logging set up, messages arrive
expect(device.startLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(1);
// stop, messages don't arrive
instance?.instanceApi.stop();
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(1);
// resume, messsages arrive again
instance?.instanceApi.start();
expect(device.startLogging).toBeCalledTimes(2);
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(2);
// device disconnects, loggers are disposed
device.disconnect();
expect(device.stopLogging).toBeCalledTimes(2);
});
test('log listeners are resumed and suspended automatically - 2', async () => {
const message = {
date: new Date(),
message: 'test',
pid: 0,
tid: 1,
type: 'info',
tag: 'tag',
} as const;
const device = new TestDevice('serial', 'physical', 'test device', 'Android');
device.startLogging = jest.fn();
device.stopLogging = jest.fn();
const entries: any[] = [];
const DevicePlugin = TestUtils.createTestDevicePlugin({
devicePlugin(client) {
client.onDeviceLogEntry((entry) => {
entries.push(entry);
});
return {};
},
});
const Plugin = TestUtils.createTestPlugin(
{
plugin(client) {
client.onDeviceLogEntry((entry) => {
entries.push(entry);
});
return {};
},
},
{
id: 'AnotherPlugin',
},
);
const flipper = await createMockFlipperWithPlugin(DevicePlugin, {
device,
additionalPlugins: [Plugin],
});
const instance = device.sandyPluginStates.get(DevicePlugin.id);
expect(instance).toBeDefined();
// logging set up, messages arrives in both
expect(device.startLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(2);
// disable one plugin
flipper.togglePlugin(Plugin.id);
expect(device.stopLogging).toBeCalledTimes(0);
device.addLogEntry(message);
expect(entries.length).toBe(3);
// disable the other plugin
flipper.togglePlugin(DevicePlugin.id);
expect(device.stopLogging).toBeCalledTimes(1);
device.addLogEntry(message);
expect(entries.length).toBe(3);
// re-enable plugn
flipper.togglePlugin(Plugin.id);
expect(device.startLogging).toBeCalledTimes(2);
device.addLogEntry(message);
expect(entries.length).toBe(4);
});

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlipperPlugin} from '../../plugin';
export default class extends FlipperPlugin<any, any, any> {
static id = 'Static ID';
}
test('TestPlugin', () => {
// supress jest warning
expect(true).toBeTruthy();
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {uriComponents} from '../../deeplink';
test('test parsing of deeplink URL', () => {
const url = 'flipper://app/plugin/meta/data';
const components = uriComponents(url);
expect(components).toEqual(['app', 'plugin', 'meta/data']);
});
test('test parsing of deeplink URL when arguments are less', () => {
const url = 'flipper://app/';
const components = uriComponents(url);
expect(components).toEqual(['app']);
});
test('test parsing of deeplink URL when url is null', () => {
// @ts-ignore
const components = uriComponents(null);
expect(components).toEqual([]);
});
test('test parsing of deeplink URL when pattern does not match', () => {
const url = 'Some random string';
const components = uriComponents(url);
expect(components).toEqual([]);
});
test('test parsing of deeplinkURL when there are query params', () => {
const url = 'flipper://null/React/?device=React%20Native';
const components = uriComponents(url);
expect(components).toEqual(['null', 'React', '?device=React Native']);
});
test('test parsing of deeplinkURL when there are query params without slash', () => {
const url = 'flipper://null/React?device=React%20Native';
const components = uriComponents(url);
expect(components).toEqual(['null', 'React', '?device=React Native']);
});

View File

@@ -0,0 +1,395 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
jest.useFakeTimers();
import React from 'react';
import {renderMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin';
import {
_SandyPluginDefinition,
PluginClient,
TestUtils,
usePlugin,
createState,
useValue,
DevicePluginClient,
Dialog,
} from 'flipper-plugin';
import {parseOpenPluginParams} from '../handleOpenPluginDeeplink';
import {handleDeeplink} from '../../deeplink';
import {selectPlugin} from '../../reducers/connections';
let origAlertImpl: any;
let origConfirmImpl: any;
beforeEach(() => {
origAlertImpl = Dialog.alert;
origConfirmImpl = Dialog.confirm;
});
afterEach(() => {
Dialog.alert = origAlertImpl;
Dialog.confirm = origConfirmImpl;
});
test('open-plugin deeplink parsing', () => {
const testpayload = 'http://www.google/?test=c o%20o+l';
const testLink =
'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload=' +
encodeURIComponent(testpayload);
const res = parseOpenPluginParams(testLink);
expect(res).toEqual({
pluginId: 'graphql',
client: 'facebook',
devices: ['android', 'ios'],
payload: 'http://www.google/?test=c o o+l',
});
});
test('open-plugin deeplink parsing - 2', () => {
const testLink = 'flipper://open-plugin?plugin-id=graphql';
const res = parseOpenPluginParams(testLink);
expect(res).toEqual({
pluginId: 'graphql',
client: undefined,
devices: [],
payload: undefined,
});
});
test('open-plugin deeplink parsing - 3', () => {
expect(() =>
parseOpenPluginParams('flipper://open-plugin?'),
).toThrowErrorMatchingInlineSnapshot(`"Missing plugin-id param"`);
});
test('Triggering a deeplink will work', async () => {
const linksSeen: any[] = [];
const plugin = (client: PluginClient) => {
const linkState = createState('');
client.onDeepLink((link) => {
linksSeen.push(link);
linkState.set(String(link));
});
return {
linkState,
};
};
const definition = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
plugin,
Component() {
const instance = usePlugin(plugin);
const linkState = useValue(instance.linkState);
return <h1>{linkState || 'world'}</h1>;
},
},
);
const {renderer, client, store, logger} = await renderMockFlipperWithPlugin(
definition,
);
logger.track = jest.fn();
expect(linksSeen).toEqual([]);
await handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&client=${client.query.app}&payload=universe`,
);
jest.runAllTimers();
expect(linksSeen).toEqual(['universe']);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<h1>
universe
</h1>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'INIT',
},
undefined,
);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
query:
'flipper://open-plugin?plugin-id=TestPlugin&client=TestApp&payload=universe',
state: 'PLUGIN_OPEN_SUCCESS',
plugin: {
client: 'TestApp',
devices: [],
payload: 'universe',
pluginId: 'TestPlugin',
},
},
'TestPlugin',
);
});
test('triggering a deeplink without applicable device can wait for a device', async () => {
let lastOS: string = '';
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
devicePlugin(c: DevicePluginClient) {
lastOS = c.device.os;
return {};
},
},
{
id: 'DevicePlugin',
supportedDevices: [{os: 'iOS'}],
},
);
const {renderer, store, logger, createDevice, device} =
await renderMockFlipperWithPlugin(definition);
store.dispatch(
selectPlugin({
selectedPlugin: 'nonexisting',
deepLinkPayload: null,
selectedDevice: device,
}),
);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`,
);
jest.runAllTimers();
// No device yet available (dialogs are not renderable atm)
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
// create a new device
createDevice({serial: 'device2', os: 'iOS'});
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
</body>
`);
expect(lastOS).toBe('iOS');
});
test('triggering a deeplink without applicable client can wait for a device', async () => {
const definition = TestUtils.createTestPlugin(
{
Component() {
return <p>Hello</p>;
},
plugin() {
return {};
},
},
{
id: 'pluggy',
},
);
const {renderer, store, createClient, device, logger} =
await renderMockFlipperWithPlugin(definition);
store.dispatch(
selectPlugin({
selectedPlugin: 'nonexisting',
deepLinkPayload: null,
selectedDevice: device,
}),
);
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&client=clienty`,
);
jest.runAllTimers();
// No device yet available (dialogs are not renderable atm)
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
No plugin selected
</div>
</body>
`);
// create a new client
createClient(device, 'clienty');
// wizard should continue automatically
await handlePromise;
expect(renderer.baseElement).toMatchInlineSnapshot(`
<body>
<div>
<div
class="css-1x2cmzz-SandySplitContainer e1hsqii10"
>
<div />
<div
class="css-1knrt0j-SandySplitContainer e1hsqii10"
>
<div
class="css-1woty6b-Container"
>
<p>
Hello
</p>
</div>
<div
class="css-724x97-View-FlexBox-FlexRow"
id="detailsSidebar"
/>
</div>
</div>
</div>
<div />
</body>
`);
});
test('triggering a deeplink with incompatible device will cause bail', async () => {
const definition = TestUtils.createTestDevicePlugin(
{
Component() {
return <p>Hello</p>;
},
devicePlugin() {
return {};
},
},
{
id: 'DevicePlugin',
supportedDevices: [{os: 'iOS'}],
},
);
const {store, logger, createDevice} = await renderMockFlipperWithPlugin(
definition,
);
logger.track = jest.fn();
// Skipping user interactions.
Dialog.alert = (async () => {}) as any;
Dialog.confirm = (async () => {}) as any;
store.dispatch(
selectPlugin({selectedPlugin: 'nonexisting', deepLinkPayload: null}),
);
const handlePromise = handleDeeplink(
store,
logger,
`flipper://open-plugin?plugin-id=${definition.id}&devices=iOS`,
);
jest.runAllTimers();
// create a new device that doesn't match spec
createDevice({serial: 'device2', os: 'Android'});
// wait for dialogues
await handlePromise;
expect(logger.track).toHaveBeenCalledTimes(2);
expect(logger.track).toHaveBeenCalledWith(
'usage',
'deeplink',
{
plugin: {
client: undefined,
devices: ['iOS'],
payload: undefined,
pluginId: 'DevicePlugin',
},
query: 'flipper://open-plugin?plugin-id=DevicePlugin&devices=iOS',
state: 'PLUGIN_DEVICE_BAIL',
},
'DevicePlugin',
);
});

View File

@@ -0,0 +1,268 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
jest.mock('../plugins');
jest.mock('../../utils/electronModuleCache');
import {
loadPlugin,
switchPlugin,
uninstallPlugin,
} from '../../reducers/pluginManager';
import {requirePlugin} from '../plugins';
import {mocked} from 'ts-jest/utils';
import {TestUtils} from 'flipper-plugin';
import * as TestPlugin from '../../test-utils/TestPlugin';
import {_SandyPluginDefinition as SandyPluginDefinition} from 'flipper-plugin';
import MockFlipper from '../../test-utils/MockFlipper';
import Client from '../../Client';
import React from 'react';
import BaseDevice from '../../devices/BaseDevice';
const pluginDetails1 = TestUtils.createMockPluginDetails({
id: 'plugin1',
name: 'flipper-plugin1',
version: '0.0.1',
});
const pluginDefinition1 = new SandyPluginDefinition(pluginDetails1, TestPlugin);
const pluginDetails1V2 = TestUtils.createMockPluginDetails({
id: 'plugin1',
name: 'flipper-plugin1',
version: '0.0.2',
});
const pluginDefinition1V2 = new SandyPluginDefinition(
pluginDetails1V2,
TestPlugin,
);
const pluginDetails2 = TestUtils.createMockPluginDetails({
id: 'plugin2',
name: 'flipper-plugin2',
});
const pluginDefinition2 = new SandyPluginDefinition(pluginDetails2, TestPlugin);
const devicePluginDetails = TestUtils.createMockPluginDetails({
id: 'device',
name: 'flipper-device',
});
const devicePluginDefinition = new SandyPluginDefinition(devicePluginDetails, {
supportsDevice() {
return true;
},
devicePlugin() {
return {};
},
Component() {
return <h1>Plugin3</h1>;
},
});
const mockedRequirePlugin = mocked(requirePlugin);
let mockFlipper: MockFlipper;
let mockClient: Client;
let mockDevice: BaseDevice;
beforeEach(async () => {
mockedRequirePlugin.mockImplementation(
(details) =>
(details === pluginDetails1
? pluginDefinition1
: details === pluginDetails2
? pluginDefinition2
: details === pluginDetails1V2
? pluginDefinition1V2
: details === devicePluginDetails
? devicePluginDefinition
: undefined)!,
);
mockFlipper = new MockFlipper();
const initResult = await mockFlipper.initWithDeviceAndClient({
clientOptions: {supportedPlugins: ['plugin1', 'plugin2']},
});
mockClient = initResult.client;
mockDevice = initResult.device;
});
afterEach(async () => {
mockedRequirePlugin.mockReset();
await mockFlipper.destroy();
});
test('load plugin when no other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load plugin when other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
loadPlugin({
plugin: pluginDetails1V2,
enable: false,
notifyIfFailed: false,
}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1V2,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1V2,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load and enable Sandy plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockClient.sandyPluginStates.has('plugin1')).toBeTruthy();
});
test('uninstall plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition1}));
expect(
mockFlipper.getState().plugins.clientPlugins.has('plugin1'),
).toBeFalsy();
expect(
mockFlipper.getState().plugins.loadedPlugins.has('plugin1'),
).toBeFalsy();
expect(
mockFlipper
.getState()
.plugins.uninstalledPluginNames.has('flipper-plugin1'),
).toBeTruthy();
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('uninstall bundled plugin', async () => {
const pluginDetails = TestUtils.createMockBundledPluginDetails({
id: 'bundled-plugin',
name: 'flipper-bundled-plugin',
version: '0.43.0',
});
const pluginDefinition = new SandyPluginDefinition(pluginDetails, TestPlugin);
mockedRequirePlugin.mockReturnValue(pluginDefinition);
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails, enable: true, notifyIfFailed: false}),
);
mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition}));
expect(
mockFlipper.getState().plugins.clientPlugins.has('bundled-plugin'),
).toBeFalsy();
expect(
mockFlipper.getState().plugins.loadedPlugins.has('bundled-plugin'),
).toBeFalsy();
expect(
mockFlipper
.getState()
.plugins.uninstalledPluginNames.has('flipper-bundled-plugin'),
).toBeTruthy();
expect(mockClient.sandyPluginStates.has('bundled-plugin')).toBeFalsy();
});
test('star plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).toContain('plugin1');
expect(mockClient.sandyPluginStates.has('plugin1')).toBeTruthy();
});
test('disable plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: pluginDefinition1,
selectedApp: mockClient.query.app,
}),
);
expect(
mockFlipper.getState().connections.enabledPlugins[mockClient.query.app],
).not.toContain('plugin1');
expect(mockClient.sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('star device plugin', async () => {
mockFlipper.dispatch(
loadPlugin({
plugin: devicePluginDetails,
enable: false,
notifyIfFailed: false,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeTruthy();
expect(mockDevice.sandyPluginStates.has('device')).toBeTruthy();
});
test('disable device plugin', async () => {
mockFlipper.dispatch(
loadPlugin({
plugin: devicePluginDetails,
enable: false,
notifyIfFailed: false,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
mockFlipper.dispatch(
switchPlugin({
plugin: devicePluginDefinition,
}),
);
expect(
mockFlipper.getState().connections.enabledDevicePlugins.has('device'),
).toBeFalsy();
expect(mockDevice.sandyPluginStates.has('device')).toBeFalsy();
});

View File

@@ -0,0 +1,325 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
jest.mock('../../../../app/src/defaultPlugins');
jest.mock('../../utils/loadDynamicPlugins');
import dispatcher, {
getDynamicPlugins,
checkDisabled,
checkGK,
createRequirePluginFunction,
getLatestCompatibleVersionOfEachPlugin,
} from '../plugins';
import {BundledPluginDetails, InstalledPluginDetails} from 'flipper-plugin-lib';
import path from 'path';
import {createRootReducer, State} from '../../reducers/index';
import {getLogger} from 'flipper-common';
import configureStore from 'redux-mock-store';
import {TEST_PASSING_GK, TEST_FAILING_GK} from '../../fb-stubs/GK';
import TestPlugin from './TestPlugin';
import {resetConfigForTesting} from '../../utils/processConfig';
import {_SandyPluginDefinition} from 'flipper-plugin';
import {mocked} from 'ts-jest/utils';
import loadDynamicPlugins from '../../utils/loadDynamicPlugins';
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
const mockStore = configureStore<State, {}>([])(
createRootReducer()(undefined, {type: 'INIT'}),
);
const logger = getLogger();
const sampleInstalledPluginDetails: InstalledPluginDetails = {
name: 'other Name',
version: '1.0.0',
specVersion: 2,
pluginType: 'client',
main: 'dist/bundle.js',
source: 'src/index.js',
id: 'Sample',
title: 'Sample',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: 'this/path/does not/exist',
isBundled: false,
isActivatable: true,
};
const sampleBundledPluginDetails: BundledPluginDetails = {
...sampleInstalledPluginDetails,
id: 'SampleBundled',
isBundled: true,
};
beforeEach(() => {
resetConfigForTesting();
loadDynamicPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
loadDynamicPluginsMock.mockClear();
});
test('dispatcher dispatches REGISTER_PLUGINS', async () => {
await dispatcher(mockStore, logger);
const actions = mockStore.getActions();
expect(actions.map((a) => a.type)).toContain('REGISTER_PLUGINS');
});
test('getDynamicPlugins returns empty array on errors', async () => {
const loadDynamicPluginsMock = mocked(loadDynamicPlugins);
loadDynamicPluginsMock.mockRejectedValue(new Error('ooops'));
const res = await getDynamicPlugins();
expect(res).toEqual([]);
});
test('checkDisabled', () => {
const disabledPlugin = 'pluginName';
const config = {disabledPlugins: [disabledPlugin]};
const orig = process.env.CONFIG;
try {
process.env.CONFIG = JSON.stringify(config);
const disabled = checkDisabled([]);
expect(
disabled({
...sampleBundledPluginDetails,
name: 'other Name',
version: '1.0.0',
}),
).toBeTruthy();
expect(
disabled({
...sampleBundledPluginDetails,
name: disabledPlugin,
version: '1.0.0',
}),
).toBeFalsy();
} finally {
process.env.CONFIG = orig;
}
});
test('checkGK for plugin without GK', () => {
expect(
checkGK([])({
...sampleBundledPluginDetails,
name: 'pluginID',
version: '1.0.0',
}),
).toBeTruthy();
});
test('checkGK for passing plugin', () => {
expect(
checkGK([])({
...sampleBundledPluginDetails,
name: 'pluginID',
gatekeeper: TEST_PASSING_GK,
version: '1.0.0',
}),
).toBeTruthy();
});
test('checkGK for failing plugin', () => {
const gatekeepedPlugins: InstalledPluginDetails[] = [];
const name = 'pluginID';
const plugins = checkGK(gatekeepedPlugins)({
...sampleBundledPluginDetails,
name,
gatekeeper: TEST_FAILING_GK,
version: '1.0.0',
});
expect(plugins).toBeFalsy();
expect(gatekeepedPlugins[0].name).toEqual(name);
});
test('requirePlugin returns null for invalid requires', () => {
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name: 'pluginID',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: 'this/path/does not/exist',
version: '1.0.0',
});
expect(plugin).toBeNull();
});
test('requirePlugin loads plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name,
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample',
entry: path.join(__dirname, 'TestPlugin'),
version: '1.0.0',
});
expect(plugin).not.toBeNull();
expect(Object.keys(plugin as any)).toEqual([
'id',
'details',
'isDevicePlugin',
'module',
]);
expect(Object.keys((plugin as any).module)).toEqual(['plugin', 'Component']);
expect(plugin!.id).toBe(TestPlugin.id);
});
test('newest version of each plugin is used', () => {
const bundledPlugins: BundledPluginDetails[] = [
{
...sampleBundledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.1.0',
},
{
...sampleBundledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.201',
},
];
const installedPlugins: InstalledPluginDetails[] = [
{
...sampleInstalledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.21',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test2',
entry: './test/index.js',
},
{
...sampleInstalledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.10.0',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test1',
entry: './test/index.js',
},
];
const filteredPlugins = getLatestCompatibleVersionOfEachPlugin([
...bundledPlugins,
...installedPlugins,
]);
expect(filteredPlugins).toHaveLength(2);
expect(filteredPlugins).toContainEqual({
...sampleInstalledPluginDetails,
id: 'TestPlugin1',
name: 'flipper-plugin-test1',
version: '0.10.0',
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-test1',
entry: './test/index.js',
});
expect(filteredPlugins).toContainEqual({
...sampleBundledPluginDetails,
id: 'TestPlugin2',
name: 'flipper-plugin-test2',
version: '0.1.0-alpha.201',
});
});
test('requirePlugin loads valid Sandy plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
name,
dir: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/TestPlugin',
),
entry: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/TestPlugin',
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');
expect(plugin.details).toMatchObject({
flipperSDKVersion: '0.0.0',
id: 'Sample',
isBundled: false,
main: 'dist/bundle.js',
name: 'pluginID',
source: 'src/index.js',
specVersion: 2,
title: 'Sample',
version: '1.0.0',
});
expect(plugin.isDevicePlugin).toBe(false);
expect(typeof plugin.module.Component).toBe('function');
expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)');
expect(typeof plugin.asPluginModule().plugin).toBe('function');
});
test('requirePlugin errors on invalid Sandy plugin', () => {
const name = 'pluginID';
const failedPlugins: any[] = [];
const requireFn = createRequirePluginFunction(failedPlugins, require);
requireFn({
...sampleInstalledPluginDetails,
name,
// Intentionally the wrong file:
dir: __dirname,
entry: path.join(__dirname, 'TestPlugin'),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
});
expect(failedPlugins[0][1]).toMatchInlineSnapshot(
`"Flipper plugin 'Sample' should export named function called 'plugin'"`,
);
});
test('requirePlugin loads valid Sandy Device plugin', () => {
const name = 'pluginID';
const requireFn = createRequirePluginFunction([], require);
const plugin = requireFn({
...sampleInstalledPluginDetails,
pluginType: 'device',
name,
dir: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/DeviceTestPlugin',
),
entry: path.join(
__dirname,
'../../../../flipper-plugin/src/__tests__/DeviceTestPlugin',
),
version: '1.0.0',
flipperSDKVersion: '0.0.0',
}) as _SandyPluginDefinition;
expect(plugin).not.toBeNull();
expect(plugin).toBeInstanceOf(_SandyPluginDefinition);
expect(plugin.id).toBe('Sample');
expect(plugin.details).toMatchObject({
flipperSDKVersion: '0.0.0',
id: 'Sample',
isBundled: false,
main: 'dist/bundle.js',
name: 'pluginID',
source: 'src/index.js',
specVersion: 2,
title: 'Sample',
version: '1.0.0',
});
expect(plugin.isDevicePlugin).toBe(true);
expect(typeof plugin.module.Component).toBe('function');
expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)');
expect(typeof plugin.asDevicePluginModule().devicePlugin).toBe('function');
expect(typeof plugin.asDevicePluginModule().supportsDevice).toBe('function');
});

View File

@@ -0,0 +1,231 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {computeUsageSummary} from '../tracking';
import type {State} from '../../reducers/usageTracking';
import type {SelectionInfo} from '../../utils/info';
const layoutSelection: SelectionInfo = {
plugin: 'Layout',
pluginName: 'flipper-plugin-layout',
pluginVersion: '0.0.0',
pluginEnabled: true,
app: 'Facebook',
device: 'test device',
deviceName: 'test device',
deviceSerial: 'serial',
deviceType: 'emulator',
os: 'iOS',
archived: false,
};
const networkSelection = {...layoutSelection, plugin: 'Network'};
const databasesSelection = {...layoutSelection, plugin: 'Databases'};
const layoutPluginKey = JSON.stringify(layoutSelection);
const networkPluginKey = JSON.stringify(networkSelection);
const databasesPluginKey = JSON.stringify(databasesSelection);
test('Never focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: false}],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 0, 100);
});
test('Always focused', () => {
const state: State = {
timeline: [{type: 'TIMELINE_START', time: 100, isFocused: true}],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 100, 0);
});
test('Focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
],
};
const result = computeUsageSummary(state, 350);
expect(result.total).toReportTimeSpent('total', 50, 200);
});
test('Unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
],
};
const result = computeUsageSummary(state, 350);
expect(result.total).toReportTimeSpent('total', 200, 50);
});
test('Unfocused then focused then unfocused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 200, 350);
});
test('Focused then unfocused then focused', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{type: 'WINDOW_FOCUS_CHANGE', time: 150, isFocused: false},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: true},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 350, 200);
});
test('Always focused plugin change', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
],
};
const result = computeUsageSummary(state, 200);
expect(result.total).toReportTimeSpent('total', 100, 0);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 50, 0);
});
test('Focused then plugin change then unfocusd', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{type: 'WINDOW_FOCUS_CHANGE', time: 350, isFocused: false},
],
};
const result = computeUsageSummary(state, 650);
expect(result.total).toReportTimeSpent('total', 250, 300);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 200, 300);
});
test('Multiple plugin changes', () => {
const state: State = {
timeline: [
{type: 'TIMELINE_START', time: 100, isFocused: true},
{
type: 'SELECTION_CHANGED',
time: 150,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{
type: 'SELECTION_CHANGED',
time: 350,
selectionKey: networkPluginKey,
selection: networkSelection,
},
{
type: 'SELECTION_CHANGED',
time: 650,
selectionKey: layoutPluginKey,
selection: layoutSelection,
},
{
type: 'SELECTION_CHANGED',
time: 1050,
selectionKey: databasesPluginKey,
selection: databasesSelection,
},
],
};
const result = computeUsageSummary(state, 1550);
expect(result.total).toReportTimeSpent('total', 1450, 0);
expect(result.plugin[layoutPluginKey]).toReportTimeSpent('Layout', 600, 0);
expect(result.plugin[networkPluginKey]).toReportTimeSpent('Network', 300, 0);
expect(result.plugin[databasesPluginKey]).toReportTimeSpent(
'Databases',
500,
0,
);
});
declare global {
namespace jest {
interface Matchers<R> {
toReportTimeSpent(
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
): R;
}
}
}
expect.extend({
toReportTimeSpent(
received: {focusedTime: number; unfocusedTime: number} | undefined,
plugin: string,
focusedTimeSpent: number,
unfocusedTimeSpent: number,
) {
if (!received) {
return {
message: () =>
`expected to have tracking element for plugin ${plugin}, but was not found`,
pass: false,
};
}
const focusedPass = received.focusedTime === focusedTimeSpent;
const unfocusedPass = received.unfocusedTime === unfocusedTimeSpent;
if (!focusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have focused time spent: ${focusedTimeSpent} for plugin ${plugin}, but was ${
received.focusedTime
}`,
pass: false,
};
}
if (!unfocusedPass) {
return {
message: () =>
`expected ${JSON.stringify(
received,
)} to have unfocused time spent: ${unfocusedTimeSpent} for plugin ${plugin}, but was ${
received.unfocusedTime
}`,
pass: false,
};
}
return {
message: () =>
`expected ${JSON.stringify(
received,
)} not to have focused time spent: ${focusedTimeSpent} and unfocused: ${unfocusedTimeSpent}`,
pass: true,
};
},
});

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {
importFileToStore,
IMPORT_FLIPPER_TRACE_EVENT,
} from '../utils/exportData';
import {tryCatchReportPlatformFailures} from 'flipper-common';
import {handleDeeplink} from '../deeplink';
import {Dialog} from 'flipper-plugin';
import {getRenderHostInstance} from '../RenderHost';
export default (store: Store, logger: Logger) => {
const renderHost = getRenderHostInstance();
const onFocus = () => {
setImmediate(() => {
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: true, time: Date.now()},
});
});
};
const onBlur = () => {
setImmediate(() => {
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: false, time: Date.now()},
});
});
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
window.addEventListener('beforeunload', () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
});
// windowIsFocussed is initialized in the store before the app is fully ready.
// So wait until everything is up and running and then check and set the isFocussed state.
window.addEventListener('flipper-store-ready', () => {
const isFocused = renderHost.hasFocus();
store.dispatch({
type: 'windowIsFocused',
payload: {isFocused: isFocused, time: Date.now()},
});
});
renderHost.onIpcEvent('flipper-protocol-handler', (query: string) => {
handleDeeplink(store, logger, query).catch((e) => {
console.warn('Failed to handle deeplink', query, e);
Dialog.alert({
title: 'Failed to open deeplink',
type: 'error',
message: `Failed to handle deeplink '${query}': ${
e.message ?? e.toString()
}`,
});
});
});
renderHost.onIpcEvent('open-flipper-file', (url: string) => {
tryCatchReportPlatformFailures(() => {
return importFileToStore(url, store);
}, `${IMPORT_FLIPPER_TRACE_EVENT}:Deeplink`);
});
};

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export async function loadPluginsFromMarketplace() {
// Marketplace is not implemented in public version of Flipper
}
export default () => {
// Marketplace is not implemented in public version of Flipper
};

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export default () => {
// no public implementation
};

View File

@@ -0,0 +1,308 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {State, Store} from '../reducers/index';
import {FlipperServer, Logger} from 'flipper-common';
import {FlipperServerImpl} from 'flipper-server-core';
import {selectClient} from '../reducers/connections';
import Client from '../Client';
import {notification} from 'antd';
import BaseDevice from '../devices/BaseDevice';
import {ClientDescription, timeout} from 'flipper-common';
import {reportPlatformFailures} from 'flipper-common';
import {sideEffect} from '../utils/sideEffect';
import {getStaticPath} from '../utils/pathUtils';
import constants from '../fb-stubs/constants';
import {getRenderHostInstance} from '../RenderHost';
export default async (store: Store, logger: Logger) => {
const {enableAndroid, androidHome, idbPath, enableIOS, enablePhysicalIOS} =
store.getState().settingsState;
const server = new FlipperServerImpl(
{
enableAndroid,
androidHome,
idbPath,
enableIOS,
enablePhysicalIOS,
staticPath: getStaticPath(),
tmpPath: getRenderHostInstance().paths.tempPath,
validWebSocketOrigins: constants.VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES,
},
logger,
);
store.dispatch({
type: 'SET_FLIPPER_SERVER',
payload: server,
});
server.on('notification', ({type, title, description}) => {
console.warn(`[$type] ${title}: ${description}`);
notification.open({
message: title,
description: description,
type: type,
duration: 0,
});
});
server.on('server-error', (err) => {
notification.error({
message: 'Failed to start connection server',
description:
err.code === 'EADDRINUSE' ? (
<>
Couldn't start connection server. Looks like you have multiple
copies of Flipper running or another process is using the same
port(s). As a result devices will not be able to connect to Flipper.
<br />
<br />
Please try to kill the offending process by running{' '}
<code>kill $(lsof -ti:PORTNUMBER)</code> and restart flipper.
<br />
<br />
{'' + err}
</>
) : (
<>Failed to start Flipper server: ${err.message}</>
),
duration: null,
});
});
server.on('device-connected', (deviceInfo) => {
logger.track('usage', 'register-device', {
os: deviceInfo.os,
name: deviceInfo.title,
serial: deviceInfo.serial,
});
const existing = store
.getState()
.connections.devices.find(
(device) => device.serial === deviceInfo.serial,
);
// handled outside reducer, as it might emit new redux actions...
if (existing) {
if (existing.connected.get()) {
console.warn(
`Tried to replace still connected device '${existing.serial}' with a new instance.`,
);
}
existing.destroy();
}
const device = new BaseDevice(server, deviceInfo);
device.loadDevicePlugins(
store.getState().plugins.devicePlugins,
store.getState().connections.enabledDevicePlugins,
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
});
server.on('device-disconnected', (device) => {
logger.track('usage', 'unregister-device', {
os: device.os,
serial: device.serial,
});
// N.B.: note that we don't remove the device, we keep it in offline
});
server.on('client-setup', (client) => {
store.dispatch({
type: 'START_CLIENT_SETUP',
payload: client,
});
});
server.on('client-connected', (payload: ClientDescription) =>
handleClientConnected(server, store, logger, payload),
);
server.on('client-disconnected', ({id}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.disconnect();
});
server.on('client-message', ({id, message}) => {
const existingClient = store.getState().connections.clients.get(id);
existingClient?.onMessage(message);
});
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
server.close();
});
}
server
.start()
.then(() => {
console.log(
'Flipper server started and accepting device / client connections',
);
})
.catch((e) => {
console.error('Failed to start Flipper server', e);
notification.error({
message: 'Failed to start Flipper server',
description: 'error: ' + e,
});
});
return () => {
server.close();
};
};
export async function handleClientConnected(
server: Pick<FlipperServer, 'exec'>,
store: Store,
logger: Logger,
{id, query}: ClientDescription,
) {
const {connections} = store.getState();
const existingClient = connections.clients.get(id);
if (existingClient) {
existingClient.destroy();
store.dispatch({
type: 'CLEAR_CLIENT_PLUGINS_STATE',
payload: {
clientId: id,
devicePlugins: new Set(),
},
});
store.dispatch({
type: 'CLIENT_REMOVED',
payload: id,
});
}
console.log(
`[conn] Searching matching device ${query.device_id} for client ${query.app}...`,
);
const device =
getDeviceBySerial(store.getState(), query.device_id) ??
(await findDeviceForConnection(store, query.app, query.device_id).catch(
(e) => {
console.error(
`[conn] Failed to find device '${query.device_id}' while connection app '${query.app}'`,
e,
);
notification.error({
message: 'Connection failed',
description: `Failed to find device '${query.device_id}' while trying to connect app '${query.app}'`,
duration: 0,
});
},
));
if (!device) {
return;
}
const client = new Client(
id,
query,
{
send(data: any) {
server.exec('client-request', id, data);
},
async sendExpectResponse(data: any) {
return await server.exec('client-request-response', id, data);
},
},
logger,
store,
undefined,
device,
);
console.debug(
`Device client initialized: ${client.id}. Supported plugins: ${Array.from(
client.plugins,
).join(', ')}`,
'server',
);
store.dispatch({
type: 'NEW_CLIENT',
payload: client,
});
store.dispatch(selectClient(client.id));
await timeout(
30 * 1000,
client.init(),
`[conn] Failed to initialize client ${query.app} on ${query.device_id} in a timely manner`,
);
console.log(`[conn] ${query.app} on ${query.device_id} connected and ready.`);
}
function getDeviceBySerial(
state: State,
serial: string,
): BaseDevice | undefined {
return state.connections.devices.find((device) => device.serial === serial);
}
async function findDeviceForConnection(
store: Store,
clientId: string,
serial: string,
): Promise<BaseDevice> {
let lastSeenDeviceList: BaseDevice[] = [];
/* All clients should have a corresponding Device in the store.
However, clients can connect before a device is registered, so wait a
while for the device to be registered if it isn't already. */
return reportPlatformFailures(
new Promise<BaseDevice>((resolve, reject) => {
let unsubscribe: () => void = () => {};
const timeout = setTimeout(() => {
unsubscribe();
reject(
new Error(
`Timed out waiting for device ${serial} for client ${clientId}`,
),
);
}, 15000);
unsubscribe = sideEffect(
store,
{name: 'waitForDevice', throttleMs: 100},
(state) => state.connections.devices,
(newDeviceList) => {
if (newDeviceList === lastSeenDeviceList) {
return;
}
lastSeenDeviceList = newDeviceList;
const matchingDevice = newDeviceList.find(
(device) => device.serial === serial,
);
if (matchingDevice) {
console.log(`[conn] Found device for: ${clientId} on ${serial}.`);
clearTimeout(timeout);
resolve(matchingDevice);
unsubscribe();
}
},
);
}),
'client-setMatchingDevice',
);
}

View File

@@ -0,0 +1,646 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React from 'react';
import {Dialog, getFlipperLib} from 'flipper-plugin';
import {isTest} from 'flipper-common';
import {getUser} from '../fb-stubs/user';
import {State, Store} from '../reducers/index';
import {checkForUpdate} from '../fb-stubs/checkForUpdate';
import {getAppVersion} from '../utils/info';
import {UserNotSignedInError} from 'flipper-common';
import {
canBeDefaultDevice,
selectPlugin,
setPluginEnabled,
} from '../reducers/connections';
import {getUpdateAvailableMessage} from '../chrome/UpdateIndicator';
import {Typography} from 'antd';
import {getPluginStatus, PluginStatus} from '../utils/pluginUtils';
import {loadPluginsFromMarketplace} from './fb-stubs/pluginMarketplace';
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
import {startPluginDownload} from '../reducers/pluginDownloads';
import isProduction from '../utils/isProduction';
import BaseDevice from '../devices/BaseDevice';
import Client from '../Client';
import {RocketOutlined} from '@ant-design/icons';
import {showEmulatorLauncher} from '../sandy-chrome/appinspect/LaunchEmulator';
import {getAllClients} from '../reducers/connections';
import {showLoginDialog} from '../chrome/fb-stubs/SignInSheet';
import {
DeeplinkInteraction,
DeeplinkInteractionState,
OpenPluginParams,
} from '../deeplinkTracking';
import {getRenderHostInstance} from '../RenderHost';
export function parseOpenPluginParams(query: string): OpenPluginParams {
// 'flipper://open-plugin?plugin-id=graphql&client=facebook&devices=android,ios&chrome=1&payload='
const url = new URL(query);
const params = new Map<string, string>(url.searchParams as any);
if (!params.has('plugin-id')) {
throw new Error('Missing plugin-id param');
}
return {
pluginId: params.get('plugin-id')!,
client: params.get('client'),
devices: params.get('devices')?.split(',') ?? [],
payload: params.get('payload')
? decodeURIComponent(params.get('payload')!)
: undefined,
};
}
export async function handleOpenPluginDeeplink(
store: Store,
query: string,
trackInteraction: (interaction: DeeplinkInteraction) => void,
) {
const params = parseOpenPluginParams(query);
const title = `Opening plugin ${params.pluginId}`;
console.debug(`[deeplink] ${title} for with params`, params);
if (!(await verifyLighthouseAndUserLoggedIn(store, title))) {
trackInteraction({
state: 'PLUGIN_LIGHTHOUSE_BAIL',
plugin: params,
});
return;
}
console.debug('[deeplink] Cleared Lighthouse and log-in check.');
await verifyFlipperIsUpToDate(title);
console.debug('[deeplink] Cleared up-to-date check.');
const [pluginStatusResult, pluginStatus] = await verifyPluginStatus(
store,
params.pluginId,
title,
);
if (!pluginStatusResult) {
trackInteraction({
state: 'PLUGIN_STATUS_BAIL',
plugin: params,
extra: {pluginStatus},
});
return;
}
console.debug('[deeplink] Cleared plugin status check:', pluginStatusResult);
const isDevicePlugin = store
.getState()
.plugins.devicePlugins.has(params.pluginId);
const pluginDefinition = isDevicePlugin
? store.getState().plugins.devicePlugins.get(params.pluginId)!
: store.getState().plugins.clientPlugins.get(params.pluginId)!;
const deviceOrClient = await selectDevicesAndClient(
store,
params,
title,
isDevicePlugin,
);
console.debug('[deeplink] Selected device and client:', deviceOrClient);
if ('errorState' in deviceOrClient) {
trackInteraction({
state: deviceOrClient.errorState,
plugin: params,
});
return;
}
const client: Client | undefined = isDevicePlugin
? undefined
: (deviceOrClient as Client);
const device: BaseDevice = isDevicePlugin
? (deviceOrClient as BaseDevice)
: (deviceOrClient as Client).device;
console.debug('[deeplink] Client: ', client);
console.debug('[deeplink] Device: ', device);
// verify plugin supported by selected device / client
if (isDevicePlugin && !device.supportsPlugin(pluginDefinition)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by device ${device.displayTitle()}`,
});
trackInteraction({
state: 'PLUGIN_DEVICE_UNSUPPORTED',
plugin: params,
extra: {device: device.displayTitle()},
});
return;
}
console.debug('[deeplink] Cleared device plugin support check.');
if (!isDevicePlugin && !client!.plugins.has(params.pluginId)) {
await Dialog.alert({
title,
type: 'error',
message: `This plugin is not supported by client ${client!.query.app}`,
});
trackInteraction({
state: 'PLUGIN_CLIENT_UNSUPPORTED',
plugin: params,
extra: {client: client!.query.app},
});
return;
}
console.debug('[deeplink] Cleared client plugin support check.');
// verify plugin enabled
if (isDevicePlugin) {
// for the device plugins enabling is a bit more complication and should go through the pluginManager
if (
!store.getState().connections.enabledDevicePlugins.has(params.pluginId)
) {
store.dispatch(switchPlugin({plugin: pluginDefinition}));
}
} else {
store.dispatch(setPluginEnabled(params.pluginId, client!.query.app));
}
console.debug('[deeplink] Cleared plugin enabling.');
// open the plugin
if (isDevicePlugin) {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: null,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
} else {
store.dispatch(
selectPlugin({
selectedPlugin: params.pluginId,
selectedAppId: client!.id,
selectedDevice: device,
deepLinkPayload: params.payload,
}),
);
}
trackInteraction({
state: 'PLUGIN_OPEN_SUCCESS',
plugin: params,
});
}
// check if user is connected to VPN and logged in. Returns true if OK, or false if aborted
async function verifyLighthouseAndUserLoggedIn(
store: Store,
title: string,
): Promise<boolean> {
if (!getFlipperLib().isFB || process.env.NODE_ENV === 'test') {
return true; // ok, continue
}
// repeat until connection succeeded
while (true) {
const spinnerDialog = Dialog.loading({
title,
message: 'Checking connection to Facebook Intern',
});
try {
const user = await getUser();
spinnerDialog.close();
// User is logged in
if (user) {
return true;
} else {
// Connected, but not logged in or no valid profile object returned
return await showPleaseLoginDialog(store, title);
}
} catch (e) {
spinnerDialog.close();
if (e instanceof UserNotSignedInError) {
// connection, but user is not logged in
return await showPleaseLoginDialog(store, title);
}
// General connection error.
// Not connected (to presumably) intern at all
if (
!(await Dialog.confirm({
title,
message:
'It looks you are currently not connected to Lighthouse / VPN. Please connect and retry.',
okText: 'Retry',
}))
) {
return false;
}
}
}
}
async function showPleaseLoginDialog(
store: Store,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: 'You are currently not logged in, please login.',
okText: 'Login',
}))
) {
// cancelled login
return false;
}
await showLoginDialog();
// wait until login succeeded
await waitForLogin(store);
return true;
}
async function waitForLogin(store: Store) {
return waitFor(store, (state) => !!state.user?.id);
}
// make this more reusable?
function waitFor(
store: Store,
predicate: (state: State) => boolean,
): Promise<void> {
return new Promise<void>((resolve) => {
const unsub = store.subscribe(() => {
if (predicate(store.getState())) {
unsub();
resolve();
}
});
});
}
async function verifyFlipperIsUpToDate(title: string) {
if (!isProduction() || isTest()) {
return;
}
const currentVersion = getAppVersion();
const handle = Dialog.loading({
title,
message: 'Checking if Flipper is up-to-date',
});
try {
const result = await checkForUpdate(currentVersion);
handle.close();
switch (result.kind) {
case 'error':
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', result);
return;
case 'up-to-date':
return;
case 'update-available':
await Dialog.confirm({
title,
message: (
<Typography.Text>
{getUpdateAvailableMessage(result)}
</Typography.Text>
),
okText: 'Skip',
});
return;
}
} catch (e) {
// if we can't tell if we're up to date, we don't want to halt the process on that.
console.warn('Failed to verify Flipper version', e);
handle.close();
}
}
async function verifyPluginStatus(
store: Store,
pluginId: string,
title: string,
): Promise<[boolean, PluginStatus]> {
// make sure we have marketplace plugin data present
if (!isTest() && !store.getState().plugins.marketplacePlugins.length) {
// plugins not yet fetched
// updates plugins from marketplace (if logged in), and stores them
await loadPluginsFromMarketplace();
}
// while true loop; after pressing install or add GK, we want to check again if plugin is available
while (true) {
const [status, reason] = getPluginStatus(store, pluginId);
switch (status) {
case 'ready':
return [true, status];
case 'unknown':
await Dialog.alert({
type: 'warning',
title,
message: `No plugin with id '${pluginId}' is known to Flipper. Please correct the deeplink, or install the plugin from NPM using the plugin manager.`,
});
return [false, status];
case 'failed':
await Dialog.alert({
type: 'error',
title,
message: `We found plugin '${pluginId}', but failed to load it: ${reason}. Please check the logs for more details`,
});
return [false, status];
case 'gatekeeped':
if (
!(await Dialog.confirm({
title,
message: (
<p>
{`To use plugin '${pluginId}', it is necessary to be a member of the GK '${reason}'. Click `}
<Typography.Link
href={`https://www.internalfb.com/intern/gatekeeper/projects/${reason}`}>
here
</Typography.Link>{' '}
to enroll, restart Flipper, and click the link again.
</p>
),
okText: 'Restart',
onConfirm: async () => {
getRenderHostInstance().restartFlipper();
// intentionally forever pending, we're restarting...
return new Promise(() => {});
},
}))
) {
return [false, status];
}
break;
case 'bundle_installable': {
// For convenience, don't ask user to install bundled plugins, handle it directly
await installBundledPlugin(store, pluginId, title);
break;
}
case 'marketplace_installable': {
if (!(await installMarketPlacePlugin(store, pluginId, title))) {
return [false, status];
}
break;
}
default:
throw new Error('Unhandled state: ' + status);
}
}
}
async function installBundledPlugin(
store: Store,
pluginId: string,
title: string,
) {
const plugin = store.getState().plugins.bundledPlugins.get(pluginId);
if (!plugin || !plugin.isBundled) {
throw new Error(`Failed to find bundled plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Loading plugin '${pluginId}'...`,
});
store.dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
try {
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'bundle_installable',
);
} finally {
loadingDialog.close();
}
}
async function installMarketPlacePlugin(
store: Store,
pluginId: string,
title: string,
): Promise<boolean> {
if (
!(await Dialog.confirm({
title,
message: `The requested plugin '${pluginId}' is currently not installed, but can be downloaded from the Flipper plugin Marketplace. If you trust the source of the current link, press 'Install' to continue`,
okText: 'Install',
}))
) {
return false;
}
const plugin = store
.getState()
.plugins.marketplacePlugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(`Failed to find marketplace plugin '${pluginId}'`);
}
const loadingDialog = Dialog.loading({
title,
message: `Installing plugin '${pluginId}'...`,
});
try {
store.dispatch(startPluginDownload({plugin, startedByUser: true}));
await waitFor(
store,
() => getPluginStatus(store, pluginId)[0] !== 'marketplace_installable',
);
} finally {
loadingDialog.close();
}
return true;
}
type DeeplinkError = {
errorState: DeeplinkInteractionState;
};
async function selectDevicesAndClient(
store: Store,
params: OpenPluginParams,
title: string,
isDevicePlugin: boolean,
): Promise<DeeplinkError | BaseDevice | Client> {
function findValidDevices() {
// find connected devices with the right OS.
return (
store
.getState()
.connections.devices.filter((d) => d.connected.get())
.filter(
(d) => params.devices.length === 0 || params.devices.includes(d.os),
)
// This filters out OS-level devices which are causing more confusion than good
// when used with deeplinks.
.filter(canBeDefaultDevice)
);
}
// loop until we have devices (or abort)
while (!findValidDevices().length) {
if (!(await launchDeviceDialog(store, params, title))) {
return {errorState: 'PLUGIN_DEVICE_BAIL'};
}
}
// at this point we have 1 or more valid devices
const availableDevices = findValidDevices();
console.debug(
'[deeplink] selectDevicesAndClient found at least one more valid device:',
availableDevices,
);
// device plugin
if (isDevicePlugin) {
if (availableDevices.length === 1) {
return availableDevices[0];
}
const selectedDevice = await selectDeviceDialog(availableDevices, title);
if (!selectedDevice) {
return {errorState: 'PLUGIN_DEVICE_SELECTION_BAIL'};
}
return selectedDevice;
}
console.debug('[deeplink] Not a device plugin. Waiting for valid client.');
// wait for valid client
while (true) {
const origClients = store.getState().connections.clients;
const validClients = getAllClients(store.getState().connections)
.filter(
// correct app name, or, if not set, an app that at least supports this plugin
(c) =>
params.client
? c.query.app === params.client
: c.plugins.has(params.pluginId),
)
.filter((c) => c.connected.get())
.filter((c) => availableDevices.includes(c.device));
if (validClients.length === 1) {
return validClients[0];
}
if (validClients.length > 1) {
const selectedClient = await selectClientDialog(validClients, title);
if (!selectedClient) {
return {errorState: 'PLUGIN_CLIENT_SELECTION_BAIL'};
}
return selectedClient;
}
// no valid client yet
const result = await new Promise<boolean>((resolve) => {
const dialog = Dialog.alert({
title,
type: 'warning',
message: params.client
? `Application '${params.client}' doesn't seem to be connected yet. Please start a debug version of the app to continue.`
: `No application that supports plugin '${params.pluginId}' seems to be running. Please start a debug application that supports the plugin to continue.`,
okText: 'Cancel',
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => resolve(false));
// eslint-disable-next-line promise/catch-or-return
waitFor(store, (state) => state.connections.clients !== origClients).then(
() => {
dialog.close();
resolve(true);
},
);
// We also want to react to changes in the available plugins and refresh.
origClients.forEach((c) =>
c.on('plugins-change', () => {
dialog.close();
resolve(true);
}),
);
});
if (!result) {
return {errorState: 'PLUGIN_CLIENT_BAIL'}; // User cancelled
}
}
}
/**
* Shows a warning that no device was found, with button to launch emulator.
* Resolves false if cancelled, or true if new devices were detected.
*/
async function launchDeviceDialog(
store: Store,
params: OpenPluginParams,
title: string,
) {
return new Promise<boolean>((resolve) => {
const currentDevices = store.getState().connections.devices;
const waitForNewDevice = async () =>
await waitFor(
store,
(state) => state.connections.devices !== currentDevices,
);
const dialog = Dialog.confirm({
title,
message: (
<p>
To open the current deeplink for plugin {params.pluginId} a device{' '}
{params.devices.length ? ' of type ' + params.devices.join(', ') : ''}{' '}
should be up and running. No device was found. Please connect a device
or launch an emulator / simulator.
</p>
),
cancelText: 'Cancel',
okText: 'Launch Device',
onConfirm: async () => {
showEmulatorLauncher(store);
await waitForNewDevice();
return true;
},
okButtonProps: {
icon: <RocketOutlined />,
},
});
// eslint-disable-next-line promise/catch-or-return
dialog.then(() => {
// dialog was cancelled
resolve(false);
});
// new devices were found
// eslint-disable-next-line promise/catch-or-return
waitForNewDevice().then(() => {
dialog.close();
resolve(true);
});
});
}
async function selectDeviceDialog(
devices: BaseDevice[],
title: string,
): Promise<undefined | BaseDevice> {
const selectedId = await Dialog.options({
title,
message: 'Select the device to open:',
options: devices.map((d) => ({
value: d.serial,
label: d.displayTitle(),
})),
});
// might find nothing if id === false
return devices.find((d) => d.serial === selectedId);
}
async function selectClientDialog(
clients: Client[],
title: string,
): Promise<undefined | Client> {
const selectedId = await Dialog.options({
title,
message:
'Multiple applications running this plugin were found, please select one:',
options: clients.map((c) => ({
value: c.id,
label: `${c.query.app} on ${c.device.displayTitle()}`,
})),
});
// might find nothing if id === false
return clients.find((c) => c.id === selectedId);
}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
// Used responsibly.
import flipperServer from './flipperServer';
import application from './application';
import tracking from './tracking';
import notifications from './notifications';
import plugins from './plugins';
import user from './fb-stubs/user';
import pluginManager from './pluginManager';
import reactNative from './reactNative';
import pluginMarketplace from './fb-stubs/pluginMarketplace';
import pluginDownloads from './pluginDownloads';
import info from '../utils/info';
import pluginChangeListener from './pluginsChangeListener';
import {Logger} from 'flipper-common';
import {Store} from '../reducers/index';
import {Dispatcher} from './types';
import {notNull} from '../utils/typeUtils';
export default function (store: Store, logger: Logger): () => Promise<void> {
// This only runs in development as when the reload
// kicks in it doesn't unregister the shortcuts
const dispatchers: Array<Dispatcher> = [
application,
tracking,
flipperServer,
notifications,
plugins,
user,
pluginManager,
reactNative,
pluginMarketplace,
pluginDownloads,
info,
pluginChangeListener,
].filter(notNull);
const globalCleanup = dispatchers
.map((dispatcher) => dispatcher(store, logger))
.filter(Boolean);
return () => {
return Promise.all(globalCleanup).then(() => {});
};
}

View File

@@ -0,0 +1,162 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {PluginNotification} from '../reducers/notifications';
import reactElementToJSXString from 'react-element-to-jsx-string';
import {
updatePluginBlocklist,
updateCategoryBlocklist,
} from '../reducers/notifications';
import {textContent} from 'flipper-plugin';
import {getPluginTitle} from '../utils/pluginUtils';
import {sideEffect} from '../utils/sideEffect';
import {openNotification} from '../sandy-chrome/notification/Notification';
import {getRenderHostInstance} from '../RenderHost';
export type NotificationEvents =
| 'show'
| 'click'
| 'close'
| 'reply'
| 'action';
const NOTIFICATION_THROTTLE = 5 * 1000; // in milliseconds
export default (store: Store, logger: Logger) => {
const knownNotifications: Set<string> = new Set();
const lastNotificationTime: Map<string, number> = new Map();
getRenderHostInstance().onIpcEvent(
'notificationEvent',
(
eventName: NotificationEvents,
pluginNotification: PluginNotification,
arg: null | string | number,
) => {
if (eventName === 'click' || (eventName === 'action' && arg === 0)) {
openNotification(store, pluginNotification);
} else if (eventName === 'action') {
if (arg === 1 && pluginNotification.notification.category) {
// Hide similar (category)
logger.track(
'usage',
'notification-hide-category',
pluginNotification,
);
const {category} = pluginNotification.notification;
const {blocklistedCategories} = store.getState().notifications;
if (category && blocklistedCategories.indexOf(category) === -1) {
store.dispatch(
updateCategoryBlocklist([...blocklistedCategories, category]),
);
}
} else if (arg === 2) {
// Hide plugin
logger.track('usage', 'notification-hide-plugin', pluginNotification);
const {blocklistedPlugins} = store.getState().notifications;
if (blocklistedPlugins.indexOf(pluginNotification.pluginId) === -1) {
store.dispatch(
updatePluginBlocklist([
...blocklistedPlugins,
pluginNotification.pluginId,
]),
);
}
}
}
},
);
sideEffect(
store,
{name: 'notifications', throttleMs: 500},
({notifications, plugins}) => ({
notifications,
devicePlugins: plugins.devicePlugins,
clientPlugins: plugins.clientPlugins,
}),
({notifications, devicePlugins, clientPlugins}, store) => {
function getPlugin(name: string) {
return devicePlugins.get(name) ?? clientPlugins.get(name);
}
const {activeNotifications, blocklistedPlugins, blocklistedCategories} =
notifications;
activeNotifications
.map((n) => ({
...n,
notification: {
...n.notification,
message: textContent(n.notification.message),
},
}))
.forEach((n: PluginNotification) => {
if (
store.getState().connections.selectedPlugin !== 'notifications' &&
!knownNotifications.has(n.notification.id) &&
blocklistedPlugins.indexOf(n.pluginId) === -1 &&
(!n.notification.category ||
blocklistedCategories.indexOf(n.notification.category) === -1)
) {
const prevNotificationTime: number =
lastNotificationTime.get(n.pluginId) || 0;
lastNotificationTime.set(n.pluginId, new Date().getTime());
knownNotifications.add(n.notification.id);
if (
new Date().getTime() - prevNotificationTime <
NOTIFICATION_THROTTLE
) {
// Don't send a notification if the plugin has sent a notification
// within the NOTIFICATION_THROTTLE.
return;
}
const plugin = getPlugin(n.pluginId);
getRenderHostInstance().sendIpcEvent('sendNotification', {
payload: {
title: n.notification.title,
body: reactElementToJSXString(n.notification.message),
actions: [
{
type: 'button',
text: 'Show',
},
{
type: 'button',
text: 'Hide similar',
},
{
type: 'button',
text: `Hide all ${
plugin != null ? getPluginTitle(plugin) : ''
}`,
},
],
closeButtonText: 'Hide',
},
closeAfter: 10000,
pluginNotification: n,
});
logger.track('usage', 'native-notification', {
...n.notification,
message:
typeof n.notification.message === 'string'
? n.notification.message
: '<ReactNode>',
});
}
});
},
);
};

View File

@@ -0,0 +1,175 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
DownloadablePluginDetails,
getInstalledPluginDetails,
getPluginVersionInstallationDir,
InstalledPluginDetails,
installPluginFromFile,
} from 'flipper-plugin-lib';
import {State, Store} from '../reducers/index';
import {
PluginDownloadStatus,
pluginDownloadStarted,
pluginDownloadFinished,
} from '../reducers/pluginDownloads';
import {sideEffect} from '../utils/sideEffect';
import {default as axios} from 'axios';
import fs from 'fs-extra';
import path from 'path';
import tmp from 'tmp';
import {promisify} from 'util';
import {reportPlatformFailures, reportUsage} from 'flipper-common';
import {loadPlugin} from '../reducers/pluginManager';
import {showErrorNotification} from '../utils/notifications';
import {pluginInstalled} from '../reducers/plugins';
import {getAllClients} from '../reducers/connections';
// Adapter which forces node.js implementation for axios instead of browser implementation
// used by default in Electron. Node.js implementation is better, because it
// supports streams which can be used for direct downloading to disk.
const axiosHttpAdapter = require('axios/lib/adapters/http'); // eslint-disable-line import/no-commonjs
const getTempDirName = promisify(tmp.dir) as (
options?: tmp.DirOptions,
) => Promise<string>;
export default (store: Store) => {
sideEffect(
store,
{name: 'handlePluginDownloads', throttleMs: 1000, fireImmediately: true},
(state) => state.pluginDownloads,
(state, store) => {
for (const download of Object.values(state)) {
if (download.status === PluginDownloadStatus.QUEUED) {
reportUsage(
'plugin-auto-update:download',
{
version: download.plugin.version,
startedByUser: download.startedByUser ? '1' : '0',
},
download.plugin.id,
);
reportPlatformFailures(
handlePluginDownload(
download.plugin,
download.startedByUser,
store,
),
'plugin-auto-update:download',
).catch(() => {});
}
}
},
);
return async () => {};
};
async function handlePluginDownload(
plugin: DownloadablePluginDetails,
startedByUser: boolean,
store: Store,
) {
const dispatch = store.dispatch;
const {name, title, version, downloadUrl} = plugin;
const installationDir = getPluginVersionInstallationDir(name, version);
console.log(
`Downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
);
const tmpDir = await getTempDirName();
const tmpFile = path.join(tmpDir, `${name}-${version}.tgz`);
let installedPlugin: InstalledPluginDetails | undefined;
try {
const cancelationSource = axios.CancelToken.source();
dispatch(pluginDownloadStarted({plugin, cancel: cancelationSource.cancel}));
if (await fs.pathExists(installationDir)) {
console.log(
`Using existing files instead of downloading plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}"`,
);
installedPlugin = await getInstalledPluginDetails(installationDir);
} else {
await fs.ensureDir(tmpDir);
let percentCompleted = 0;
const response = await axios.get(plugin.downloadUrl, {
adapter: axiosHttpAdapter,
cancelToken: cancelationSource.token,
responseType: 'stream',
headers: {
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'navigate',
},
onDownloadProgress: async (progressEvent) => {
const newPercentCompleted = !progressEvent.total
? 0
: Math.round((progressEvent.loaded * 100) / progressEvent.total);
if (newPercentCompleted - percentCompleted >= 20) {
percentCompleted = newPercentCompleted;
console.log(
`Downloading plugin "${title}" v${version} from "${downloadUrl}": ${percentCompleted}% completed (${progressEvent.loaded} from ${progressEvent.total})`,
);
}
},
});
if (response.headers['content-type'] !== 'application/octet-stream') {
throw new Error(
`It looks like you are not on VPN/Lighthouse. Unexpected content type received: ${response.headers['content-type']}.`,
);
}
const responseStream = response.data as fs.ReadStream;
const writeStream = responseStream.pipe(
fs.createWriteStream(tmpFile, {autoClose: true}),
);
await new Promise((resolve, reject) =>
writeStream.once('finish', resolve).once('error', reject),
);
installedPlugin = await installPluginFromFile(tmpFile);
dispatch(pluginInstalled(installedPlugin));
}
if (pluginIsDisabledForAllConnectedClients(store.getState(), plugin)) {
dispatch(
loadPlugin({
plugin: installedPlugin,
enable: startedByUser,
notifyIfFailed: startedByUser,
}),
);
}
console.log(
`Successfully downloaded and installed plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
);
} catch (error) {
console.error(
`Failed to download plugin "${title}" v${version} from "${downloadUrl}" to "${installationDir}".`,
error,
);
if (startedByUser) {
showErrorNotification(
`Failed to download plugin "${title}" v${version}.`,
'Please check that you are on VPN/Lighthouse.',
);
}
throw error;
} finally {
dispatch(pluginDownloadFinished({plugin}));
await fs.remove(tmpDir);
}
}
function pluginIsDisabledForAllConnectedClients(
state: State,
plugin: DownloadablePluginDetails,
) {
return (
!state.plugins.clientPlugins.has(plugin.id) ||
!getAllClients(state.connections).some((c) =>
state.connections.enabledPlugins[c.query.app]?.includes(plugin.id),
)
);
}

View File

@@ -0,0 +1,343 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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 {Store} from '../reducers/index';
import type {Logger} from 'flipper-common';
import {
LoadPluginActionPayload,
UninstallPluginActionPayload,
UpdatePluginActionPayload,
pluginCommandsProcessed,
SwitchPluginActionPayload,
PluginCommand,
} from '../reducers/pluginManager';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
removePlugins,
ActivatablePluginDetails,
} from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import {requirePlugin} from './plugins';
import {showErrorNotification} from '../utils/notifications';
import {PluginDefinition} from '../plugin';
import type Client from '../Client';
import {unloadModule} from '../utils/electronModuleCache';
import {
pluginLoaded,
pluginUninstalled,
registerInstalledPlugins,
} from '../reducers/plugins';
import {_SandyPluginDefinition} from 'flipper-plugin';
import {
setDevicePluginEnabled,
setDevicePluginDisabled,
setPluginEnabled,
setPluginDisabled,
getClientsByAppName,
getAllClients,
} from '../reducers/connections';
import {deconstructClientId} from 'flipper-common';
import {clearMessageQueue} from '../reducers/pluginMessageQueue';
import {
isDevicePluginDefinition,
defaultEnabledBackgroundPlugins,
} from '../utils/pluginUtils';
import {getPluginKey} from '../utils/pluginKey';
const maxInstalledPluginVersionsToKeep = 2;
async function refreshInstalledPlugins(store: Store) {
await removePlugins(store.getState().plugins.uninstalledPluginNames.values());
await cleanupOldInstalledPluginVersions(maxInstalledPluginVersionsToKeep);
const plugins = await getInstalledPlugins();
return store.dispatch(registerInstalledPlugins(plugins));
}
export default (
store: Store,
_logger: Logger,
{runSideEffectsSynchronously}: {runSideEffectsSynchronously: boolean} = {
runSideEffectsSynchronously: false,
},
) => {
// This needn't happen immediately and is (light) I/O work.
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
refreshInstalledPlugins(store).catch((err) =>
console.error('Failed to refresh installed plugins:', err),
);
});
}
const unsubscribeHandlePluginCommands = sideEffect(
store,
{
name: 'handlePluginCommands',
throttleMs: 0,
fireImmediately: true,
runSynchronously: runSideEffectsSynchronously, // Used to simplify writing tests, if "true" passed, the all side effects will be called synchronously and immediately after changes
noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns
},
(state) => state.pluginManager.pluginCommandsQueue,
processPluginCommandsQueue,
);
return async () => {
unsubscribeHandlePluginCommands();
};
};
export function processPluginCommandsQueue(
queue: PluginCommand[],
store: Store,
) {
for (const command of queue) {
try {
switch (command.type) {
case 'LOAD_PLUGIN':
loadPlugin(store, command.payload);
break;
case 'UNINSTALL_PLUGIN':
uninstallPlugin(store, command.payload);
break;
case 'UPDATE_PLUGIN':
updatePlugin(store, command.payload);
break;
case 'SWITCH_PLUGIN':
switchPlugin(store, command.payload);
break;
default:
console.error('Unexpected plugin command', command);
break;
}
} catch (e) {
// make sure that upon failure the command is still marked processed to avoid
// unending loops!
console.error('Failed to process command', command);
}
}
store.dispatch(pluginCommandsProcessed(queue.length));
}
function loadPlugin(store: Store, payload: LoadPluginActionPayload) {
try {
const plugin = requirePlugin(payload.plugin);
const enablePlugin = payload.enable;
updatePlugin(store, {plugin, enablePlugin});
} catch (err) {
console.error(
`Failed to load plugin ${payload.plugin.title} v${payload.plugin.version}`,
err,
);
if (payload.notifyIfFailed) {
showErrorNotification(
`Failed to load plugin "${payload.plugin.title}" v${payload.plugin.version}`,
);
}
}
}
function uninstallPlugin(store: Store, {plugin}: UninstallPluginActionPayload) {
try {
const state = store.getState();
const clients = state.connections.clients;
clients.forEach((client) => {
stopPlugin(client, plugin.id);
});
if (!plugin.details.isBundled) {
unloadPluginModule(plugin.details);
}
store.dispatch(pluginUninstalled(plugin.details));
} catch (err) {
console.error(
`Failed to uninstall plugin ${plugin.title} v${plugin.version}`,
err,
);
showErrorNotification(
`Failed to uninstall plugin "${plugin.title}" v${plugin.version}`,
);
}
}
function updatePlugin(store: Store, payload: UpdatePluginActionPayload) {
const {plugin, enablePlugin} = payload;
if (isDevicePluginDefinition(plugin)) {
return updateDevicePlugin(store, plugin, enablePlugin);
} else {
return updateClientPlugin(store, plugin, enablePlugin);
}
}
function getSelectedAppName(store: Store) {
const {connections} = store.getState();
const selectedAppId = connections.selectedAppId
? deconstructClientId(connections.selectedAppId).app
: undefined;
return selectedAppId;
}
function switchPlugin(
store: Store,
{plugin, selectedApp}: SwitchPluginActionPayload,
) {
if (isDevicePluginDefinition(plugin)) {
switchDevicePlugin(store, plugin);
} else {
switchClientPlugin(store, plugin, selectedApp);
}
}
function switchClientPlugin(
store: Store,
plugin: PluginDefinition,
selectedApp: string | undefined,
) {
selectedApp = selectedApp ?? getSelectedAppName(store);
if (!selectedApp) {
return;
}
const {connections} = store.getState();
const clients = getClientsByAppName(connections.clients, selectedApp);
if (connections.enabledPlugins[selectedApp]?.includes(plugin.id)) {
clients.forEach((client) => {
stopPlugin(client, plugin.id);
const pluginKey = getPluginKey(
client.id,
{serial: client.query.device_id},
plugin.id,
);
store.dispatch(clearMessageQueue(pluginKey));
});
store.dispatch(setPluginDisabled(plugin.id, selectedApp));
} else {
clients.forEach((client) => {
startPlugin(client, plugin);
});
store.dispatch(setPluginEnabled(plugin.id, selectedApp));
}
}
function switchDevicePlugin(store: Store, plugin: PluginDefinition) {
const {connections} = store.getState();
const devicesWithPlugin = connections.devices.filter((d) =>
d.supportsPlugin(plugin.details),
);
if (connections.enabledDevicePlugins.has(plugin.id)) {
devicesWithPlugin.forEach((d) => {
d.unloadDevicePlugin(plugin.id);
});
store.dispatch(setDevicePluginDisabled(plugin.id));
} else {
devicesWithPlugin.forEach((d) => {
d.loadDevicePlugin(plugin);
});
store.dispatch(setDevicePluginEnabled(plugin.id));
}
}
function updateClientPlugin(
store: Store,
plugin: PluginDefinition,
enable: boolean,
) {
const clients = getAllClients(store.getState().connections);
if (enable) {
const selectedApp = getSelectedAppName(store);
if (selectedApp) {
store.dispatch(setPluginEnabled(plugin.id, selectedApp));
}
}
const clientsWithEnabledPlugin = clients.filter((c) => {
return (
c.supportsPlugin(plugin.id) &&
store
.getState()
.connections.enabledPlugins[c.query.app]?.includes(plugin.id)
);
});
const previousVersion = store.getState().plugins.clientPlugins.get(plugin.id);
clientsWithEnabledPlugin.forEach((client) => {
stopPlugin(client, plugin.id);
});
clientsWithEnabledPlugin.forEach((client) => {
startPlugin(client, plugin, true);
});
store.dispatch(pluginLoaded(plugin));
if (previousVersion) {
// unload previous version from Electron cache
unloadPluginModule(previousVersion.details);
}
}
function updateDevicePlugin(
store: Store,
plugin: PluginDefinition,
enable: boolean,
) {
if (enable) {
store.dispatch(setDevicePluginEnabled(plugin.id));
}
const connections = store.getState().connections;
const devicesWithEnabledPlugin = connections.devices.filter((d) =>
d.supportsPlugin(plugin),
);
devicesWithEnabledPlugin.forEach((d) => {
d.unloadDevicePlugin(plugin.id);
});
const previousVersion = store.getState().plugins.devicePlugins.get(plugin.id);
if (previousVersion) {
// unload previous version from Electron cache
unloadPluginModule(previousVersion.details);
}
store.dispatch(pluginLoaded(plugin));
devicesWithEnabledPlugin.forEach((d) => {
d.loadDevicePlugin(plugin);
});
}
function startPlugin(
client: Client,
plugin: PluginDefinition,
forceInitBackgroundPlugin: boolean = false,
) {
client.startPluginIfNeeded(plugin, true);
// background plugin? connect it needed
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(plugin.id)) &&
client?.isBackgroundPlugin(plugin.id)
) {
client.initPlugin(plugin.id);
}
}
function stopPlugin(
client: Client,
pluginId: string,
forceInitBackgroundPlugin: boolean = false,
): boolean {
if (
(forceInitBackgroundPlugin ||
!defaultEnabledBackgroundPlugins.includes(pluginId)) &&
client?.isBackgroundPlugin(pluginId)
) {
client.deinitPlugin(pluginId);
}
// stop sandy plugins
client.stopPluginIfNeeded(pluginId);
return true;
}
function unloadPluginModule(plugin: ActivatablePluginDetails) {
if (plugin.isBundled) {
// We cannot unload bundled plugin.
return;
}
unloadModule(plugin.entry);
}

View File

@@ -0,0 +1,360 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* 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 {Store} from '../reducers/index';
import type {Logger} from 'flipper-common';
import {PluginDefinition} from '../plugin';
import React from 'react';
import ReactDOM from 'react-dom';
import adbkit from 'adbkit';
import {
registerPlugins,
addGatekeepedPlugins,
addDisabledPlugins,
addFailedPlugins,
registerLoadedPlugins,
registerBundledPlugins,
registerMarketplacePlugins,
MarketplacePluginDetails,
pluginsInitialized,
} from '../reducers/plugins';
import GK from '../fb-stubs/GK';
import {FlipperBasePlugin} from '../plugin';
import fs from 'fs-extra';
import path from 'path';
import {default as config} from '../utils/processConfig';
import {notNull} from '../utils/typeUtils';
import {
ActivatablePluginDetails,
BundledPluginDetails,
ConcretePluginDetails,
} from 'flipper-plugin-lib';
import {tryCatchReportPluginFailures, reportUsage} from 'flipper-common';
import * as FlipperPluginSDK from 'flipper-plugin';
import {_SandyPluginDefinition} from 'flipper-plugin';
import loadDynamicPlugins from '../utils/loadDynamicPlugins';
import * as Immer from 'immer';
import * as antd from 'antd';
import * as emotion_styled from '@emotion/styled';
import * as antdesign_icons from '@ant-design/icons';
// @ts-ignore
import * as crc32 from 'crc32';
import {isDevicePluginDefinition} from '../utils/pluginUtils';
import isPluginCompatible from '../utils/isPluginCompatible';
import isPluginVersionMoreRecent from '../utils/isPluginVersionMoreRecent';
import {getStaticPath} from '../utils/pathUtils';
import {createSandyPluginWrapper} from '../utils/createSandyPluginWrapper';
import {getRenderHostInstance} from '../RenderHost';
let defaultPluginsIndex: any = null;
export default async (store: Store, _logger: Logger) => {
// expose Flipper and exact globally for dynamically loaded plugins
const globalObject: any = typeof window === 'undefined' ? global : window;
// this list should match `replace-flipper-requires.tsx` and the `builtInModules` in `desktop/.eslintrc`
globalObject.React = React;
globalObject.ReactDOM = ReactDOM;
globalObject.Flipper = require('../deprecated-exports');
globalObject.adbkit = adbkit;
globalObject.FlipperPlugin = FlipperPluginSDK;
globalObject.Immer = Immer;
globalObject.antd = antd;
globalObject.emotion_styled = emotion_styled;
globalObject.antdesign_icons = antdesign_icons;
globalObject.crc32_hack_fix_me = crc32;
const gatekeepedPlugins: Array<ActivatablePluginDetails> = [];
const disabledPlugins: Array<ActivatablePluginDetails> = [];
const failedPlugins: Array<[ActivatablePluginDetails, string]> = [];
defaultPluginsIndex = getRenderHostInstance().loadDefaultPlugins();
const marketplacePlugins = selectCompatibleMarketplaceVersions(
store.getState().plugins.marketplacePlugins,
);
store.dispatch(registerMarketplacePlugins(marketplacePlugins));
const uninstalledPluginNames =
store.getState().plugins.uninstalledPluginNames;
const bundledPlugins = await getBundledPlugins();
const allLocalVersions = [
...bundledPlugins,
...(await getDynamicPlugins()),
].filter((p) => !uninstalledPluginNames.has(p.name));
const loadedPlugins =
getLatestCompatibleVersionOfEachPlugin(allLocalVersions);
const initialPlugins: PluginDefinition[] = loadedPlugins
.map(reportVersion)
.filter(checkDisabled(disabledPlugins))
.filter(checkGK(gatekeepedPlugins))
.map(createRequirePluginFunction(failedPlugins))
.filter(notNull);
const classicPlugins = initialPlugins.filter(
(p) => !isSandyPlugin(p.details),
);
if (process.env.NODE_ENV !== 'test' && classicPlugins.length) {
console.warn(
`${
classicPlugins.length
} plugin(s) were loaded in legacy mode. Please visit https://fbflipper.com/docs/extending/sandy-migration to learn how to migrate these plugins to the new Sandy architecture: \n${classicPlugins
.map((p) => `${p.title} (id: ${p.id})`)
.sort()
.join('\n')}`,
);
}
store.dispatch(registerBundledPlugins(bundledPlugins));
store.dispatch(registerLoadedPlugins(loadedPlugins));
store.dispatch(addGatekeepedPlugins(gatekeepedPlugins));
store.dispatch(addDisabledPlugins(disabledPlugins));
store.dispatch(addFailedPlugins(failedPlugins));
store.dispatch(registerPlugins(initialPlugins));
store.dispatch(pluginsInitialized());
};
function reportVersion(pluginDetails: ActivatablePluginDetails) {
reportUsage(
'plugin:version',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return pluginDetails;
}
export function getLatestCompatibleVersionOfEachPlugin<
T extends ConcretePluginDetails,
>(plugins: T[]): T[] {
const latestCompatibleVersions: Map<string, T> = new Map();
for (const plugin of plugins) {
if (isPluginCompatible(plugin)) {
const loadedVersion = latestCompatibleVersions.get(plugin.id);
if (!loadedVersion || isPluginVersionMoreRecent(plugin, loadedVersion)) {
latestCompatibleVersions.set(plugin.id, plugin);
}
}
}
return Array.from(latestCompatibleVersions.values());
}
async function getBundledPlugins(): Promise<Array<BundledPluginDetails>> {
// defaultPlugins that are included in the Flipper distributive.
// List of default bundled plugins is written at build time to defaultPlugins/bundled.json.
const pluginPath = getStaticPath(
path.join('defaultPlugins', 'bundled.json'),
{asarUnpacked: true},
);
let bundledPlugins: Array<BundledPluginDetails> = [];
try {
bundledPlugins = await fs.readJson(pluginPath);
} catch (e) {
console.error('Failed to load list of bundled plugins', e);
}
return bundledPlugins;
}
export async function getDynamicPlugins() {
try {
return await loadDynamicPlugins();
} catch (e) {
console.error('Failed to load dynamic plugins', e);
return [];
}
}
export const checkGK =
(gatekeepedPlugins: Array<ActivatablePluginDetails>) =>
(plugin: ActivatablePluginDetails): boolean => {
try {
if (!plugin.gatekeeper) {
return true;
}
const result = GK.get(plugin.gatekeeper);
if (!result) {
gatekeepedPlugins.push(plugin);
}
return result;
} catch (err) {
console.error(`Failed to check GK for plugin ${plugin.id}`, err);
return false;
}
};
export const checkDisabled = (
disabledPlugins: Array<ActivatablePluginDetails>,
) => {
let enabledList: Set<string> | null = null;
let disabledList: Set<string> = new Set();
try {
if (process.env.FLIPPER_ENABLED_PLUGINS) {
enabledList = new Set<string>(
process.env.FLIPPER_ENABLED_PLUGINS.split(','),
);
}
disabledList = config().disabledPlugins;
} catch (e) {
console.error('Failed to compute enabled/disabled plugins', e);
}
return (plugin: ActivatablePluginDetails): boolean => {
try {
if (disabledList.has(plugin.name)) {
disabledPlugins.push(plugin);
return false;
}
if (
enabledList &&
!(
enabledList.has(plugin.name) ||
enabledList.has(plugin.id) ||
enabledList.has(plugin.name.replace('flipper-plugin-', ''))
)
) {
disabledPlugins.push(plugin);
return false;
}
return true;
} catch (e) {
console.error(
`Failed to check whether plugin ${plugin.id} is disabled`,
e,
);
return false;
}
};
};
export const createRequirePluginFunction = (
failedPlugins: Array<[ActivatablePluginDetails, string]>,
reqFn: Function = global.electronRequire,
) => {
return (pluginDetails: ActivatablePluginDetails): PluginDefinition | null => {
try {
const pluginDefinition = requirePlugin(pluginDetails, reqFn);
if (
pluginDefinition &&
isDevicePluginDefinition(pluginDefinition) &&
pluginDefinition.details.pluginType !== 'device'
) {
console.warn(
`Package ${pluginDefinition.details.name} contains the device plugin "${pluginDefinition.title}" defined in a wrong format. Specify "pluginType" and "supportedDevices" properties and remove exported function "supportsDevice". See details at https://fbflipper.com/docs/extending/desktop-plugin-structure#creating-a-device-plugin.`,
);
}
return pluginDefinition;
} catch (e) {
failedPlugins.push([pluginDetails, e.message]);
console.error(`Plugin ${pluginDetails.id} failed to load`, e);
return null;
}
};
};
export const requirePlugin = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
reportUsage(
'plugin:load',
{
version: pluginDetails.version,
},
pluginDetails.id,
);
return tryCatchReportPluginFailures(
() => requirePluginInternal(pluginDetails, reqFn),
'plugin:load',
pluginDetails.id,
);
};
const isSandyPlugin = (pluginDetails: ActivatablePluginDetails) => {
return !!pluginDetails.flipperSDKVersion;
};
const requirePluginInternal = (
pluginDetails: ActivatablePluginDetails,
reqFn: Function = global.electronRequire,
): PluginDefinition => {
let plugin = pluginDetails.isBundled
? defaultPluginsIndex[pluginDetails.name]
: reqFn(pluginDetails.entry);
if (isSandyPlugin(pluginDetails)) {
// Sandy plugin
return new _SandyPluginDefinition(pluginDetails, plugin);
} else {
// classic plugin
if (plugin.default) {
plugin = plugin.default;
}
if (plugin.prototype === undefined) {
throw new Error(
`Plugin ${pluginDetails.name} is neither a class-based plugin nor a Sandy-based one.
Ensure that it exports either a FlipperPlugin class or has flipper-plugin declared as a peer-dependency and exports a plugin and Component.
See https://fbflipper.com/docs/extending/sandy-migration/ for more information.`,
);
} else if (!(plugin.prototype instanceof FlipperBasePlugin)) {
throw new Error(
`Plugin ${pluginDetails.name} is not a FlipperBasePlugin`,
);
}
if (plugin.id && pluginDetails.id !== plugin.id) {
console.error(
`Plugin name mismatch: Package '${pluginDetails.id}' exposed a plugin with id '${plugin.id}'. Please update the 'package.json' to match the exposed plugin id`,
);
}
plugin.id = plugin.id || pluginDetails.id;
plugin.packageName = pluginDetails.name;
plugin.details = pluginDetails;
return createSandyPluginFromClassicPlugin(pluginDetails, plugin);
}
};
export function createSandyPluginFromClassicPlugin(
pluginDetails: ActivatablePluginDetails,
plugin: any,
) {
pluginDetails.id = plugin.id; // for backward compatibility, see above check!
return new _SandyPluginDefinition(
pluginDetails,
createSandyPluginWrapper(plugin),
);
}
export function selectCompatibleMarketplaceVersions(
availablePlugins: MarketplacePluginDetails[],
): MarketplacePluginDetails[] {
const plugins: MarketplacePluginDetails[] = [];
for (const plugin of availablePlugins) {
if (!isPluginCompatible(plugin)) {
const compatibleVersion =
plugin.availableVersions?.find(isPluginCompatible) ??
plugin.availableVersions?.slice(-1).pop();
if (compatibleVersion) {
plugins.push({
...compatibleVersion,
availableVersions: plugin?.availableVersions,
});
} else {
plugins.push(plugin);
}
} else {
plugins.push(plugin);
}
}
return plugins;
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import Client from '../Client';
import {Logger} from 'flipper-common';
import {Store} from '../reducers';
import {appPluginListChanged} from '../reducers/connections';
import {getActiveClient} from '../selectors/connections';
import {sideEffect} from '../utils/sideEffect';
export default (store: Store, _logger: Logger) => {
let prevClient: null | Client = null;
const onActiveAppPluginListChanged = () => {
store.dispatch(appPluginListChanged());
};
sideEffect(
store,
{name: 'pluginsChangeListener', throttleMs: 10, fireImmediately: true},
getActiveClient,
(activeClient, _store) => {
if (activeClient !== prevClient) {
if (prevClient) {
prevClient.off('plugins-change', onActiveAppPluginListChanged);
}
prevClient = activeClient;
if (prevClient) {
prevClient.on('plugins-change', onActiveAppPluginListChanged);
store.dispatch(appPluginListChanged()); // force refresh
}
}
},
);
};

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../reducers';
import {getRenderHostInstance} from '../RenderHost';
type ShortcutEventCommand =
| {
shortcut: string;
command: string;
}
| '';
export default (store: Store) => {
const settings = store.getState().settingsState.reactNative;
const renderHost = getRenderHostInstance();
if (!settings.shortcuts.enabled) {
return;
}
const shortcuts: ShortcutEventCommand[] = [
settings.shortcuts.reload && {
shortcut: settings.shortcuts.reload,
command: 'reload',
},
settings.shortcuts.openDevMenu && {
shortcut: settings.shortcuts.openDevMenu,
command: 'devMenu',
},
];
shortcuts.forEach(
(shortcut: ShortcutEventCommand) =>
shortcut &&
shortcut.shortcut &&
renderHost.registerShortcut(shortcut.shortcut, () => {
const devices = store
.getState()
.connections.devices.filter(
(device) => device.os === 'Metro' && !device.isArchived,
);
devices.forEach((device) =>
device.flipperServer.exec(
'metro-command',
device.serial,
shortcut.command,
),
);
}),
);
};

View File

@@ -0,0 +1,380 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {performance} from 'perf_hooks';
import {EventEmitter} from 'events';
import {State, Store} from '../reducers/index';
import {Logger} from 'flipper-common';
import {
getPluginBackgroundStats,
resetPluginBackgroundStatsDelta,
} from '../utils/pluginStats';
import {
clearTimeline,
TrackingEvent,
State as UsageTrackingState,
selectionChanged,
} from '../reducers/usageTracking';
import produce from 'immer';
import BaseDevice from '../devices/BaseDevice';
import {deconstructClientId} from 'flipper-common';
import {getCPUUsage} from 'process';
import {sideEffect} from '../utils/sideEffect';
import {getSelectionInfo} from '../utils/info';
import type {SelectionInfo} from '../utils/info';
import {getRenderHostInstance} from '../RenderHost';
const TIME_SPENT_EVENT = 'time-spent';
type UsageInterval = {
selectionKey: string | null;
selection: SelectionInfo | null;
length: number;
focused: boolean;
};
export type UsageSummary = {
total: {focusedTime: number; unfocusedTime: number};
plugin: {
[pluginKey: string]: {
focusedTime: number;
unfocusedTime: number;
} & SelectionInfo;
};
};
export const fpsEmitter = new EventEmitter();
// var is fine, let doesn't have the correct hoisting semantics
// eslint-disable-next-line no-var
var bytesReceivedEmitter: EventEmitter;
export function onBytesReceived(
callback: (plugin: string, bytes: number) => void,
): () => void {
if (!bytesReceivedEmitter) {
bytesReceivedEmitter = new EventEmitter();
}
bytesReceivedEmitter.on('bytesReceived', callback);
return () => {
bytesReceivedEmitter.off('bytesReceived', callback);
};
}
export function emitBytesReceived(plugin: string, bytes: number) {
if (bytesReceivedEmitter) {
bytesReceivedEmitter.emit('bytesReceived', plugin, bytes);
}
}
export default (store: Store, logger: Logger) => {
const renderHost = getRenderHostInstance();
sideEffect(
store,
{
name: 'pluginUsageTracking',
throttleMs: 0,
noTimeBudgetWarns: true,
runSynchronously: true,
},
getSelectionInfo,
(selection, store) => {
const time = Date.now();
store.dispatch(selectionChanged({selection, time}));
},
);
let droppedFrames: number = 0;
let largeFrameDrops: number = 0;
const oldExitData = loadExitData();
if (oldExitData) {
const isReload = renderHost.processId === oldExitData.pid;
const timeSinceLastStartup =
Date.now() - parseInt(oldExitData.lastSeen, 10);
// console.log(isReload ? 'reload' : 'restart', oldExitData);
logger.track('usage', isReload ? 'reload' : 'restart', {
...oldExitData,
pid: undefined,
timeSinceLastStartup,
});
// create fresh exit data
const {selectedDevice, selectedAppId, selectedPlugin} =
store.getState().connections;
persistExitData(
{
selectedDevice,
selectedAppId,
selectedPlugin,
},
false,
);
}
function droppedFrameDetection(
past: DOMHighResTimeStamp,
isWindowFocused: () => boolean,
) {
const now = performance.now();
requestAnimationFrame(() => droppedFrameDetection(now, isWindowFocused));
const delta = now - past;
const dropped = Math.round(delta / (1000 / 60) - 1);
fpsEmitter.emit('fps', delta > 1000 ? 0 : Math.round(1000 / (now - past)));
if (!isWindowFocused() || dropped < 1) {
return;
}
droppedFrames += dropped;
if (dropped > 3) {
largeFrameDrops++;
}
}
if (typeof window !== 'undefined') {
droppedFrameDetection(
performance.now(),
() => store.getState().application.windowIsFocused,
);
}
renderHost.onIpcEvent('trackUsage', (...args: any[]) => {
let state: State;
try {
state = store.getState();
} catch (e) {
// if trackUsage is called (indirectly) through a reducer, this will utterly die Flipper. Let's prevent that and log an error instead
console.error(
'trackUsage triggered indirectly as side effect of a reducer',
e,
);
return;
}
const {selectedDevice, selectedPlugin, selectedAppId, clients} =
state.connections;
persistExitData(
{selectedDevice, selectedPlugin, selectedAppId},
args[0] === 'exit',
);
const currentTime = Date.now();
const usageSummary = computeUsageSummary(state.usageTracking, currentTime);
store.dispatch(clearTimeline(currentTime));
logger.track('usage', TIME_SPENT_EVENT, usageSummary.total);
for (const key of Object.keys(usageSummary.plugin)) {
logger.track(
'usage',
TIME_SPENT_EVENT,
usageSummary.plugin[key],
usageSummary.plugin[key]?.plugin ?? 'none',
);
}
Object.entries(state.connections.enabledPlugins).forEach(
([app, plugins]) => {
// TODO: remove "starred-plugns" event in favor of "enabled-plugins" after some transition period
logger.track('usage', 'starred-plugins', {
app,
starredPlugins: plugins,
});
logger.track('usage', 'enabled-plugins', {
app,
enabledPugins: plugins,
});
},
);
const bgStats = getPluginBackgroundStats();
logger.track('usage', 'plugin-stats', {
cpuTime: bgStats.cpuTime,
bytesReceived: bgStats.bytesReceived,
});
for (const key of Object.keys(bgStats.byPlugin)) {
const {
cpuTimeTotal: _a,
messageCountTotal: _b,
bytesReceivedTotal: _c,
...dataWithoutTotal
} = bgStats.byPlugin[key];
if (Object.values(dataWithoutTotal).some((v) => v > 0)) {
logger.track('usage', 'plugin-stats-plugin', dataWithoutTotal, key);
}
}
resetPluginBackgroundStatsDelta();
if (
!state.application.windowIsFocused ||
!selectedDevice ||
!selectedPlugin
) {
return;
}
let app: string | null = null;
let sdkVersion: number | null = null;
if (selectedAppId) {
const client = clients.get(selectedAppId);
if (client) {
app = client.query.app;
sdkVersion = client.query.sdk_version || 0;
}
}
const info = {
droppedFrames,
largeFrameDrops,
os: selectedDevice.os,
device: selectedDevice.title,
plugin: selectedPlugin,
app,
sdkVersion,
isForeground: state.application.windowIsFocused,
usedJSHeapSize: (window.performance as any).memory.usedJSHeapSize,
cpuLoad: getCPUUsage().percentCPUUsage,
};
// reset dropped frames counter
droppedFrames = 0;
largeFrameDrops = 0;
logger.track('usage', 'ping', info);
});
};
export function computeUsageSummary(
state: UsageTrackingState,
currentTime: number,
) {
const intervals: UsageInterval[] = [];
let intervalStart = 0;
let isFocused = false;
let selection: SelectionInfo | null = null;
let selectionKey: string | null;
function startInterval(event: TrackingEvent) {
intervalStart = event.time;
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE'
) {
isFocused = event.isFocused;
}
if (event.type === 'SELECTION_CHANGED') {
selectionKey = event.selectionKey;
selection = event.selection;
}
}
function endInterval(time: number) {
const length = time - intervalStart;
intervals.push({
length,
focused: isFocused,
selectionKey,
selection,
});
}
for (const event of state.timeline) {
if (
event.type === 'TIMELINE_START' ||
event.type === 'WINDOW_FOCUS_CHANGE' ||
event.type === 'SELECTION_CHANGED'
) {
if (event.type !== 'TIMELINE_START') {
endInterval(event.time);
}
startInterval(event);
}
}
endInterval(currentTime);
return intervals.reduce<UsageSummary>(
(acc: UsageSummary, x: UsageInterval) =>
produce(acc, (draft) => {
draft.total.focusedTime += x.focused ? x.length : 0;
draft.total.unfocusedTime += x.focused ? 0 : x.length;
const selectionKey = x.selectionKey ?? 'none';
draft.plugin[selectionKey] = draft.plugin[selectionKey] ?? {
focusedTime: 0,
unfocusedTime: 0,
...x.selection,
};
draft.plugin[selectionKey].focusedTime += x.focused ? x.length : 0;
draft.plugin[selectionKey].unfocusedTime += x.focused ? 0 : x.length;
}),
{
total: {focusedTime: 0, unfocusedTime: 0},
plugin: {},
},
);
}
const flipperExitDataKey = 'FlipperExitData';
interface ExitData {
lastSeen: string;
deviceOs: string;
deviceType: string;
deviceTitle: string;
plugin: string;
app: string;
cleanExit: boolean;
pid: number;
}
function loadExitData(): ExitData | undefined {
if (!window.localStorage) {
return undefined;
}
const data = window.localStorage.getItem(flipperExitDataKey);
if (data) {
try {
const res = JSON.parse(data);
if (res.cleanExit === undefined) {
res.cleanExit = true; // avoid skewing results for historical data where this info isn't present
}
return res;
} catch (e) {
console.warn('Failed to parse flipperExitData', e);
}
}
return undefined;
}
export function persistExitData(
state: {
selectedDevice: BaseDevice | null;
selectedPlugin: string | null;
selectedAppId: string | null;
},
cleanExit: boolean,
) {
if (!window.localStorage) {
return;
}
const exitData: ExitData = {
lastSeen: '' + Date.now(),
deviceOs: state.selectedDevice ? state.selectedDevice.os : '',
deviceType: state.selectedDevice ? state.selectedDevice.deviceType : '',
deviceTitle: state.selectedDevice ? state.selectedDevice.title : '',
plugin: state.selectedPlugin || '',
app: state.selectedAppId
? deconstructClientId(state.selectedAppId).app
: '',
cleanExit,
pid: getRenderHostInstance().processId,
};
window.localStorage.setItem(
flipperExitDataKey,
JSON.stringify(exitData, null, 2),
);
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../reducers/index';
import {Logger} from 'flipper-common';
export type Dispatcher = (
store: Store,
logger: Logger,
) => (() => Promise<void>) | null | void;

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
/*
* This class exists to allow error reporting to your own service.
* The recommended way to use this, is to instantiate it inside Logger,
* so that all logged errors get reported to this class.
*/
export function cleanStack(_stack: string, _loc?: string) {}
import ScribeLogger from './ScribeLogger';
export default class ErrorReporter {
constructor(_scribeLogger: ScribeLogger) {}
report(_err: Error) {}
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type GKID = string;
export const TEST_PASSING_GK = 'TEST_PASSING_GK';
export const TEST_FAILING_GK = 'TEST_FAILING_GK';
export type GKMap = {[key: string]: boolean};
const whitelistedGKs: Array<GKID> = [];
export function loadGKs(_username: string, _gks: Array<GKID>): Promise<GKMap> {
return Promise.reject(
new Error('Implement your custom logic for loading GK'),
);
}
export function loadDistilleryGK(
_gk: GKID,
): Promise<{[key: string]: {result: boolean}}> {
return Promise.reject(
new Error('Implement your custom logic for loading GK'),
);
}
export default class GK {
static init() {}
static get(id: GKID): boolean {
if (process.env.NODE_ENV === 'test' && id === TEST_PASSING_GK) {
return true;
}
if (whitelistedGKs.includes(id)) {
return true;
}
return false;
}
static serializeGKs() {
return '';
}
static async withWhitelistedGK(
id: GKID,
callback: () => Promise<void> | void,
) {
whitelistedGKs.push(id);
try {
const p = callback();
if (p) {
await p;
}
} finally {
const idx = whitelistedGKs.indexOf(id);
if (idx !== -1) {
whitelistedGKs.splice(idx, 1);
}
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {ElementFramework} from '../ui/components/elements-inspector/ElementFramework';
import {ElementsInspectorElement} from 'flipper-plugin';
export enum IDEType {
'DIFFUSION',
'AS',
'XCODE',
'VSCODE',
}
export abstract class IDEFileResolver {
static async resolveFullPathsFromMyles(
_fileName: string,
_dirRoot: string,
): Promise<string[]> {
throw new Error('Method not implemented.');
}
static openInIDE(
_filePath: string,
_ide: IDEType,
_repo: string,
_lineNumber = 0,
) {
throw new Error('Method not implemented.');
}
static async getLithoComponentPath(_className: string): Promise<string> {
throw new Error('Method not implemented.');
}
static async getCKComponentPath(_className: string): Promise<string> {
throw new Error('Method not implemented.');
}
static getBestPath(
_paths: string[],
_className: string,
_extension?: string,
): string {
throw new Error('Method not implemented.');
}
static async resolvePath(
_className: string,
_framework: string,
): Promise<string> {
throw new Error('Method not implemented.');
}
static isElementFromFramework(
_node: ElementsInspectorElement,
_framework: ElementFramework,
): boolean {
throw new Error('Method not implemented.');
}
static isElementFromSupportedFramework(
_node: ElementsInspectorElement,
): boolean {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Tristate} from '../reducers/settings';
import ReleaseChannel from '../ReleaseChannel';
export default function (_props: {
isPrefetchingEnabled: Tristate;
onEnablePrefetchingChange: (v: Tristate) => void;
isLocalPinIgnored: boolean;
onIgnoreLocalPinChange: (v: boolean) => void;
releaseChannel: ReleaseChannel;
onReleaseChannelChange: (v: ReleaseChannel) => void;
}) {
return null;
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Logger, LoggerArgs, NoopLogger} from 'flipper-common';
import {Store} from '../reducers/index';
export function init(_store: Store, _args?: LoggerArgs): Logger {
return new NoopLogger();
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Settings} from '../reducers/settings';
export default async function setupPrefetcher(_settings: Settings) {}
export const shouldInstallPrefetcher = () => false;

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type ScribeMessage = {
category: string;
message: string;
};
export default class ScribeLogger {
constructor() {}
send(_message: ScribeMessage) {}
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component} from 'react';
import {StaticViewProps} from '../reducers/connections';
import {Text} from '../ui';
export default class extends Component<StaticViewProps, {}> {
render() {
return <Text>Build your support request deteails form.</Text>;
}
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import React, {Component} from 'react';
import {StaticViewProps} from '../reducers/connections';
import {Text} from '../ui';
export default class extends Component<StaticViewProps, {}> {
render() {
return <Text>Build your support request creation form.</Text>;
}
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export type FeedbackPrompt = {
preSubmitHeading: string;
postSubmitHeading: string;
commentPlaceholder: string;
bodyText: string;
predefinedComments: Array<string>;
shouldPopup: boolean;
};
export async function submitRating(
_rating: number,
_sessionId: string | null,
): Promise<void> {
throw new Error('Method not implemented.');
}
export async function submitComment(
_rating: number,
_comment: string,
_selectedPredefinedComments: string[],
_allowUserInfoSharing: boolean,
_sessionId: string | null,
): Promise<void> {
throw new Error('Method not implemented.');
}
export async function dismiss(_sessionId: string | null): Promise<void> {
throw new Error('Method not implemented.');
}
export async function getPrompt(): Promise<FeedbackPrompt> {
throw new Error('Method not implemented.');
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Store} from '../../reducers/index';
import {getErrorFromErrorLike, getStringFromErrorLike} from 'flipper-common';
import {LoggerArgs, Logger} from 'flipper-common';
const instance = {
track: jest.fn(),
trackTimeSince: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
export function extractError(...data: Array<any>): {
message: string;
error: Error;
} {
const message = getStringFromErrorLike(data);
const error = getErrorFromErrorLike(data) ?? new Error(message);
return {
message,
error,
};
}
export function init(_store: Store, _args?: LoggerArgs): Logger {
return instance;
}
export function getInstance(): Logger {
return instance;
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import os from 'os';
import {VersionCheckResult} from '../chrome/UpdateIndicator';
const updateServer = 'https://www.facebook.com/fbflipper/public/latest.json';
const getPlatformSpecifier = (): string => {
switch (os.platform()) {
case 'win32':
return 'windows';
case 'linux':
return 'linux';
case 'darwin':
return 'mac';
default:
throw new Error('Unsupported platform.');
}
};
/**
* @param resp A parsed JSON object retrieved from the update server.
*/
const parseResponse = (resp: any): VersionCheckResult => {
const version = resp.version;
const platforms = resp.platforms;
if (!version || !platforms) {
return {kind: 'error', msg: 'Incomplete response.'};
}
const platformSpecifier = getPlatformSpecifier();
const platform = platforms[platformSpecifier];
if (!platform) {
return {kind: 'error', msg: `Unsupported platform: ${platformSpecifier}.`};
}
return {
kind: 'update-available',
url: platform,
version,
};
};
export async function checkForUpdate(
currentVersion: string,
): Promise<VersionCheckResult> {
return fetch(`${updateServer}?version=${currentVersion}`).then(
(res: Response) => {
switch (res.status) {
case 204:
return {kind: 'up-to-date'};
case 200:
if (res.url.startsWith('https://www.facebook.com/login/')) {
// We're being redirected because we're not on an authenticated network.
// Treat that as being up-to-date as there's special-casing the UI for
// this is not worth it.
console.log('Skipping version check on non-authenticated network.');
return {kind: 'up-to-date'};
}
// Good use of nesting.
// eslint-disable-next-line promise/no-nesting
return res.json().then(parseResponse);
default:
const msg = `Server responded with ${res.statusText}.`;
console.warn('Version check failure: ', msg);
return {
kind: 'error',
msg,
};
}
},
);
}

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import ReleaseChannel from '../ReleaseChannel';
export default {
updateServer: 'https://www.facebook.com/fbflipper/public/latest.json',
showLogin: false,
showFlipperRating: false,
warnFBEmployees: true,
isFBBuild: false,
getReleaseChannel: () => ReleaseChannel.STABLE,
};

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {DeviceOS} from 'flipper-plugin';
export default Object.freeze({
GRAPH_APP_ID: '',
GRAPH_CLIENT_TOKEN: '',
GRAPH_ACCESS_TOKEN: '',
// this provides elevated access to scribe. we really shouldn't be exposing this.
// need to investigate how to abstract the scribe logging so it's safe.
GRAPH_SECRET: '',
GRAPH_SECRET_ACCESS_TOKEN: '',
// Provides access to Insights Validation endpoint on interngraph
INSIGHT_INTERN_APP_ID: '',
INSIGHT_INTERN_APP_TOKEN: '',
// Enables the flipper data to be exported through shareabale link
ENABLE_SHAREABLE_LINK: false,
IS_PUBLIC_BUILD: true,
FEEDBACK_GROUP_LINK: 'https://github.com/facebook/flipper/issues',
// Workplace Group ID's
DEFAULT_SUPPORT_GROUP: {
name: 'Default Support Group',
workplaceGroupID: 0,
requiredPlugins: ['Inspector'],
defaultPlugins: ['DeviceLogs'],
supportedOS: ['Android'] as Array<DeviceOS>,
deeplinkSuffix: 'default',
papercuts: '',
},
SUPPORT_GROUPS: [],
// Only WebSocket requests from the following origin prefixes will be accepted
VALID_WEB_SOCKET_REQUEST_ORIGIN_PREFIXES: [
'chrome-extension://',
'localhost:',
'http://localhost:',
'app://',
],
});

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
export default function createPaste(
_input: string,
): Promise<string | undefined> {
return Promise.reject(new Error('Not implemented!'));
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Atom, createState} from 'flipper-plugin';
import {User} from '../reducers/user';
export async function getUser(): Promise<User | null> {
throw new Error('Feature not implemented');
}
export async function internGraphPOSTAPIRequest(
_endpoint: string,
_formFields: {
[key: string]: any;
} = {},
_internGraphUrl?: string,
): Promise<any> {
throw new Error('Feature not implemented');
}
export async function internGraphGETAPIRequest(
_endpoint: string,
_params: {
[key: string]: any;
} = {},
_internGraphUrl?: string,
): Promise<any> {
throw new Error('Feature not implemented');
}
export async function graphQLQuery(_query: string): Promise<any> {
throw new Error('Feature not implemented');
}
export function logoutUser(_persist: boolean = false): Promise<void> {
throw new Error('Feature not implemented');
}
export type DataExportResult = {
id: string;
os: 'string';
deviceType: string;
plugins: string[];
fileUrl: string;
flipperUrl: string;
};
export type DataExportError = {
error: string;
error_class: string;
stacktrace: string;
};
export async function shareFlipperData(
_trace: string,
): Promise<DataExportError | DataExportResult> {
new Notification('Feature not implemented');
throw new Error('Feature not implemented');
}
export async function writeKeychain(_token: string) {
throw new Error('Feature not implemented');
}
export async function uploadFlipperMedia(
_path: string,
_kind: 'Image' | 'Video',
): Promise<string> {
throw new Error('Feature not implemented');
}
export async function getFlipperMediaCDN(
_uploadID: string,
_kind: 'Image' | 'Video',
): Promise<string> {
throw new Error('Feature not implemented');
}
export async function getPreferredEditorUriScheme(): Promise<string> {
return 'vscode';
}
export async function appendAccessTokenToUrl(_url: URL): Promise<string> {
throw new Error('Implement appendAccessTokenToUrl');
}
const isLoggedInAtom = createState(false);
const isConnectedAtom = createState(true);
export function isLoggedIn(): Atom<boolean> {
return isLoggedInAtom;
}
export function isConnected(): Atom<boolean> {
return isConnectedAtom;
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {StoreEnhancerStoreCreator} from 'redux';
import {Store} from './reducers';
import {RenderHost} from './RenderHost';
declare global {
interface StoreEnhancerStateSanitizer {
stateSanitizer: Function;
}
interface Window {
flipperGlobalStoreDispatch: Store['dispatch'];
__REDUX_DEVTOOLS_EXTENSION__:
| undefined
| (StoreEnhancerStoreCreator & StoreEnhancerStateSanitizer);
Flipper: {
init: () => void;
};
FlipperRenderHostInstance: RenderHost;
}
}

View File

@@ -7,6 +7,9 @@
* @format
*/
export function helloWorld() {
return true;
}
// TODO: should not be exported anymore, but still needed for 'import from 'flipper'' stuff
export * from './deprecated-exports';
export {RenderHost, getRenderHostInstance} from './RenderHost';
export {startFlipperDesktop} from './startFlipperDesktop';

View File

@@ -0,0 +1,314 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Logger} from 'flipper-common';
import Client from './Client';
import {Component} from 'react';
import BaseDevice from './devices/BaseDevice';
import {StaticView} from './reducers/connections';
import {State as ReduxState} from './reducers';
import {DEFAULT_MAX_QUEUE_SIZE} from './reducers/pluginMessageQueue';
import {ActivatablePluginDetails} from 'flipper-plugin-lib';
import {Settings} from './reducers/settings';
import {
Notification,
Idler,
_SandyPluginDefinition,
_makeShallowSerializable,
_deserializeShallowObject,
_buildInMenuEntries,
} from 'flipper-plugin';
export type DefaultKeyboardAction = keyof typeof _buildInMenuEntries;
export type KeyboardAction = {
action: string;
label: string;
accelerator?: string;
};
export type KeyboardActions = Array<DefaultKeyboardAction | KeyboardAction>;
type Parameters = {[key: string]: any};
export type PluginDefinition = _SandyPluginDefinition;
export type ClientPluginMap = Map<string, PluginDefinition>;
export type DevicePluginMap = Map<string, PluginDefinition>;
// This function is intended to be called from outside of the plugin.
// If you want to `call` from the plugin use, this.client.call
export function callClient(
client: Client,
id: string,
): (method: string, params: Parameters) => Promise<any> {
return (method, params) => client.call(id, method, false, params);
}
// This function is intended to be called from outside of the plugin.
// If you want to `supportsMethod` from the plugin use, this.client.supportsMethod
export function supportsMethod(
client: Client,
id: string,
): (method: string) => Promise<boolean> {
return (method) => client.supportsMethod(id, method);
}
export interface PluginClient {
isConnected: boolean;
// eslint-disable-next-line
send(method: string, params?: Parameters): void;
// eslint-disable-next-line
call(method: string, params?: Parameters): Promise<any>;
// eslint-disable-next-line
subscribe(method: string, callback: (params: any) => void): void;
// eslint-disable-next-line
supportsMethod(method: string): Promise<boolean>;
}
type PluginTarget = BaseDevice | Client;
export type Props<T> = {
logger: Logger;
persistedState: T;
setPersistedState: (state: Partial<T>) => void;
target: PluginTarget;
deepLinkPayload: unknown;
selectPlugin: (pluginID: string, deepLinkPayload: unknown) => void;
isArchivedDevice: boolean;
selectedApp: string | null; // name
setStaticView: (payload: StaticView) => void;
settingsState: Settings;
};
export type BaseAction = {
type: string;
};
export type PersistedStateReducer = (
persistedState: StaticPersistedState,
method: string,
data: any,
) => StaticPersistedState;
type StaticPersistedState = any;
export abstract class FlipperBasePlugin<
State,
Actions extends BaseAction,
PersistedState,
> extends Component<Props<PersistedState>, State> {
abstract ['constructor']: any;
static title: string | null = null;
static category: string | null = null;
static id: string = '';
static packageName: string = '';
static version: string = '';
static icon: string | null = null;
static gatekeeper: string | null = null;
static isBundled: boolean;
static details: ActivatablePluginDetails;
static keyboardActions: KeyboardActions | null;
static screenshot: string | null;
static defaultPersistedState: any;
static persistedStateReducer: PersistedStateReducer | null;
static maxQueueSize: number = DEFAULT_MAX_QUEUE_SIZE;
static exportPersistedState:
| ((
callClient:
| undefined
| ((method: string, params?: any) => Promise<any>),
persistedState: StaticPersistedState | undefined,
store: ReduxState | undefined,
idler?: Idler,
statusUpdate?: (msg: string) => void,
supportsMethod?: (method: string) => Promise<boolean>,
) => Promise<StaticPersistedState | undefined>)
| undefined;
static getActiveNotifications:
| ((persistedState: StaticPersistedState) => Array<Notification>)
| undefined;
reducers: {
[actionName: string]: (state: State, actionData: any) => Partial<State>;
} = {};
onKeyboardAction: ((action: string) => void) | undefined;
toJSON() {
return `<${this.constructor.name}#${this.constructor.id}>`;
}
// methods to be overriden by plugins
init(): void {}
static serializePersistedState: (
persistedState: StaticPersistedState,
statusUpdate?: (msg: string) => void,
idler?: Idler,
pluginName?: string,
) => Promise<string> = async (
persistedState: StaticPersistedState,
_statusUpdate?: (msg: string) => void,
_idler?: Idler,
_pluginName?: string,
) => {
if (
persistedState &&
typeof persistedState === 'object' &&
!Array.isArray(persistedState)
) {
return JSON.stringify(
Object.fromEntries(
Object.entries(persistedState).map(([key, value]) => [
key,
_makeShallowSerializable(value), // make first level of persisted state serializable
]),
),
);
} else {
return JSON.stringify(persistedState);
}
};
static deserializePersistedState: (
serializedString: string,
) => StaticPersistedState = (serializedString: string) => {
const raw = JSON.parse(serializedString);
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return Object.fromEntries(
Object.entries(raw).map(([key, value]) => [
key,
_deserializeShallowObject(value),
]),
);
} else {
return raw;
}
};
teardown(): void {}
// methods to be overridden by subclasses
_init(): void {}
_teardown(): void {}
dispatchAction(actionData: Actions) {
const action = this.reducers[actionData.type];
if (!action) {
throw new ReferenceError(`Unknown action ${actionData.type}`);
}
if (typeof action === 'function') {
this.setState(action.call(this, this.state, actionData) as State);
} else {
throw new TypeError(`Reducer ${actionData.type} isn't a function`);
}
}
}
/**
* @deprecated Please use the newer "Sandy" plugin APIs!
* https://fbflipper.com/docs/extending/sandy-migration
*/
export class FlipperDevicePlugin<
S,
A extends BaseAction,
P,
> extends FlipperBasePlugin<S, A, P> {
['constructor']: typeof FlipperPlugin;
device: BaseDevice;
constructor(props: Props<P>) {
super(props);
this.device = props.target as BaseDevice;
}
_init() {
this.init();
}
_teardown() {
this.teardown();
}
// TODO T84453692: remove this function after some transition period in favor of BaseDevice.supportsPlugin.
static supportsDevice(_device: BaseDevice): boolean {
throw new Error(
'supportsDevice is unimplemented in FlipperDevicePlugin class',
);
}
}
/**
* @deprecated Please use the newer "Sandy" plugin APIs!
* https://fbflipper.com/docs/extending/sandy-migration
*/
export class FlipperPlugin<
S,
A extends BaseAction,
P,
> extends FlipperBasePlugin<S, A, P> {
['constructor']: typeof FlipperPlugin;
constructor(props: Props<P>) {
super(props);
// @ts-ignore constructor should be assigned already
const {id} = this.constructor;
this.subscriptions = [];
const realClient = (this.realClient = props.target as Client);
this.client = {
get isConnected() {
return realClient.connected.get();
},
call: (method, params) => this.realClient.call(id, method, true, params),
send: (method, params) => this.realClient.send(id, method, params),
subscribe: (method, callback) => {
this.subscriptions.push({
method,
callback,
});
this.realClient.subscribe(id, method, callback);
},
supportsMethod: (method) => this.realClient.supportsMethod(id, method),
};
}
subscriptions: Array<{
method: string;
callback: Function;
}>;
client: PluginClient;
realClient: Client;
get device() {
return this.realClient.device;
}
_teardown() {
// automatically unsubscribe subscriptions
const pluginId = this.constructor.id;
for (const {method, callback} of this.subscriptions) {
this.realClient.unsubscribe(pluginId, method, callback);
}
// run plugin teardown
this.teardown();
if (!this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.deinitPlugin(pluginId);
}
}
_init() {
const pluginId = this.constructor.id;
if (!this.realClient.isBackgroundPlugin(pluginId)) {
this.realClient.initPlugin(pluginId);
}
this.init();
}
}

View File

@@ -0,0 +1,335 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`acknowledgeProblems 1`] = `
Object {
"acknowledgedProblems": Array [
"ios.sdk",
"common.openssl",
],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": true,
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": true,
"status": "SUCCESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
},
"result": Object {
"isAcknowledged": true,
"status": "FAILED",
},
},
}
`;
exports[`finish 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"isAcknowledged": false,
"status": "SUCCESS",
},
},
},
"result": Object {
"status": "SUCCESS",
},
},
}
`;
exports[`startHealthCheck 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"status": "IN_PROGRESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "IN_PROGRESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"result": Object {
"status": "IN_PROGRESS",
},
},
}
`;
exports[`statuses updated after healthchecks finished 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "FAILED",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"isAcknowledged": false,
"status": "FAILED",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "SUCCESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "SUCCESS",
},
},
},
"result": Object {
"isAcknowledged": false,
"status": "FAILED",
},
},
}
`;
exports[`updateHealthcheckResult 1`] = `
Object {
"acknowledgedProblems": Array [],
"healthcheckReport": Object {
"categories": Object {
"android": Object {
"checks": Object {
"android.sdk": Object {
"key": "android.sdk",
"label": "SDK Installed",
"result": Object {
"isAcknowledged": false,
"message": "Updated Test Message",
"status": "SUCCESS",
},
},
},
"key": "android",
"label": "Android",
"result": Object {
"status": "IN_PROGRESS",
},
},
"common": Object {
"checks": Object {
"common.openssl": Object {
"key": "common.openssl",
"label": "OpenSSL Istalled",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "common",
"label": "Common",
"result": Object {
"status": "IN_PROGRESS",
},
},
"ios": Object {
"checks": Object {
"ios.sdk": Object {
"key": "ios.sdk",
"label": "SDK Installed",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"key": "ios",
"label": "iOS",
"result": Object {
"status": "IN_PROGRESS",
},
},
},
"result": Object {
"status": "IN_PROGRESS",
},
},
}
`;

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import reducer from '../application';
import {
initialState,
addStatusMessage,
removeStatusMessage,
} from '../application';
test('ADD_STATUS_MSG, to check if the status messages get pushed to the state', () => {
const state = reducer(
initialState(),
addStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(state.statusMessages).toEqual(['Test: Status Msg']);
const updatedstate = reducer(
state,
addStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
expect(updatedstate.statusMessages).toEqual([
'Test: Status Msg',
'Test: Status Msg 2',
]);
});
test('REMOVE_STATUS_MSG, to check if the status messages gets removed from the state', () => {
const initState = initialState();
const state = reducer(
initState,
removeStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(state).toEqual(initState);
const stateWithMessages = reducer(
reducer(
initialState(),
addStatusMessage({msg: 'Status Msg', sender: 'Test'}),
),
addStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
const updatedState = reducer(
stateWithMessages,
removeStatusMessage({msg: 'Status Msg', sender: 'Test'}),
);
expect(updatedState.statusMessages).toEqual(['Test: Status Msg 2']);
const updatedStateWithNoMessages = reducer(
updatedState,
removeStatusMessage({msg: 'Status Msg 2', sender: 'Test'}),
);
expect(updatedStateWithNoMessages.statusMessages).toEqual([]);
});

View File

@@ -0,0 +1,413 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import reducer, {selectClient, selectDevice} from '../connections';
import {State, selectPlugin} from '../connections';
import {
_SandyPluginDefinition,
_setFlipperLibImplementation,
TestUtils,
MockedConsole,
} from 'flipper-plugin';
import {TestDevice} from '../../test-utils/TestDevice';
import {
createMockFlipperWithPlugin,
MockFlipperResult,
} from '../../test-utils/createMockFlipperWithPlugin';
import {Store} from '..';
import {getActiveClient, getActiveDevice} from '../../selectors/connections';
import BaseDevice from '../../devices/BaseDevice';
import Client from '../../Client';
let mockedConsole: MockedConsole;
beforeEach(() => {
mockedConsole = TestUtils.mockConsole();
_setFlipperLibImplementation(TestUtils.createMockFlipperLib());
});
afterEach(() => {
mockedConsole.unmock();
_setFlipperLibImplementation(undefined);
});
test('doing a double REGISTER_DEVICE fails', () => {
const device1 = new TestDevice('serial', 'physical', 'title', 'Android');
const device2 = new TestDevice('serial', 'physical', 'title2', 'Android');
const initialState: State = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
expect(initialState.devices.length).toBe(1);
expect(initialState.devices[0]).toBe(device1);
expect(() => {
reducer(initialState, {
type: 'REGISTER_DEVICE',
payload: device2,
});
}).toThrow('still connected');
});
test('register, remove, re-register a metro device works correctly', () => {
const device1 = new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
);
let state: State = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native');
device1.disconnect();
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native (Offline)');
state = reducer(state, {
type: 'REGISTER_DEVICE',
payload: new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
),
});
expect(state.devices.length).toBe(1);
expect(state.devices[0].displayTitle()).toBe('React Native');
expect(state.devices[0]).not.toBe(device1);
});
test('selectPlugin sets deepLinkPayload correctly', () => {
const device1 = new TestDevice(
'http://localhost:8081',
'emulator',
'React Native',
'Metro',
);
let state = reducer(undefined, {
type: 'REGISTER_DEVICE',
payload: device1,
});
state = reducer(
undefined,
selectPlugin({
selectedPlugin: 'myPlugin',
deepLinkPayload: 'myPayload',
selectedDevice: device1,
}),
);
expect(state.deepLinkPayload).toBe('myPayload');
});
test('can handle plugins that throw at start', async () => {
const TestPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
throw new Error('Broken plugin');
},
},
);
const {client, store, createClient, createDevice} =
await createMockFlipperWithPlugin(TestPlugin);
// not initialized
expect(client.sandyPluginStates.get(TestPlugin.id)).toBe(undefined);
expect(store.getState().connections.clients.size).toBe(1);
expect(client.connected.get()).toBe(true);
expect((console.error as any).mock.calls[0]).toMatchInlineSnapshot(`
Array [
"Failed to start plugin 'TestPlugin': ",
[Error: Broken plugin],
]
`);
const device2 = await createDevice({});
const client2 = await createClient(device2, client.query.app);
expect((console.error as any).mock.calls[1]).toMatchInlineSnapshot(`
Array [
"Failed to start plugin 'TestPlugin': ",
[Error: Broken plugin],
]
`);
expect(store.getState().connections.clients.size).toBe(2);
expect(client2.connected.get()).toBe(true);
expect(client2.sandyPluginStates.size).toBe(0);
});
test('can handle device plugins that throw at start', async () => {
const TestPlugin = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
devicePlugin() {
throw new Error('Broken device plugin');
},
},
);
const {device, store, createDevice} = await createMockFlipperWithPlugin(
TestPlugin,
);
expect(mockedConsole.errorCalls[0]).toMatchInlineSnapshot(`
Array [
"Failed to start device plugin 'TestPlugin': ",
[Error: Broken device plugin],
]
`);
// not initialized
expect(device.sandyPluginStates.get(TestPlugin.id)).toBe(undefined);
expect(store.getState().connections.devices.length).toBe(1);
expect(device.connected.get()).toBe(true);
const device2 = await createDevice({});
expect(store.getState().connections.devices.length).toBe(2);
expect(device2.connected.get()).toBe(true);
expect(mockedConsole.errorCalls[1]).toMatchInlineSnapshot(`
Array [
"Failed to start device plugin 'TestPlugin': ",
[Error: Broken device plugin],
]
`);
expect(device2.sandyPluginStates.size).toBe(0);
});
describe('selection changes', () => {
const TestPlugin1 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
return {};
},
},
);
const TestPlugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails(),
{
Component() {
return null;
},
plugin() {
return {};
},
},
);
const DevicePlugin1 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({pluginType: 'device'}),
{
Component() {
return null;
},
devicePlugin() {
return {};
},
},
);
let device1: BaseDevice;
let device2: BaseDevice;
let metroDevice: BaseDevice;
let d1app1: Client;
let d1app2: Client;
let d2app1: Client;
let d2app2: Client;
let store: Store;
let mockFlipper: MockFlipperResult;
beforeEach(async () => {
mockFlipper = await createMockFlipperWithPlugin(TestPlugin1, {
additionalPlugins: [TestPlugin2, DevicePlugin1],
supportedPlugins: [TestPlugin1.id, TestPlugin2.id, DevicePlugin1.id],
});
device1 = mockFlipper.device;
device2 = mockFlipper.createDevice({});
metroDevice = mockFlipper.createDevice({
os: 'Metro',
serial: 'http://localhost:8081',
});
d1app1 = mockFlipper.client;
d1app2 = await mockFlipper.createClient(device1, 'd1app2');
d2app1 = await mockFlipper.createClient(device2, 'd2app1');
d2app2 = await mockFlipper.createClient(device2, 'd2app2');
store = mockFlipper.store;
});
test('basic/ device selection change', async () => {
// after registering d1app2, this will have become the selection
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin1.id,
selectedAppId: d1app2.id,
// no preferences changes, no explicit selection was made
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
expect(getActiveClient(store.getState())).toBe(d1app2);
expect(getActiveDevice(store.getState())).toBe(device1);
// select plugin 2 on d2app2
store.dispatch(
selectPlugin({
selectedPlugin: TestPlugin2.id,
selectedAppId: d2app2.id,
}),
);
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin2.id,
selectedAppId: d2app2.id,
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
// disconnect device1, and then register a new device should select it
device1.disconnect();
const device3 = await mockFlipper.createDevice({});
expect(store.getState().connections).toMatchObject({
selectedDevice: device3,
selectedPlugin: TestPlugin2.id,
selectedAppId: null,
// prefs not updated
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
store.dispatch(selectDevice(device1));
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin2.id,
selectedAppId: null,
userPreferredDevice: device1.title,
// other prefs not updated
userPreferredPlugin: TestPlugin2.id,
userPreferredApp: d2app2.query.app,
});
// used by plugin list, to keep main device / app selection correct
expect(getActiveClient(store.getState())).toBe(null);
expect(getActiveDevice(store.getState())).toBe(device1);
});
test('select a metro device', async () => {
store.dispatch(
selectPlugin({
selectedPlugin: DevicePlugin1.id,
selectedDevice: metroDevice,
selectedAppId: d2app1.id, // this app will determine the active device
}),
);
const state = store.getState();
expect(state.connections).toMatchObject({
selectedDevice: metroDevice,
selectedPlugin: DevicePlugin1.id,
selectedAppId: d2app1.id,
userPreferredDevice: metroDevice.title,
// other prefs not updated
userPreferredPlugin: DevicePlugin1.id,
userPreferredApp: d2app1.query.app,
});
// used by plugin list, to keep main device / app selection correct
expect(getActiveClient(state)).toBe(d2app1);
expect(getActiveDevice(state)).toBe(device2);
});
test('introducing new client does not select it', async () => {
await mockFlipper.createClient(device2, 'd2app3');
expect(store.getState().connections).toMatchObject({
selectedDevice: device1,
selectedPlugin: TestPlugin1.id,
selectedAppId: d1app2.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('introducing new client does select it if preferred', async () => {
// pure testing evil
const client3 = await mockFlipper.createClient(
device2,
store.getState().connections.userPreferredApp!,
);
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: client3.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('introducing new client does select it if old is offline', async () => {
d1app2.disconnect();
const client3 = await mockFlipper.createClient(device2, 'd2app3');
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: client3.id,
// other prefs not updated
userPreferredDevice: device1.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
test('select client', () => {
store.dispatch(selectClient(d2app2.id));
expect(store.getState().connections).toMatchObject({
selectedDevice: device2,
selectedPlugin: TestPlugin1.id,
selectedAppId: d2app2.id,
userPreferredDevice: device2.title,
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d2app2.query.app,
});
});
test('select device', () => {
store.dispatch(selectDevice(metroDevice));
expect(store.getState().connections).toMatchObject({
selectedDevice: metroDevice,
selectedPlugin: TestPlugin1.id,
selectedAppId: null,
userPreferredDevice: metroDevice.title,
// other prefs not updated
userPreferredPlugin: TestPlugin1.id,
userPreferredApp: d1app1.query.app,
});
});
});

View File

@@ -0,0 +1,168 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
default as reducer,
startHealthchecks,
finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
} from '../healthchecks';
import {Healthchecks, EnvironmentInfo} from 'flipper-doctor';
const HEALTHCHECKS: Healthchecks = {
ios: {
label: 'iOS',
isSkipped: false,
isRequired: true,
healthchecks: [
{
key: 'ios.sdk',
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false, message: ''};
},
},
],
},
android: {
label: 'Android',
isSkipped: false,
isRequired: true,
healthchecks: [
{
key: 'android.sdk',
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: true, message: 'Error'};
},
},
],
},
common: {
label: 'Common',
isSkipped: false,
isRequired: false,
healthchecks: [
{
key: 'common.openssl',
label: 'OpenSSL Istalled',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false, message: ''};
},
},
],
},
};
test('startHealthCheck', () => {
const res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
expect(res).toMatchSnapshot();
});
test('updateHealthcheckResult', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
expect(res).toMatchSnapshot();
});
test('finish', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res).toMatchSnapshot();
});
test('statuses updated after healthchecks finished', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
message: 'Updated Test Message',
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res).toMatchSnapshot();
});
test('acknowledgeProblems', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult('ios', 'ios.sdk', {
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(
res,
updateHealthcheckResult('android', 'android.sdk', {
isAcknowledged: false,
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult('common', 'common.openssl', {
isAcknowledged: false,
status: 'FAILED',
}),
);
res = reducer(res, finishHealthchecks());
res = reducer(res, acknowledgeProblems());
expect(res).toMatchSnapshot();
});

Some files were not shown because too many files have changed in this diff Show More