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:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
21
desktop/flipper-ui-core/src/.eslintrc.js
Normal file
21
desktop/flipper-ui-core/src/.eslintrc.js
Normal 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/*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
706
desktop/flipper-ui-core/src/Client.tsx
Normal file
706
desktop/flipper-ui-core/src/Client.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
577
desktop/flipper-ui-core/src/NotificationsHub.tsx
Normal file
577
desktop/flipper-ui-core/src/NotificationsHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
438
desktop/flipper-ui-core/src/PluginContainer.tsx
Normal file
438
desktop/flipper-ui-core/src/PluginContainer.tsx
Normal 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);
|
||||
16
desktop/flipper-ui-core/src/ReleaseChannel.tsx
Normal file
16
desktop/flipper-ui-core/src/ReleaseChannel.tsx
Normal 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;
|
||||
126
desktop/flipper-ui-core/src/RenderHost.tsx
Normal file
126
desktop/flipper-ui-core/src/RenderHost.tsx
Normal 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 {};
|
||||
},
|
||||
};
|
||||
}
|
||||
25
desktop/flipper-ui-core/src/__mocks__/electron.tsx
Normal file
25
desktop/flipper-ui-core/src/__mocks__/electron.tsx
Normal 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: {},
|
||||
};
|
||||
16
desktop/flipper-ui-core/src/__mocks__/uuid.tsx
Normal file
16
desktop/flipper-ui-core/src/__mocks__/uuid.tsx
Normal 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';
|
||||
}
|
||||
1402
desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx
Normal file
1402
desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {},
|
||||
}
|
||||
`;
|
||||
@@ -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}"`);
|
||||
});
|
||||
165
desktop/flipper-ui-core/src/__tests__/deeplink.node.tsx
Normal file
165
desktop/flipper-ui-core/src/__tests__/deeplink.node.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
254
desktop/flipper-ui-core/src/__tests__/disconnect.node.tsx
Normal file
254
desktop/flipper-ui-core/src/__tests__/disconnect.node.tsx
Normal 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);
|
||||
});
|
||||
153
desktop/flipper-ui-core/src/chrome/ChangelogSheet.tsx
Normal file
153
desktop/flipper-ui-core/src/chrome/ChangelogSheet.tsx
Normal 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));
|
||||
}
|
||||
144
desktop/flipper-ui-core/src/chrome/ConsoleLogs.tsx
Normal file
144
desktop/flipper-ui-core/src/chrome/ConsoleLogs.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
420
desktop/flipper-ui-core/src/chrome/DoctorSheet.tsx
Normal file
420
desktop/flipper-ui-core/src/chrome/DoctorSheet.tsx
Normal 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);
|
||||
59
desktop/flipper-ui-core/src/chrome/ExportDataPluginSheet.tsx
Normal file
59
desktop/flipper-ui-core/src/chrome/ExportDataPluginSheet.tsx
Normal 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);
|
||||
29
desktop/flipper-ui-core/src/chrome/FlipperDevTools.tsx
Normal file
29
desktop/flipper-ui-core/src/chrome/FlipperDevTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
desktop/flipper-ui-core/src/chrome/FlipperMessages.tsx
Normal file
205
desktop/flipper-ui-core/src/chrome/FlipperMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
desktop/flipper-ui-core/src/chrome/FpsGraph.tsx
Normal file
92
desktop/flipper-ui-core/src/chrome/FpsGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
desktop/flipper-ui-core/src/chrome/ListView.tsx
Normal file
256
desktop/flipper-ui-core/src/chrome/ListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
94
desktop/flipper-ui-core/src/chrome/MetroButton.tsx
Normal file
94
desktop/flipper-ui-core/src/chrome/MetroButton.tsx
Normal 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');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
desktop/flipper-ui-core/src/chrome/NetworkGraph.tsx
Normal file
69
desktop/flipper-ui-core/src/chrome/NetworkGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
desktop/flipper-ui-core/src/chrome/PlatformSelectWizard.tsx
Normal file
158
desktop/flipper-ui-core/src/chrome/PlatformSelectWizard.tsx
Normal 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');
|
||||
}
|
||||
126
desktop/flipper-ui-core/src/chrome/PluginActions.tsx
Normal file
126
desktop/flipper-ui-core/src/chrome/PluginActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
desktop/flipper-ui-core/src/chrome/PluginActionsMenu.tsx
Normal file
154
desktop/flipper-ui-core/src/chrome/PluginActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
desktop/flipper-ui-core/src/chrome/RatingButton.tsx
Normal file
355
desktop/flipper-ui-core/src/chrome/RatingButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
desktop/flipper-ui-core/src/chrome/ScreenCaptureButtons.tsx
Normal file
126
desktop/flipper-ui-core/src/chrome/ScreenCaptureButtons.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
389
desktop/flipper-ui-core/src/chrome/SettingsSheet.tsx
Normal file
389
desktop/flipper-ui-core/src/chrome/SettingsSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
desktop/flipper-ui-core/src/chrome/ShareSheetErrorList.tsx
Normal file
63
desktop/flipper-ui-core/src/chrome/ShareSheetErrorList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
211
desktop/flipper-ui-core/src/chrome/ShareSheetExportFile.tsx
Normal file
211
desktop/flipper-ui-core/src/chrome/ShareSheetExportFile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
227
desktop/flipper-ui-core/src/chrome/ShareSheetExportUrl.tsx
Normal file
227
desktop/flipper-ui-core/src/chrome/ShareSheetExportUrl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
136
desktop/flipper-ui-core/src/chrome/UpdateIndicator.tsx
Normal file
136
desktop/flipper-ui-core/src/chrome/UpdateIndicator.tsx
Normal 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>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
desktop/flipper-ui-core/src/chrome/VideoRecordingButton.tsx
Normal file
101
desktop/flipper-ui-core/src/chrome/VideoRecordingButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
52
desktop/flipper-ui-core/src/chrome/fb-stubs/PluginInfo.tsx
Normal file
52
desktop/flipper-ui-core/src/chrome/fb-stubs/PluginInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
desktop/flipper-ui-core/src/chrome/fb-stubs/SignInSheet.tsx
Normal file
14
desktop/flipper-ui-core/src/chrome/fb-stubs/SignInSheet.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
153
desktop/flipper-ui-core/src/chrome/settings/configFields.tsx
Normal file
153
desktop/flipper-ui-core/src/chrome/settings/configFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
desktop/flipper-ui-core/src/deeplink.tsx
Normal file
164
desktop/flipper-ui-core/src/deeplink.tsx
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
53
desktop/flipper-ui-core/src/deeplinkTracking.tsx
Normal file
53
desktop/flipper-ui-core/src/deeplinkTracking.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
132
desktop/flipper-ui-core/src/deprecated-exports.tsx
Normal file
132
desktop/flipper-ui-core/src/deprecated-exports.tsx
Normal 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';
|
||||
85
desktop/flipper-ui-core/src/devices/ArchivedDevice.tsx
Normal file
85
desktop/flipper-ui-core/src/devices/ArchivedDevice.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
350
desktop/flipper-ui-core/src/devices/BaseDevice.tsx
Normal file
350
desktop/flipper-ui-core/src/devices/BaseDevice.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
75
desktop/flipper-ui-core/src/dispatcher/application.tsx
Normal file
75
desktop/flipper-ui-core/src/dispatcher/application.tsx
Normal 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`);
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
12
desktop/flipper-ui-core/src/dispatcher/fb-stubs/user.tsx
Normal file
12
desktop/flipper-ui-core/src/dispatcher/fb-stubs/user.tsx
Normal 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
|
||||
};
|
||||
308
desktop/flipper-ui-core/src/dispatcher/flipperServer.tsx
Normal file
308
desktop/flipper-ui-core/src/dispatcher/flipperServer.tsx
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
52
desktop/flipper-ui-core/src/dispatcher/index.tsx
Normal file
52
desktop/flipper-ui-core/src/dispatcher/index.tsx
Normal 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(() => {});
|
||||
};
|
||||
}
|
||||
162
desktop/flipper-ui-core/src/dispatcher/notifications.tsx
Normal file
162
desktop/flipper-ui-core/src/dispatcher/notifications.tsx
Normal 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>',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
175
desktop/flipper-ui-core/src/dispatcher/pluginDownloads.tsx
Normal file
175
desktop/flipper-ui-core/src/dispatcher/pluginDownloads.tsx
Normal 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),
|
||||
)
|
||||
);
|
||||
}
|
||||
343
desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx
Normal file
343
desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx
Normal 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);
|
||||
}
|
||||
360
desktop/flipper-ui-core/src/dispatcher/plugins.tsx
Normal file
360
desktop/flipper-ui-core/src/dispatcher/plugins.tsx
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
59
desktop/flipper-ui-core/src/dispatcher/reactNative.tsx
Normal file
59
desktop/flipper-ui-core/src/dispatcher/reactNative.tsx
Normal 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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
380
desktop/flipper-ui-core/src/dispatcher/tracking.tsx
Normal file
380
desktop/flipper-ui-core/src/dispatcher/tracking.tsx
Normal 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),
|
||||
);
|
||||
}
|
||||
16
desktop/flipper-ui-core/src/dispatcher/types.tsx
Normal file
16
desktop/flipper-ui-core/src/dispatcher/types.tsx
Normal 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;
|
||||
21
desktop/flipper-ui-core/src/fb-stubs/ErrorReporter.tsx
Normal file
21
desktop/flipper-ui-core/src/fb-stubs/ErrorReporter.tsx
Normal 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) {}
|
||||
}
|
||||
66
desktop/flipper-ui-core/src/fb-stubs/GK.tsx
Normal file
66
desktop/flipper-ui-core/src/fb-stubs/GK.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
desktop/flipper-ui-core/src/fb-stubs/IDEFileResolver.tsx
Normal file
72
desktop/flipper-ui-core/src/fb-stubs/IDEFileResolver.tsx
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
15
desktop/flipper-ui-core/src/fb-stubs/Logger.tsx
Normal file
15
desktop/flipper-ui-core/src/fb-stubs/Logger.tsx
Normal 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();
|
||||
}
|
||||
13
desktop/flipper-ui-core/src/fb-stubs/Prefetcher.tsx
Normal file
13
desktop/flipper-ui-core/src/fb-stubs/Prefetcher.tsx
Normal 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;
|
||||
18
desktop/flipper-ui-core/src/fb-stubs/ScribeLogger.tsx
Normal file
18
desktop/flipper-ui-core/src/fb-stubs/ScribeLogger.tsx
Normal 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) {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
39
desktop/flipper-ui-core/src/fb-stubs/UserFeedback.tsx
Normal file
39
desktop/flipper-ui-core/src/fb-stubs/UserFeedback.tsx
Normal 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.');
|
||||
}
|
||||
41
desktop/flipper-ui-core/src/fb-stubs/__mocks__/Logger.tsx
Normal file
41
desktop/flipper-ui-core/src/fb-stubs/__mocks__/Logger.tsx
Normal 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;
|
||||
}
|
||||
81
desktop/flipper-ui-core/src/fb-stubs/checkForUpdate.tsx
Normal file
81
desktop/flipper-ui-core/src/fb-stubs/checkForUpdate.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
19
desktop/flipper-ui-core/src/fb-stubs/config.tsx
Normal file
19
desktop/flipper-ui-core/src/fb-stubs/config.tsx
Normal 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,
|
||||
};
|
||||
53
desktop/flipper-ui-core/src/fb-stubs/constants.tsx
Normal file
53
desktop/flipper-ui-core/src/fb-stubs/constants.tsx
Normal 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://',
|
||||
],
|
||||
});
|
||||
14
desktop/flipper-ui-core/src/fb-stubs/createPaste.tsx
Normal file
14
desktop/flipper-ui-core/src/fb-stubs/createPaste.tsx
Normal 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!'));
|
||||
}
|
||||
101
desktop/flipper-ui-core/src/fb-stubs/user.tsx
Normal file
101
desktop/flipper-ui-core/src/fb-stubs/user.tsx
Normal 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;
|
||||
}
|
||||
32
desktop/flipper-ui-core/src/global.ts
Normal file
32
desktop/flipper-ui-core/src/global.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
314
desktop/flipper-ui-core/src/plugin.tsx
Normal file
314
desktop/flipper-ui-core/src/plugin.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user