Merge branch 'main' of github.com:facebook/flipper into universalBuild

This commit is contained in:
2023-11-29 09:19:25 +01:00
103 changed files with 2466 additions and 1154 deletions

View File

@@ -143,7 +143,7 @@ async function getFlipperServer(
const {readyForIncomingConnections} = await startServer(
{
staticPath,
entry: 'index.web.dev.html',
entry: 'index.web.html',
port,
},
environmentInfo,

View File

@@ -1,11 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 const getIdbInstallationInstructions = (idbPath: string) =>
`IDB is required to use Flipper with iOS devices. It can be installed from https://github.com/facebook/idb and configured in Flipper settings. You can also disable physical iOS device support in settings. Current setting: ${idbPath} isn't a valid IDB installation.`;

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 const getIdbInstallationInstructions = (
idbPath: string,
): {message: string; commands: {title: string; command: string}[]} => ({
message: `IDB is required to use Flipper with iOS devices. It can be installed from https://github.com/facebook/idb and configured in Flipper settings. You can also disable physical iOS device support in settings. Current setting: ${idbPath} isn't a valid IDB installation.`,
commands: [],
});
export const installXcode =
'Install Xcode from the App Store or download it from https://developer.apple.com';
export const installSDK =
'You can install it using Xcode (https://developer.apple.com/xcode/). Once installed, restart flipper.';
export const installAndroidStudio = [
'Android Studio is not installed.',
'Install Android Studio from https://developer.android.com/studio',
].join('\n');

View File

@@ -17,7 +17,12 @@ import * as fs from 'fs';
import * as path from 'path';
import type {FlipperDoctor} from 'flipper-common';
import * as fs_extra from 'fs-extra';
import {getIdbInstallationInstructions} from './fb-stubs/idbInstallationInstructions';
import {
getIdbInstallationInstructions,
installXcode,
installSDK,
installAndroidStudio,
} from './fb-stubs/messages';
import {validateSelectedXcodeVersion} from './fb-stubs/validateSelectedXcodeVersion';
export function getHealthchecks(): FlipperDoctor.Healthchecks {
@@ -62,6 +67,29 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
isRequired: false,
isSkipped: false,
healthchecks: [
...(process.platform === 'darwin'
? [
{
key: 'android.android-studio',
label: 'Android Studio Installed',
isRequired: false,
run: async (_: FlipperDoctor.EnvironmentInfo) => {
const hasProblem = !fs.existsSync(
'/Applications/Android Studio.app',
);
const message = hasProblem
? installAndroidStudio
: `Android Studio is installed.`;
return {
hasProblem,
message,
};
},
},
]
: []),
{
key: 'android.sdk',
label: 'SDK Installed',
@@ -74,12 +102,12 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
if (!androidHome) {
androidHomeResult = {
hasProblem: true,
message: `ANDROID_HOME is not defined. You can use Flipper Settings (File > Preferences) to point to its location.`,
message: `ANDROID_HOME is not defined. You can use Flipper Settings (More > Settings) to point to its location.`,
};
} else if (!fs.existsSync(androidHome)) {
androidHomeResult = {
hasProblem: true,
message: `ANDROID_HOME point to a folder which does not exist: ${androidHome}. You can use Flipper Settings (File > Preferences) to point to a different location.`,
message: `ANDROID_HOME point to a folder which does not exist: ${androidHome}. You can use Flipper Settings (More > Settings) to point to a different location.`,
};
} else {
const platformToolsDir = path.join(androidHome, 'platform-tools');
@@ -102,12 +130,12 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
if (!androidSdkRoot) {
androidSdkRootResult = {
hasProblem: true,
message: `ANDROID_SDK_ROOT is not defined. You can use Flipper Settings (File > Preferences) to point to its location.`,
message: `ANDROID_SDK_ROOT is not defined. You can use Flipper Settings (More > Settings) to point to its location.`,
};
} else if (!fs.existsSync(androidSdkRoot)) {
androidSdkRootResult = {
hasProblem: true,
message: `ANDROID_SDK_ROOT point to a folder which does not exist: ${androidSdkRoot}. You can use Flipper Settings (File > Preferences) to point to a different location.`,
message: `ANDROID_SDK_ROOT point to a folder which does not exist: ${androidSdkRoot}. You can use Flipper Settings (More > Settings) to point to a different location.`,
};
} else {
const platformToolsDir = path.join(
@@ -137,26 +165,6 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
isRequired: false,
isSkipped: false,
healthchecks: [
{
key: 'ios.sdk',
label: 'SDK Installed',
isRequired: true,
run: async (e: FlipperDoctor.EnvironmentInfo) => {
const hasProblem =
!e.SDKs['iOS SDK'] ||
!e.SDKs['iOS SDK'].Platforms ||
!e.SDKs['iOS SDK'].Platforms.length;
const message = hasProblem
? 'iOS SDK is not installed. You can install it using Xcode (https://developer.apple.com/xcode/).'
: `iOS SDK is installed for the following platforms: ${JSON.stringify(
e.SDKs['iOS SDK'].Platforms,
)}.`;
return {
hasProblem,
message,
};
},
},
{
key: 'ios.xcode',
label: 'XCode Installed',
@@ -164,7 +172,7 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
run: async (e: FlipperDoctor.EnvironmentInfo) => {
const hasProblem = e.IDEs == null || e.IDEs.Xcode == null;
const message = hasProblem
? 'Xcode (https://developer.apple.com/xcode/) is not installed.'
? `Xcode is not installed.\n${installXcode}.`
: `Xcode version ${e.IDEs.Xcode.version} is installed at "${e.IDEs.Xcode.path}".`;
return {
hasProblem,
@@ -217,6 +225,26 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
};
},
},
{
key: 'ios.sdk',
label: 'SDK Installed',
isRequired: true,
run: async (e: FlipperDoctor.EnvironmentInfo) => {
const hasProblem =
!e.SDKs['iOS SDK'] ||
!e.SDKs['iOS SDK'].Platforms ||
!e.SDKs['iOS SDK'].Platforms.length;
const message = hasProblem
? `iOS SDK is not installed. ${installSDK}`
: `iOS SDK is installed for the following platforms: ${JSON.stringify(
e.SDKs['iOS SDK'].Platforms,
)}.`;
return {
hasProblem,
message,
};
},
},
{
key: 'ios.xctrace',
label: 'xctrace exists',
@@ -260,13 +288,17 @@ export function getHealthchecks(): FlipperDoctor.Healthchecks {
const result = await tryExecuteCommand(
`${settings?.idbPath} --help`,
);
const hasProblem = result.hasProblem;
const message = hasProblem
? getIdbInstallationInstructions(settings.idbPath)
: 'Flipper is configured to use your IDB installation.';
if (result.hasProblem) {
return {
hasProblem: true,
...getIdbInstallationInstructions(settings.idbPath),
};
}
return {
hasProblem,
message,
hasProblem: false,
message:
'Flipper is configured to use your IDB installation.',
};
},
},

View File

@@ -82,6 +82,7 @@ export type ActivatablePluginDetails = InstalledPluginDetails;
// Describes plugin available for downloading. Until downloaded to the disk it is not available for activation in Flipper.
export interface DownloadablePluginDetails extends ConcretePluginDetails {
isActivatable: false;
buildId: string;
downloadUrl: string;
lastUpdated: Date;
// Indicates whether plugin should be enabled by default for new users

View File

@@ -54,6 +54,7 @@ export {
isConnectivityOrAuthError,
isError,
isAuthError,
FlipperServerDisconnectedError,
getStringFromErrorLike,
getErrorFromErrorLike,
deserializeRemoteError,

View File

@@ -168,6 +168,7 @@ export type FlipperServerEvents = {
'plugins-server-add-on-message': ExecuteMessage;
'download-file-update': DownloadFileUpdate;
'server-log': LoggerInfo;
'browser-connection-created': {};
};
export type OS =
@@ -287,6 +288,7 @@ export type FlipperServerCommands = {
serial: string,
appBundlePath: string,
) => Promise<void>;
'device-open-app': (serial: string, name: string) => Promise<void>;
'device-forward-port': (
serial: string,
local: string,
@@ -371,6 +373,7 @@ export type FlipperServerCommands = {
timeout?: number;
internGraphUrl?: string;
headers?: Record<string, string | number | boolean>;
vpnMode?: 'vpn' | 'vpnless';
},
) => Promise<GraphResponse>;
'intern-upload-scribe-logs': (
@@ -381,6 +384,7 @@ export type FlipperServerCommands = {
'is-logged-in': () => Promise<boolean>;
'environment-info': () => Promise<EnvironmentInfo>;
'move-pwa': () => Promise<void>;
'fetch-new-version': (version: string) => Promise<void>;
};
export type GraphResponse = {

View File

@@ -96,6 +96,12 @@ export class NoLongerConnectedToClientError extends Error {
name: 'NoLongerConnectedToClientError';
}
export class FlipperServerDisconnectedError extends Error {
constructor(public readonly reason: 'ws-close') {
super(`Flipper Server disconnected. Reason: ${reason}`);
}
}
declare global {
interface Error {
interaction?: unknown;

View File

@@ -11,6 +11,7 @@ import {
InstalledPluginDetails,
tryCatchReportPluginFailuresAsync,
notNull,
FlipperServerDisconnectedError,
} from 'flipper-common';
import {ActivatablePluginDetails, ConcretePluginDetails} from 'flipper-common';
import {reportUsage} from 'flipper-common';
@@ -229,7 +230,15 @@ export const createRequirePluginFunction =
return pluginDefinition;
} catch (e) {
failedPlugins.push([pluginDetails, e.message]);
console.error(`Plugin ${pluginDetails.id} failed to load`, e);
let severity: 'error' | 'warn' = 'error';
if (
e instanceof FlipperServerDisconnectedError &&
e.reason === 'ws-close'
) {
severity = 'warn';
}
console[severity](`Plugin ${pluginDetails.id} failed to load`, e);
return null;
}
};

View File

@@ -11,6 +11,7 @@ import sortedIndexBy from 'lodash/sortedIndexBy';
import sortedLastIndexBy from 'lodash/sortedLastIndexBy';
import property from 'lodash/property';
import lodashSort from 'lodash/sortBy';
import EventEmitter from 'eventemitter3';
// If the dataSource becomes to large, after how many records will we start to drop items?
const dropFactor = 0.1;
@@ -45,12 +46,23 @@ type ShiftEvent<T> = {
entries: Entry<T>[];
amount: number;
};
type SINewIndexValueEvent<T> = {
type: 'siNewIndexValue';
indexKey: string;
value: T;
firstOfKind: boolean;
};
type ClearEvent = {
type: 'clear';
};
type DataEvent<T> =
| AppendEvent<T>
| UpdateEvent<T>
| RemoveEvent<T>
| ShiftEvent<T>;
| ShiftEvent<T>
| SINewIndexValueEvent<T>
| ClearEvent;
type Entry<T> = {
value: T;
@@ -180,6 +192,8 @@ export class DataSource<T extends any, KeyType = never> {
[viewId: string]: DataSourceView<T, KeyType>;
};
private readonly outputEventEmitter = new EventEmitter();
constructor(
keyAttribute: keyof T | undefined,
secondaryIndices: IndexDefinition<T>[] = [],
@@ -259,6 +273,10 @@ export class DataSource<T extends any, KeyType = never> {
};
}
public secondaryIndicesKeys(): string[] {
return [...this._secondaryIndices.keys()];
}
/**
* Returns the index of a specific key in the *records* set.
* Returns -1 if the record wansn't found
@@ -466,6 +484,7 @@ export class DataSource<T extends any, KeyType = never> {
this.shiftOffset = 0;
this.idToIndex.clear();
this.rebuild();
this.emitDataEvent({type: 'clear'});
}
/**
@@ -519,6 +538,16 @@ export class DataSource<T extends any, KeyType = never> {
}
}
public addDataListener<E extends DataEvent<T>['type']>(
event: E,
cb: (data: Extract<DataEvent<T>, {type: E}>) => void,
) {
this.outputEventEmitter.addListener(event, cb);
return () => {
this.outputEventEmitter.removeListener(event, cb);
};
}
private assertKeySet() {
if (!this.keyAttribute) {
throw new Error(
@@ -550,6 +579,7 @@ export class DataSource<T extends any, KeyType = never> {
Object.entries(this.additionalViews).forEach(([, dataView]) => {
dataView.processEvent(event);
});
this.outputEventEmitter.emit(event.type, event);
}
private storeSecondaryIndices(value: T) {
@@ -567,6 +597,12 @@ export class DataSource<T extends any, KeyType = never> {
} else {
a.push(value);
}
this.emitDataEvent({
type: 'siNewIndexValue',
indexKey: indexValue,
value,
firstOfKind: !a,
});
}
}
@@ -627,11 +663,21 @@ export class DataSource<T extends any, KeyType = never> {
return this.getAllRecordsByIndex(indexQuery)[0];
}
public getAllIndexValues(index: IndexDefinition<T>) {
const sortedKeys = index.slice().sort();
const indexKey = sortedKeys.join(':');
const recordsByIndex = this._recordsBySecondaryIndex.get(indexKey);
if (!recordsByIndex) {
return;
}
return [...recordsByIndex.keys()];
}
private getSecondaryIndexValueFromRecord(
record: T,
// assumes keys is already ordered
keys: IndexDefinition<T>,
): any {
): string {
return JSON.stringify(
Object.fromEntries(keys.map((k) => [k, String(record[k])])),
);
@@ -989,6 +1035,10 @@ export class DataSourceView<T, KeyType> {
}
break;
}
case 'clear':
case 'siNewIndexValue': {
break;
}
default:
throw new Error('unknown event type');
}

View File

@@ -912,6 +912,13 @@ test('secondary keys - lookup by single key', () => {
indices: [['id'], ['title'], ['done']],
});
expect(ds.secondaryIndicesKeys()).toEqual(['id', 'title', 'done']);
expect(ds.getAllIndexValues(['id'])).toEqual([
JSON.stringify({id: 'cookie'}),
JSON.stringify({id: 'coffee'}),
JSON.stringify({id: 'bug'}),
]);
expect(
ds.getAllRecordsByIndex({
title: 'eat a cookie',
@@ -938,6 +945,12 @@ test('secondary keys - lookup by single key', () => {
}),
).toEqual(submitBug);
expect(ds.getAllIndexValues(['id'])).toEqual([
JSON.stringify({id: 'cookie'}),
JSON.stringify({id: 'coffee'}),
JSON.stringify({id: 'bug'}),
]);
ds.delete(0); // eat Cookie
expect(
ds.getAllRecordsByIndex({
@@ -945,6 +958,13 @@ test('secondary keys - lookup by single key', () => {
}),
).toEqual([cookie2]);
// We do not remove empty index values (for now)
expect(ds.getAllIndexValues(['id'])).toEqual([
JSON.stringify({id: 'cookie'}),
JSON.stringify({id: 'coffee'}),
JSON.stringify({id: 'bug'}),
]);
// replace submit Bug
const n = {
id: 'bug',
@@ -972,6 +992,12 @@ test('secondary keys - lookup by single key', () => {
title: 'eat a cookie',
}),
).toEqual([cookie2]);
expect(ds.getAllIndexValues(['id'])).toEqual([
JSON.stringify({id: 'cookie'}),
JSON.stringify({id: 'coffee'}),
JSON.stringify({id: 'bug'}),
]);
});
test('secondary keys - lookup by combined keys', () => {
@@ -983,6 +1009,13 @@ test('secondary keys - lookup by combined keys', () => {
],
});
expect(ds.secondaryIndicesKeys()).toEqual(['id:title', 'done:title']);
expect(ds.getAllIndexValues(['id', 'title'])).toEqual([
JSON.stringify({id: 'cookie', title: 'eat a cookie'}),
JSON.stringify({id: 'coffee', title: 'drink coffee'}),
JSON.stringify({id: 'bug', title: 'submit a bug'}),
]);
expect(
ds.getAllRecordsByIndex({
id: 'cookie',
@@ -1014,6 +1047,13 @@ test('secondary keys - lookup by combined keys', () => {
}),
).toEqual([eatCookie, cookie2]);
expect(ds.getAllIndexValues(['id', 'title'])).toEqual([
JSON.stringify({id: 'cookie', title: 'eat a cookie'}),
JSON.stringify({id: 'coffee', title: 'drink coffee'}),
JSON.stringify({id: 'bug', title: 'submit a bug'}),
JSON.stringify({id: 'cookie2', title: 'eat a cookie'}),
]);
const upsertedCookie = {
id: 'cookie',
title: 'eat a cookie',
@@ -1041,6 +1081,16 @@ test('secondary keys - lookup by combined keys', () => {
}),
).toEqual(undefined);
expect(ds.getAllIndexValues(['id', 'title'])).toEqual([
JSON.stringify({id: 'cookie', title: 'eat a cookie'}),
JSON.stringify({id: 'coffee', title: 'drink coffee'}),
JSON.stringify({id: 'bug', title: 'submit a bug'}),
JSON.stringify({id: 'cookie2', title: 'eat a cookie'}),
]);
const clearSub = jest.fn();
ds.addDataListener('clear', clearSub);
ds.clear();
expect(
ds.getAllRecordsByIndex({
@@ -1049,6 +1099,12 @@ test('secondary keys - lookup by combined keys', () => {
}),
).toEqual([]);
expect(ds.getAllIndexValues(['id', 'title'])).toEqual([]);
expect(clearSub).toBeCalledTimes(1);
const newIndexValueSub = jest.fn();
ds.addDataListener('siNewIndexValue', newIndexValueSub);
ds.append(cookie2);
expect(
ds.getAllRecordsByIndex({
@@ -1056,4 +1112,23 @@ test('secondary keys - lookup by combined keys', () => {
title: 'eat a cookie',
}),
).toEqual([cookie2]);
expect(ds.getAllIndexValues(['id', 'title'])).toEqual([
JSON.stringify({id: 'cookie2', title: 'eat a cookie'}),
]);
// Because we have 2 indecies
expect(newIndexValueSub).toBeCalledTimes(2);
expect(newIndexValueSub).toBeCalledWith({
type: 'siNewIndexValue',
indexKey: JSON.stringify({id: 'cookie2', title: 'eat a cookie'}),
firstOfKind: true,
value: cookie2,
});
expect(newIndexValueSub).toBeCalledWith({
type: 'siNewIndexValue',
indexKey: JSON.stringify({done: 'true', title: 'eat a cookie'}),
firstOfKind: true,
value: cookie2,
});
});

View File

@@ -121,6 +121,7 @@ test('Correct top level API exposed', () => {
"ElementSearchResultSet",
"ElementsInspectorElement",
"ElementsInspectorProps",
"EnumLabels",
"FieldConfig",
"FileDescriptor",
"FileEncoding",

View File

@@ -38,9 +38,9 @@ export {Sidebar as _Sidebar} from './ui/Sidebar';
export {DetailSidebar} from './ui/DetailSidebar';
export {Toolbar} from './ui/Toolbar';
export {MasterDetail} from './ui/MasterDetail';
export {MasterDetail as MasterDetailLegacy} from './ui/MasterDetail';
export {MasterDetailWithPowerSearch as MasterDetail} from './ui/MasterDetailWithPowerSearch';
export {MasterDetailWithPowerSearch as _MasterDetailWithPowerSearch} from './ui/MasterDetailWithPowerSearch';
export {MasterDetail as MasterDetailLegacy} from './ui/MasterDetail';
export {CodeBlock} from './ui/CodeBlock';
export {renderReactRoot, _PortalsManager} from './utils/renderReactRoot';
@@ -59,19 +59,22 @@ export {DataFormatter} from './ui/DataFormatter';
export {useLogger, _LoggerContext} from './utils/useLogger';
export {DataTable, DataTableColumn} from './ui/data-table/DataTable';
export {
DataTable as DataTableLegacy,
DataTableColumn as DataTableColumnLegacy,
} from './ui/data-table/DataTable';
export {DataTableManager} from './ui/data-table/DataTableManager';
export {DataTableManager as DataTableManagerLegacy} from './ui/data-table/DataTableManager';
DataTable,
DataTableColumn,
} from './ui/data-table/DataTableWithPowerSearch';
export {
DataTable as _DataTableWithPowerSearch,
DataTableColumn as _DataTableColumnWithPowerSearch,
} from './ui/data-table/DataTableWithPowerSearch';
export {dataTablePowerSearchOperators} from './ui/data-table/DataTableDefaultPowerSearchOperators';
export {
DataTable as DataTableLegacy,
DataTableColumn as DataTableColumnLegacy,
} from './ui/data-table/DataTable';
export {DataTableManager} from './ui/data-table/DataTableWithPowerSearchManager';
export {DataTableManager as _DataTableWithPowerSearchManager} from './ui/data-table/DataTableWithPowerSearchManager';
export {DataTableManager as DataTableManagerLegacy} from './ui/data-table/DataTableManager';
export {dataTablePowerSearchOperators} from './ui/data-table/DataTableDefaultPowerSearchOperators';
export {DataList} from './ui/DataList';
export {Spinner} from './ui/Spinner';
export * from './ui/PowerSearch';

View File

@@ -213,7 +213,6 @@ export function MasterDetailWithPowerSearch<T extends object>({
<Button
size="small"
type="text"
style={{height: '100%'}}
title={`Click to ${pausedState ? 'resume' : 'pause'} the stream`}
danger={pausedState}
onClick={handleTogglePause}>
@@ -225,8 +224,7 @@ export function MasterDetailWithPowerSearch<T extends object>({
size="small"
type="text"
title="Clear records"
onClick={handleClear}
style={{height: '100%'}}>
onClick={handleClear}>
<DeleteOutlined />
</Button>
)}

View File

@@ -33,7 +33,6 @@ export type StringOperatorConfig = {
valueType: StringFilterValueType;
key: string;
label: string;
handleUnknownValues?: boolean;
};
export type StringSetOperatorConfig = {
@@ -55,11 +54,17 @@ export type FloatOperatorConfig = {
precision?: number;
};
/**
* { value: label }
*/
export type EnumLabels = {[key: string | number]: string | number};
export type EnumOperatorConfig = {
valueType: EnumFilterValueType;
key: string;
label: string;
enumLabels: {[key: string]: string};
enumLabels: EnumLabels;
allowFreeform?: boolean;
};
export type AbsoluteDateOperatorConfig = {

View File

@@ -12,14 +12,16 @@ import {css} from '@emotion/css';
import {theme} from '../theme';
const containerStyle = css`
flex: 1 0 auto;
flex: 1 1 auto;
background-color: ${theme.backgroundDefault};
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
border-radius: ${theme.borderRadius};
border: 1px solid ${theme.borderColor};
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
padding: 0 ${theme.space.tiny}px;
padding: ${theme.space.tiny / 2}px;
&:focus-within,
&:hover {

View File

@@ -9,12 +9,14 @@
import {Select} from 'antd';
import React from 'react';
import {EnumLabels} from './PowerSearchConfig';
type PowerSearchEnumSetTermProps = {
onCancel: () => void;
onChange: (value: string[]) => void;
enumLabels: {[key: string]: string};
enumLabels: EnumLabels;
defaultValue?: string[];
allowFreeform?: boolean;
};
export const PowerSearchEnumSetTerm: React.FC<PowerSearchEnumSetTermProps> = ({
@@ -22,6 +24,7 @@ export const PowerSearchEnumSetTerm: React.FC<PowerSearchEnumSetTermProps> = ({
onChange,
enumLabels,
defaultValue,
allowFreeform,
}) => {
const options = React.useMemo(() => {
return Object.entries(enumLabels).map(([key, label]) => ({
@@ -37,13 +40,14 @@ export const PowerSearchEnumSetTerm: React.FC<PowerSearchEnumSetTermProps> = ({
return (
<Select
mode="multiple"
mode={allowFreeform ? 'tags' : 'multiple'}
autoFocus={!defaultValue}
style={{minWidth: 100}}
placeholder="..."
options={options}
defaultOpen={!defaultValue}
defaultValue={defaultValue}
dropdownMatchSelectWidth={false}
onBlur={() => {
if (!selectValueRef.current?.length) {
onCancel();

View File

@@ -9,12 +9,14 @@
import {Button, Select} from 'antd';
import React from 'react';
import {EnumLabels} from './PowerSearchConfig';
type PowerSearchEnumTermProps = {
onCancel: () => void;
onChange: (value: string) => void;
enumLabels: {[key: string]: string};
enumLabels: EnumLabels;
defaultValue?: string;
allowFreeform?: boolean;
};
export const PowerSearchEnumTerm: React.FC<PowerSearchEnumTermProps> = ({
@@ -22,6 +24,7 @@ export const PowerSearchEnumTerm: React.FC<PowerSearchEnumTermProps> = ({
onChange,
enumLabels,
defaultValue,
allowFreeform,
}) => {
const [editing, setEditing] = React.useState(!defaultValue);
@@ -38,8 +41,8 @@ export const PowerSearchEnumTerm: React.FC<PowerSearchEnumTermProps> = ({
let longestOptionLabelWidth = 0;
Object.values(enumLabels).forEach((label) => {
if (label.length > longestOptionLabelWidth) {
longestOptionLabelWidth = label.length;
if (label.toString().length > longestOptionLabelWidth) {
longestOptionLabelWidth = label.toString().length;
}
});
@@ -71,6 +74,7 @@ export const PowerSearchEnumTerm: React.FC<PowerSearchEnumTermProps> = ({
if (editing) {
return (
<Select
mode={allowFreeform ? 'tags' : undefined}
autoFocus
style={{width}}
placeholder="..."
@@ -99,7 +103,7 @@ export const PowerSearchEnumTerm: React.FC<PowerSearchEnumTermProps> = ({
return (
<Button onClick={() => setEditing(true)}>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{enumLabels[defaultValue!]}
{enumLabels[defaultValue!] ?? defaultValue}
</Button>
);
};

View File

@@ -10,6 +10,7 @@
import {CloseOutlined} from '@ant-design/icons';
import {Button, Space} from 'antd';
import * as React from 'react';
import {theme} from '../theme';
import {PowerSearchAbsoluteDateTerm} from './PowerSearchAbsoluteDateTerm';
import {OperatorConfig} from './PowerSearchConfig';
import {PowerSearchEnumSetTerm} from './PowerSearchEnumSetTerm';
@@ -115,6 +116,7 @@ export const PowerSearchTerm: React.FC<PowerSearchTermProps> = ({
});
}}
enumLabels={searchTerm.operator.enumLabels}
allowFreeform={searchTerm.operator.allowFreeform}
defaultValue={searchTerm.searchValue}
/>
);
@@ -131,6 +133,7 @@ export const PowerSearchTerm: React.FC<PowerSearchTermProps> = ({
});
}}
enumLabels={searchTerm.operator.enumLabels}
allowFreeform={searchTerm.operator.allowFreeform}
defaultValue={searchTerm.searchValue}
/>
);
@@ -166,7 +169,7 @@ export const PowerSearchTerm: React.FC<PowerSearchTermProps> = ({
}
return (
<Space.Compact block size="small">
<Space.Compact size="small" style={{margin: theme.space.tiny / 2}}>
<Button tabIndex={-1} style={{pointerEvents: 'none'}}>
{searchTerm.field.label}
</Button>

View File

@@ -67,6 +67,7 @@ export const PowerSearchTermFinder = React.forwardRef<
setSearchTermFinderValue(null);
}}>
<Input
size="small"
bordered={false}
onKeyUp={(event) => {
if (event.key === 'Enter') {

View File

@@ -8,11 +8,11 @@
*/
import * as React from 'react';
import {Space} from 'antd';
import {
PowerSearchConfig,
FieldConfig,
OperatorConfig,
EnumLabels,
} from './PowerSearchConfig';
import {PowerSearchContainer} from './PowerSearchContainer';
import {
@@ -31,7 +31,13 @@ import {theme} from '../theme';
import {SearchOutlined} from '@ant-design/icons';
import {getFlipperLib} from 'flipper-plugin-core';
export {PowerSearchConfig, OperatorConfig, FieldConfig, SearchExpressionTerm};
export {
PowerSearchConfig,
EnumLabels,
OperatorConfig,
FieldConfig,
SearchExpressionTerm,
};
type PowerSearchProps = {
config: PowerSearchConfig;
@@ -122,44 +128,41 @@ export const PowerSearch: React.FC<PowerSearchProps> = ({
return (
<PowerSearchContainer>
<Space size={[theme.space.tiny, 0]}>
<SearchOutlined
style={{
marginLeft: theme.space.tiny,
marginRight: theme.space.tiny,
color: theme.textColorSecondary,
}}
/>
{searchExpression.map((searchTerm, i) => {
return (
<PowerSearchTerm
key={JSON.stringify(searchTerm)}
searchTerm={searchTerm}
onCancel={() => {
setSearchExpression((prevSearchExpression) => {
if (prevSearchExpression[i]) {
return [
...prevSearchExpression.slice(0, i),
...prevSearchExpression.slice(i + 1),
];
}
return prevSearchExpression;
});
}}
onFinalize={(finalSearchTerm) => {
setSearchExpression((prevSearchExpression) => {
<SearchOutlined
style={{
margin: theme.space.tiny,
color: theme.textColorSecondary,
}}
/>
{searchExpression.map((searchTerm, i) => {
return (
<PowerSearchTerm
key={JSON.stringify(searchTerm)}
searchTerm={searchTerm}
onCancel={() => {
setSearchExpression((prevSearchExpression) => {
if (prevSearchExpression[i]) {
return [
...prevSearchExpression.slice(0, i),
finalSearchTerm,
...prevSearchExpression.slice(i + 1),
];
});
searchTermFinderRef.current?.focus();
}}
/>
);
})}
</Space>
}
return prevSearchExpression;
});
}}
onFinalize={(finalSearchTerm) => {
setSearchExpression((prevSearchExpression) => {
return [
...prevSearchExpression.slice(0, i),
finalSearchTerm,
...prevSearchExpression.slice(i + 1),
];
});
searchTermFinderRef.current?.focus();
}}
/>
);
})}
<PowerSearchTermFinder
ref={searchTermFinderRef}
options={options}

View File

@@ -168,7 +168,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
if (horizontal) {
width = width == null ? 200 : width;
minWidth = (minWidth == null ? 100 : minWidth) + gutterWidth;
maxWidth = maxWidth == null ? 600 : maxWidth;
maxWidth = maxWidth == null ? 1200 : maxWidth;
} else {
height = height == null ? 200 : height;
minHeight = minHeight == null ? 100 : minHeight;

View File

@@ -8,11 +8,10 @@
*/
import dayjs from 'dayjs';
import {getFlipperLib} from 'flipper-plugin-core';
import {OperatorConfig} from '../PowerSearch';
import {
EnumLabels,
FloatOperatorConfig,
StringOperatorConfig,
} from '../PowerSearch/PowerSearchConfig';
export type PowerSearchOperatorProcessor = (
@@ -22,35 +21,41 @@ export type PowerSearchOperatorProcessor = (
) => boolean;
export const dataTablePowerSearchOperators = {
string_contains: (handleUnknownValues?: boolean) => ({
string_matches_regex: () => ({
label: 'matches regex',
key: 'string_matches_regex',
valueType: 'STRING',
}),
string_contains: () => ({
label: 'contains',
key: 'string_contains',
valueType: 'STRING',
handleUnknownValues,
}),
string_not_contains: (handleUnknownValues?: boolean) => ({
string_not_contains: () => ({
label: 'does not contain',
key: 'string_not_contains',
valueType: 'STRING',
handleUnknownValues,
}),
string_matches_exactly: (handleUnknownValues?: boolean) => ({
string_matches_exactly: () => ({
label: 'is',
key: 'string_matches_exactly',
valueType: 'STRING',
handleUnknownValues,
}),
string_not_matches_exactly: (handleUnknownValues?: boolean) => ({
string_not_matches_exactly: () => ({
label: 'is not',
key: 'string_not_matches_exactly',
valueType: 'STRING',
handleUnknownValues,
}),
searializable_object_contains: () => ({
label: 'contains',
key: 'searializable_object_contains',
valueType: 'STRING',
}),
searializable_object_matches_regex: () => ({
label: 'matches regex',
key: 'searializable_object_matches_regex',
valueType: 'STRING',
}),
searializable_object_not_contains: () => ({
label: 'does not contain',
key: 'searializable_object_not_contains',
@@ -118,42 +123,51 @@ export const dataTablePowerSearchOperators = {
valueType: 'FLOAT',
}),
// { [enumValue]: enumLabel }
enum_is: (enumLabels: Record<string, string>) => ({
enum_is: (enumLabels: EnumLabels, allowFreeform?: boolean) => ({
label: 'is',
key: 'enum_is',
valueType: 'ENUM',
enumLabels,
allowFreeform,
}),
enum_is_nullish_or: (enumLabels: Record<string, string>) => ({
enum_is_nullish_or: (enumLabels: EnumLabels, allowFreeform?: boolean) => ({
label: 'is nullish or',
key: 'enum_is_nullish_or',
valueType: 'ENUM',
enumLabels,
allowFreeform,
}),
enum_is_not: (enumLabels: Record<string, string>) => ({
enum_is_not: (enumLabels: EnumLabels, allowFreeform?: boolean) => ({
label: 'is not',
key: 'enum_is_not',
valueType: 'ENUM',
enumLabels,
allowFreeform,
}),
// TODO: Support logical operations (AND, OR, NOT) to combine primitive operators instead of adding new complex operators!
enum_set_is_nullish_or_any_of: (enumLabels: Record<string, string>) => ({
enum_set_is_nullish_or_any_of: (
enumLabels: EnumLabels,
allowFreeform?: boolean,
) => ({
label: 'is nullish or any of',
key: 'enum_set_is_nullish_or_any_of',
valueType: 'ENUM_SET',
enumLabels,
allowFreeform,
}),
enum_set_is_any_of: (enumLabels: Record<string, string>) => ({
enum_set_is_any_of: (enumLabels: EnumLabels, allowFreeform?: boolean) => ({
label: 'is any of',
key: 'enum_set_is_any_of',
valueType: 'ENUM_SET',
enumLabels,
allowFreeform,
}),
enum_set_is_none_of: (enumLabels: Record<string, string>) => ({
enum_set_is_none_of: (enumLabels: EnumLabels, allowFreeform?: boolean) => ({
label: 'is none of',
key: 'enum_set_is_none_of',
valueType: 'ENUM_SET',
enumLabels,
allowFreeform,
}),
is_nullish: () => ({
label: 'is nullish',
@@ -222,25 +236,53 @@ const tryConvertingUnknownToString = (value: unknown): string | null => {
}
};
const regexCache: Record<string, RegExp> = {};
function safeCreateRegExp(source: string): RegExp | undefined {
try {
if (!regexCache[source]) {
regexCache[source] = new RegExp(source);
}
return regexCache[source];
} catch (_e) {
return undefined;
}
}
const enumPredicateForWhenValueCouldBeAStringifiedNullish = (
// searchValue is typed as a string here, but originally it could have been an undefined or a null and we stringified them during inference (search for `inferEnumOptionsFromData`)
searchValue: string,
value: string | null | undefined,
): boolean => {
if (searchValue === value) {
return true;
}
if (value === null && searchValue === 'null') {
return true;
}
if (value === undefined && searchValue === 'undefined') {
return true;
}
return false;
};
export const dataTablePowerSearchOperatorProcessorConfig = {
string_contains: (operator, searchValue: string, value: string) =>
!!(
(operator as StringOperatorConfig).handleUnknownValues &&
getFlipperLib().GK('flipper_power_search_auto_json_stringify')
? tryConvertingUnknownToString(value)
: value
)
string_matches_regex: (_operator, searchValue: string, value: string) =>
!!safeCreateRegExp(searchValue)?.test(
tryConvertingUnknownToString(value) ?? '',
),
string_contains: (_operator, searchValue: string, value: string) =>
!!tryConvertingUnknownToString(value)
?.toLowerCase()
.includes(searchValue.toLowerCase()),
string_not_contains: (operator, searchValue: string, value: string) =>
!(
(operator as StringOperatorConfig).handleUnknownValues &&
getFlipperLib().GK('flipper_power_search_auto_json_stringify')
? tryConvertingUnknownToString(value)
: value
)
string_not_contains: (_operator, searchValue: string, value: string) =>
!tryConvertingUnknownToString(value)
?.toLowerCase()
.includes(searchValue.toLowerCase()),
searializable_object_matches_regex: (
_operator,
searchValue: string,
value: object,
) => !!safeCreateRegExp(searchValue)?.test(JSON.stringify(value)),
searializable_object_contains: (
_operator,
searchValue: string,
@@ -251,16 +293,10 @@ export const dataTablePowerSearchOperatorProcessorConfig = {
searchValue: string,
value: object,
) => !JSON.stringify(value).toLowerCase().includes(searchValue.toLowerCase()),
string_matches_exactly: (operator, searchValue: string, value: string) =>
((operator as StringOperatorConfig).handleUnknownValues &&
getFlipperLib().GK('flipper_power_search_auto_json_stringify')
? tryConvertingUnknownToString(value)
: value) === searchValue,
string_not_matches_exactly: (operator, searchValue: string, value: string) =>
((operator as StringOperatorConfig).handleUnknownValues &&
getFlipperLib().GK('flipper_power_search_auto_json_stringify')
? tryConvertingUnknownToString(value)
: value) !== searchValue,
string_matches_exactly: (_operator, searchValue: string, value: string) =>
tryConvertingUnknownToString(value) === searchValue,
string_not_matches_exactly: (_operator, searchValue: string, value: string) =>
tryConvertingUnknownToString(value) !== searchValue,
// See PowerSearchStringSetTerm
string_set_contains_any_of: (
_operator,
@@ -268,7 +304,9 @@ export const dataTablePowerSearchOperatorProcessorConfig = {
value: string,
) =>
searchValue.some((item) =>
value.toLowerCase().includes(item.toLowerCase()),
tryConvertingUnknownToString(value)
?.toLowerCase()
.includes(item.toLowerCase()),
),
string_set_contains_none_of: (
_operator,
@@ -276,7 +314,9 @@ export const dataTablePowerSearchOperatorProcessorConfig = {
value: string,
) =>
!searchValue.some((item) =>
value.toLowerCase().includes(item.toLowerCase()),
tryConvertingUnknownToString(value)
?.toLowerCase()
.includes(item.toLowerCase()),
),
int_equals: (_operator, searchValue: number, value: number) =>
value === searchValue,
@@ -301,20 +341,29 @@ export const dataTablePowerSearchOperatorProcessorConfig = {
float_less_or_equal: (_operator, searchValue: number, value: number) =>
value <= searchValue,
enum_is: (_operator, searchValue: string, value: string) =>
searchValue === value,
enumPredicateForWhenValueCouldBeAStringifiedNullish(searchValue, value),
enum_is_nullish_or: (_operator, searchValue: string, value?: string | null) =>
value == null || searchValue === value,
value == null ||
enumPredicateForWhenValueCouldBeAStringifiedNullish(searchValue, value),
enum_is_not: (_operator, searchValue: string, value: string) =>
searchValue !== value,
!enumPredicateForWhenValueCouldBeAStringifiedNullish(searchValue, value),
enum_set_is_nullish_or_any_of: (
_operator,
searchValue: string[],
value?: string | null,
) => value == null || searchValue.some((item) => value === item),
) =>
value == null ||
searchValue.some((item) =>
enumPredicateForWhenValueCouldBeAStringifiedNullish(item, value),
),
enum_set_is_any_of: (_operator, searchValue: string[], value: string) =>
searchValue.some((item) => value === item),
searchValue.some((item) =>
enumPredicateForWhenValueCouldBeAStringifiedNullish(item, value),
),
enum_set_is_none_of: (_operator, searchValue: string[], value: string) =>
!searchValue.some((item) => value === item),
!searchValue.some((item) =>
enumPredicateForWhenValueCouldBeAStringifiedNullish(item, value),
),
is_nullish: (_operator, _searchValue, value) => value == null,
// See PowerSearchAbsoluteDateTerm
newer_than_absolute_date: (_operator, searchValue: Date, value: any) => {

View File

@@ -67,6 +67,7 @@ import {
FieldConfig,
OperatorConfig,
SearchExpressionTerm,
EnumLabels,
} from '../PowerSearch';
import {
dataTablePowerSearchOperatorProcessorConfig,
@@ -104,6 +105,10 @@ type DataTableBaseProps<T = any> = {
* @default true
*/
enablePowerSearchWholeRowSearch?: boolean;
/** If set to `true` and row[columnKey] is undefined, then it is going to pass filtering (search).
* @default false
*/
treatUndefinedValuesAsMatchingFiltering?: boolean;
};
const powerSearchConfigEntireRow: FieldConfig = {
@@ -114,6 +119,8 @@ const powerSearchConfigEntireRow: FieldConfig = {
dataTablePowerSearchOperators.searializable_object_contains(),
searializable_object_not_contains:
dataTablePowerSearchOperators.searializable_object_not_contains(),
searializable_object_matches_regex:
dataTablePowerSearchOperators.searializable_object_matches_regex(),
},
useWholeRow: true,
};
@@ -138,6 +145,64 @@ type DataTableInput<T = any> =
dataSource?: undefined;
};
type PowerSearchSimplifiedConfig =
| {
type: 'enum';
enumLabels: EnumLabels;
inferEnumOptionsFromData?: false;
allowFreeform?: boolean;
}
| {
type: 'enum';
enumLabels?: never;
inferEnumOptionsFromData: true;
allowFreeform?: boolean;
}
| {type: 'int'}
| {type: 'float'}
| {type: 'string'}
| {type: 'date'}
| {type: 'dateTime'}
| {type: 'object'};
type PowerSearchExtendedConfig = {
operators: OperatorConfig[];
useWholeRow?: boolean;
/**
* Auto-generate enum options based on the data.
* Requires the column to be set as a secondary "index" (single column, not a compound multi-column index).
* See https://fburl.com/code/0waicx6p
*/
inferEnumOptionsFromData?: boolean;
/**
* Allows freeform entries for enum column types. Makes most sense together with `inferEnumOptionsFromData`.
* If `inferEnumOptionsFromData=true`, then it is `true` by default.
* See use-case https://fburl.com/workplace/0kx6fkhm
*/
allowFreeform?: boolean;
};
const powerSearchConfigIsExtendedConfig = (
powerSearchConfig:
| undefined
| PowerSearchSimplifiedConfig
| OperatorConfig[]
| false
| PowerSearchExtendedConfig,
): powerSearchConfig is PowerSearchExtendedConfig =>
!!powerSearchConfig &&
Array.isArray((powerSearchConfig as PowerSearchExtendedConfig).operators);
const powerSearchConfigIsSimplifiedConfig = (
powerSearchConfig:
| undefined
| PowerSearchSimplifiedConfig
| OperatorConfig[]
| false
| PowerSearchExtendedConfig,
): powerSearchConfig is PowerSearchSimplifiedConfig =>
!!powerSearchConfig &&
typeof (powerSearchConfig as PowerSearchSimplifiedConfig).type === 'string';
export type DataTableColumn<T = any> = {
//this can be a dotted path into a nest objects. e.g foo.bar
key: keyof T & string;
@@ -152,9 +217,10 @@ export type DataTableColumn<T = any> = {
inversed?: boolean;
sortable?: boolean;
powerSearchConfig?:
| PowerSearchSimplifiedConfig
| OperatorConfig[]
| false
| {operators: OperatorConfig[]; useWholeRow?: boolean};
| PowerSearchExtendedConfig;
};
export interface TableRowRenderContext<T = any> {
@@ -263,6 +329,74 @@ export function DataTable<T extends object>(
[columns],
);
// Collecting a hashmap of unique values for every column we infer the power search enum labels for (hashmap of hashmaps).
// It could be a hashmap of sets, but then we would need to convert a set to a hashpmap when rendering enum power search term, so it is just more convenient to make it a hashmap of hashmaps
const [inferredPowerSearchEnumLabels, setInferredPowerSearchEnumLabels] =
React.useState<Record<DataTableColumn['key'], EnumLabels>>({});
React.useEffect(() => {
const columnKeysToInferOptionsFor: string[] = [];
const secondaryIndeciesKeys = new Set(dataSource.secondaryIndicesKeys());
for (const column of columns) {
if (
(powerSearchConfigIsExtendedConfig(column.powerSearchConfig) ||
(powerSearchConfigIsSimplifiedConfig(column.powerSearchConfig) &&
column.powerSearchConfig.type === 'enum')) &&
column.powerSearchConfig.inferEnumOptionsFromData
) {
if (!secondaryIndeciesKeys.has(column.key)) {
console.warn(
'inferEnumOptionsFromData work only if the same column key is specified as a DataSource secondary index! See https://fburl.com/code/0waicx6p. Missing index definition!',
column.key,
);
continue;
}
columnKeysToInferOptionsFor.push(column.key);
}
}
if (columnKeysToInferOptionsFor.length > 0) {
const getInferredLabels = () => {
const newInferredLabels: Record<DataTableColumn['key'], EnumLabels> =
{};
for (const key of columnKeysToInferOptionsFor) {
newInferredLabels[key] = {};
for (const indexValue of dataSource.getAllIndexValues([
key as keyof T,
]) ?? []) {
// `indexValue` is a stringified JSON in a format of { key: value }
const value = Object.values(JSON.parse(indexValue))[0] as string;
newInferredLabels[key][value] = value;
}
}
return newInferredLabels;
};
setInferredPowerSearchEnumLabels(getInferredLabels());
const unsubscribeIndexUpdates = dataSource.addDataListener(
'siNewIndexValue',
({firstOfKind}) => {
if (firstOfKind) {
setInferredPowerSearchEnumLabels(getInferredLabels());
}
},
);
const unsubscribeDataSourceClear = dataSource.addDataListener(
'clear',
() => {
setInferredPowerSearchEnumLabels(getInferredLabels());
},
);
return () => {
unsubscribeIndexUpdates();
unsubscribeDataSourceClear();
};
}
}, [columns, dataSource]);
const powerSearchConfig: PowerSearchConfig = useMemo(() => {
const res: PowerSearchConfig = {fields: {}};
@@ -280,16 +414,128 @@ export function DataTable<T extends object>(
// If no power search config provided we treat every input as a string
if (!column.powerSearchConfig) {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.string_contains(true),
dataTablePowerSearchOperators.string_not_contains(true),
dataTablePowerSearchOperators.string_matches_exactly(true),
dataTablePowerSearchOperators.string_not_matches_exactly(true),
dataTablePowerSearchOperators.string_contains(),
dataTablePowerSearchOperators.string_not_contains(),
dataTablePowerSearchOperators.string_matches_exactly(),
dataTablePowerSearchOperators.string_not_matches_exactly(),
dataTablePowerSearchOperators.string_set_contains_any_of(),
dataTablePowerSearchOperators.string_set_contains_none_of(),
dataTablePowerSearchOperators.string_matches_regex(),
];
} else if (Array.isArray(column.powerSearchConfig)) {
columnPowerSearchOperators = column.powerSearchConfig;
} else {
} else if (powerSearchConfigIsExtendedConfig(column.powerSearchConfig)) {
columnPowerSearchOperators = column.powerSearchConfig.operators;
useWholeRow = !!column.powerSearchConfig.useWholeRow;
const inferredPowerSearchEnumLabelsForColumn =
inferredPowerSearchEnumLabels[column.key];
if (
inferredPowerSearchEnumLabelsForColumn &&
column.powerSearchConfig.inferEnumOptionsFromData
) {
const allowFreeform = column.powerSearchConfig.allowFreeform ?? true;
columnPowerSearchOperators = columnPowerSearchOperators.map(
(operator) => ({
...operator,
enumLabels: inferredPowerSearchEnumLabelsForColumn,
allowFreeform,
}),
);
}
} else {
switch (column.powerSearchConfig.type) {
case 'date': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.same_as_absolute_date_no_time(),
dataTablePowerSearchOperators.older_than_absolute_date_no_time(),
dataTablePowerSearchOperators.newer_than_absolute_date_no_time(),
];
break;
}
case 'dateTime': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.older_than_absolute_date(),
dataTablePowerSearchOperators.newer_than_absolute_date(),
];
break;
}
case 'string': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.string_matches_exactly(),
dataTablePowerSearchOperators.string_not_matches_exactly(),
dataTablePowerSearchOperators.string_set_contains_any_of(),
dataTablePowerSearchOperators.string_set_contains_none_of(),
dataTablePowerSearchOperators.string_matches_regex(),
];
break;
}
case 'int': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.int_equals(),
dataTablePowerSearchOperators.int_greater_or_equal(),
dataTablePowerSearchOperators.int_greater_than(),
dataTablePowerSearchOperators.int_less_or_equal(),
dataTablePowerSearchOperators.int_less_than(),
];
break;
}
case 'float': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.float_equals(),
dataTablePowerSearchOperators.float_greater_or_equal(),
dataTablePowerSearchOperators.float_greater_than(),
dataTablePowerSearchOperators.float_less_or_equal(),
dataTablePowerSearchOperators.float_less_than(),
];
break;
}
case 'enum': {
let enumLabels: EnumLabels;
let allowFreeform = column.powerSearchConfig.allowFreeform;
if (column.powerSearchConfig.inferEnumOptionsFromData) {
enumLabels = inferredPowerSearchEnumLabels[column.key] ?? {};
// Fallback to `true` by default when we use inferred labels
if (allowFreeform === undefined) {
allowFreeform = true;
}
} else {
enumLabels = column.powerSearchConfig.enumLabels;
}
columnPowerSearchOperators = [
dataTablePowerSearchOperators.enum_set_is_any_of(
enumLabels,
allowFreeform,
),
dataTablePowerSearchOperators.enum_set_is_none_of(
enumLabels,
allowFreeform,
),
dataTablePowerSearchOperators.enum_set_is_nullish_or_any_of(
enumLabels,
allowFreeform,
),
];
break;
}
case 'object': {
columnPowerSearchOperators = [
dataTablePowerSearchOperators.searializable_object_contains(),
dataTablePowerSearchOperators.searializable_object_not_contains(),
dataTablePowerSearchOperators.searializable_object_matches_regex(),
];
break;
}
default: {
throw new Error(
`Unknown power search config type ${JSON.stringify(
column.powerSearchConfig,
)}`,
);
}
}
}
const columnFieldConfig: FieldConfig = {
@@ -305,7 +551,11 @@ export function DataTable<T extends object>(
}
return res;
}, [columns, props.enablePowerSearchWholeRowSearch]);
}, [
columns,
props.enablePowerSearchWholeRowSearch,
inferredPowerSearchEnumLabels,
]);
const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
let startIndex = 0;
@@ -461,6 +711,7 @@ export function DataTable<T extends object>(
computeDataTableFilter(
tableState.searchExpression,
dataTablePowerSearchOperatorProcessorConfig,
props.treatUndefinedValuesAsMatchingFiltering,
),
);
dataView.setFilterExpections(
@@ -670,7 +921,7 @@ export function DataTable<T extends object>(
<Layout.Container>
{props.actionsTop ? <Searchbar gap>{props.actionsTop}</Searchbar> : null}
{props.enableSearchbar && (
<Searchbar gap>
<Searchbar grow shrink gap style={{alignItems: 'baseline'}}>
<PowerSearch
config={powerSearchConfig}
searchExpression={searchExpression}
@@ -690,7 +941,7 @@ export function DataTable<T extends object>(
/>
{contexMenu && (
<Dropdown overlay={contexMenu} placement="bottomRight">
<Button type="text" size="small" style={{height: '100%'}}>
<Button type="text" size="small">
<MenuOutlined />
</Button>
</Dropdown>
@@ -819,6 +1070,7 @@ DataTable.defaultProps = {
enablePersistSettings: true,
onRenderEmpty: undefined,
enablePowerSearchWholeRowSearch: true,
treatUndefinedValuesAsMatchingFiltering: false,
} as Partial<DataTableProps<any>>;
/* eslint-disable react-hooks/rules-of-hooks */

View File

@@ -14,7 +14,10 @@ import {DataSourceVirtualizer} from '../../data-source/index';
import produce, {castDraft, immerable, original} from 'immer';
import {DataSource, getFlipperLib, _DataSourceView} from 'flipper-plugin-core';
import {SearchExpressionTerm} from '../PowerSearch';
import {PowerSearchOperatorProcessorConfig} from './DataTableDefaultPowerSearchOperators';
import {
dataTablePowerSearchOperators,
PowerSearchOperatorProcessorConfig,
} from './DataTableDefaultPowerSearchOperators';
import {DataTableManager as DataTableManagerLegacy} from './DataTableManager';
export type OnColumnResize = (id: string, size: number | Percentage) => void;
@@ -38,7 +41,7 @@ const emptySelection: Selection = {
type PersistedState = {
/** Active search value */
searchExpression?: SearchExpressionTerm[];
searchExpression: SearchExpressionTerm[];
/** current selection, describes the index index in the datasources's current output (not window!) */
selection: {current: number; items: number[]};
/** The currently applicable sorting, if any */
@@ -87,6 +90,7 @@ type DataManagerActions<T> =
}
>
| Action<'clearSelection', {}>
| Action<'setSearchExpressionFromSelection', {column: DataTableColumn<T>}>
| Action<'setFilterExceptions', {exceptions: string[] | undefined}>
| Action<'appliedInitialScroll'>
| Action<'toggleAutoScroll'>
@@ -116,7 +120,7 @@ export type DataManagerState<T> = {
sorting: Sorting<T> | undefined;
selection: Selection;
autoScroll: boolean;
searchExpression?: SearchExpressionTerm[];
searchExpression: SearchExpressionTerm[];
filterExceptions: string[] | undefined;
sideBySide: boolean;
};
@@ -136,13 +140,13 @@ export const dataTableManagerReducer = produce<
case 'reset': {
draft.columns = computeInitialColumns(config.defaultColumns);
draft.sorting = undefined;
draft.searchExpression = undefined;
draft.searchExpression = [];
draft.selection = castDraft(emptySelection);
draft.filterExceptions = undefined;
break;
}
case 'resetFilters': {
draft.searchExpression = undefined;
draft.searchExpression = [];
draft.filterExceptions = undefined;
break;
}
@@ -169,7 +173,35 @@ export const dataTableManagerReducer = produce<
}
case 'setSearchExpression': {
getFlipperLib().logger.track('usage', 'data-table:filter:power-search');
draft.searchExpression = action.searchExpression;
draft.searchExpression = action.searchExpression ?? [];
draft.filterExceptions = undefined;
break;
}
case 'setSearchExpressionFromSelection': {
getFlipperLib().logger.track(
'usage',
'data-table:filter:power-search-from-selection',
);
draft.filterExceptions = undefined;
const items = getSelectedItems(
config.dataView as _DataSourceView<any, any>,
draft.selection,
);
const searchExpressionFromSelection: SearchExpressionTerm[] = [
{
field: {
key: action.column.key,
label: action.column.title ?? action.column.key,
},
operator: dataTablePowerSearchOperators.enum_set_is_any_of({}),
searchValue: items.map((item) =>
getValueAtPath(item, action.column.key),
),
},
];
draft.searchExpression = searchExpressionFromSelection;
draft.filterExceptions = undefined;
break;
}
@@ -374,7 +406,7 @@ export function createInitialState<T>(
});
}
let searchExpression = config.initialSearchExpression;
let searchExpression = config.initialSearchExpression ?? [];
if (prefs?.searchExpression?.length) {
searchExpression = prefs.searchExpression;
}
@@ -506,25 +538,18 @@ export function getValueAtPath(obj: Record<string, any>, keyPath: string): any {
}
export function computeDataTableFilter(
searchExpression: SearchExpressionTerm[] | undefined,
searchExpression: SearchExpressionTerm[],
powerSearchProcessors: PowerSearchOperatorProcessorConfig,
treatUndefinedValuesAsMatchingFiltering: boolean = false,
) {
return function dataTableFilter(item: any) {
if (!searchExpression || !searchExpression.length) {
if (!searchExpression.length) {
return true;
}
return searchExpression.every((searchTerm) => {
const value = searchTerm.field.useWholeRow
? item
: getValueAtPath(item, searchTerm.field.key);
if (!value) {
console.warn(
'computeDataTableFilter -> value at searchTerm.field.key is not recognized',
searchTerm,
item,
);
return true;
}
const processor =
powerSearchProcessors[
@@ -539,7 +564,21 @@ export function computeDataTableFilter(
return true;
}
return processor(searchTerm.operator, searchTerm.searchValue, value);
try {
const res = processor(
searchTerm.operator,
searchTerm.searchValue,
value,
);
if (!res && !value) {
return treatUndefinedValuesAsMatchingFiltering;
}
return res;
} catch {
return treatUndefinedValuesAsMatchingFiltering;
}
});
};
}

View File

@@ -15,7 +15,7 @@ import {
getSelectedItems,
getValueAtPath,
Selection,
} from './DataTableManager';
} from './DataTableWithPowerSearchManager';
import React from 'react';
import {
_tryGetFlipperLibImplementation,
@@ -65,8 +65,8 @@ export function tableContextMenuFactory<T extends object>(
key={column.key ?? idx}
onClick={() => {
dispatch({
type: 'setColumnFilterFromSelection',
column: column.key,
type: 'setSearchExpressionFromSelection',
column,
});
}}>
{friendlyColumnTitle(column)}

View File

@@ -14,6 +14,7 @@ import {
FlipperServerCommands,
FlipperServerExecOptions,
ServerWebSocketMessage,
FlipperServerDisconnectedError,
} from 'flipper-common';
import ReconnectingWebSocket from 'reconnecting-websocket';
@@ -30,11 +31,11 @@ export type {FlipperServer, FlipperServerCommands, FlipperServerExecOptions};
export function createFlipperServer(
host: string,
port: number,
tokenProvider: () => Promise<string | null | undefined>,
tokenProvider: () => string | null | undefined,
onStateChange: (state: FlipperServerState) => void,
): Promise<FlipperServer> {
const URLProvider = async () => {
const token = await tokenProvider();
const URLProvider = () => {
const token = tokenProvider();
return `ws://${host}:${port}?token=${token}`;
};
@@ -90,7 +91,7 @@ export function createFlipperServerWithSocket(
onStateChange(FlipperServerState.DISCONNECTED);
pendingRequests.forEach((r) =>
r.reject(new Error('flipper-server disconnected')),
r.reject(new FlipperServerDisconnectedError('ws-close')),
);
pendingRequests.clear();
});

View File

@@ -59,6 +59,8 @@ import {DebuggableDevice} from './devices/DebuggableDevice';
import {jfUpload} from './fb-stubs/jf';
import path from 'path';
import {movePWA} from './utils/findInstallation';
import GK from './fb-stubs/GK';
import {fetchNewVersion} from './fb-stubs/fetchNewVersion';
const {access, copyFile, mkdir, unlink, stat, readlink, readFile, writeFile} =
promises;
@@ -75,10 +77,8 @@ function setProcessState(settings: Settings) {
const androidHome = settings.androidHome;
const idbPath = settings.idbPath;
if (!process.env.ANDROID_HOME && !process.env.ANDROID_SDK_ROOT) {
process.env.ANDROID_HOME = androidHome;
process.env.ANDROID_SDK_ROOT = androidHome;
}
process.env.ANDROID_HOME = androidHome;
process.env.ANDROID_SDK_ROOT = androidHome;
// emulator/emulator is more reliable than tools/emulator, so prefer it if
// it exists
@@ -111,6 +111,7 @@ export class FlipperServerImpl implements FlipperServer {
keytarManager: KeytarManager;
pluginManager: PluginManager;
unresponsiveClients: Set<string> = new Set();
private acceptingNewConections = true;
constructor(
public config: FlipperServerConfig,
@@ -118,9 +119,7 @@ export class FlipperServerImpl implements FlipperServer {
keytarModule?: KeytarModule,
) {
setFlipperServerConfig(config);
console.log(
'Loaded flipper config, paths: ' + JSON.stringify(config.paths, null, 2),
);
console.info('Loaded flipper config: ' + JSON.stringify(config, null, 2));
setProcessState(config.settings);
const server = (this.server = new ServerController(this));
@@ -179,6 +178,35 @@ export class FlipperServerImpl implements FlipperServer {
);
}
startAcceptingNewConections() {
if (!GK.get('flipper_disconnect_device_when_ui_offline')) {
return;
}
if (this.acceptingNewConections) {
return;
}
this.acceptingNewConections = true;
this.server.insecureServer?.startAcceptingNewConections();
this.server.altInsecureServer?.startAcceptingNewConections();
this.server.secureServer?.startAcceptingNewConections();
this.server.altSecureServer?.startAcceptingNewConections();
this.server.browserServer?.startAcceptingNewConections();
}
stopAcceptingNewConections() {
if (!GK.get('flipper_disconnect_device_when_ui_offline')) {
return;
}
this.acceptingNewConections = false;
this.server.insecureServer?.stopAcceptingNewConections();
this.server.altInsecureServer?.stopAcceptingNewConections();
this.server.secureServer?.stopAcceptingNewConections();
this.server.altSecureServer?.stopAcceptingNewConections();
this.server.browserServer?.stopAcceptingNewConections();
}
setServerState(state: FlipperServerState, error?: Error) {
this.state = state;
this.stateError = '' + error;
@@ -369,6 +397,9 @@ export class FlipperServerImpl implements FlipperServer {
'device-install-app': async (serial, bundlePath) => {
return this.devices.get(serial)?.installApp(bundlePath);
},
'device-open-app': async (serial, name) => {
return this.devices.get(serial)?.openApp(name);
},
'get-server-state': async () => ({
state: this.state,
error: this.stateError,
@@ -585,6 +616,7 @@ export class FlipperServerImpl implements FlipperServer {
return uploadRes;
},
shutdown: async () => {
// Do not use processExit helper. We want to server immediatelly quit when this call is triggerred
process.exit(0);
},
'is-logged-in': async () => {
@@ -601,6 +633,7 @@ export class FlipperServerImpl implements FlipperServer {
'move-pwa': async () => {
await movePWA();
},
'fetch-new-version': fetchNewVersion,
};
registerDevice(device: ServerDevice) {

View File

@@ -148,6 +148,10 @@ class BrowserServerWebSocket extends SecureServerWebSocket {
protected verifyClient(): ws.VerifyClientCallbackSync {
return (info: {origin: string; req: IncomingMessage; secure: boolean}) => {
if (!this.acceptingNewConections) {
return false;
}
if (isFBBuild) {
try {
const urlObj = new URL(info.origin);

View File

@@ -293,6 +293,11 @@ class ServerRSocket extends ServerWebSocketBase {
},
};
};
protected stopAcceptingNewConectionsImpl(): void {
// Did not find a straightforard way to iterate through RSocket open connections and close them.
// We probably should not care and invest in it anyway as we are going to remove RScokets.
}
}
export default ServerRSocket;

View File

@@ -294,11 +294,20 @@ class ServerWebSocket extends ServerWebSocketBase {
*/
protected verifyClient(): VerifyClientCallbackSync {
return (_info: {origin: string; req: IncomingMessage; secure: boolean}) => {
if (!this.acceptingNewConections) {
return false;
}
// Client verification is not necessary. The connected client has
// already been verified using its certificate signed by the server.
return true;
};
}
protected stopAcceptingNewConectionsImpl(): void {
this.wsServer?.clients.forEach((client) =>
client.close(WSCloseCode.GoingAway),
);
}
}
export default ServerWebSocket;

View File

@@ -15,6 +15,7 @@ import {
SignCertificateMessage,
} from 'flipper-common';
import {SecureServerConfig} from './certificate-exchange/certificate-utils';
import GK from '../fb-stubs/GK';
/**
* Defines an interface for events triggered by a running server interacting
@@ -98,6 +99,8 @@ export interface ServerEventsListener {
* RSocket, WebSocket, etc.
*/
abstract class ServerWebSocketBase {
protected acceptingNewConections = true;
constructor(protected listener: ServerEventsListener) {}
/**
@@ -169,6 +172,23 @@ abstract class ServerWebSocketBase {
return undefined;
}
startAcceptingNewConections() {
if (!GK.get('flipper_disconnect_device_when_ui_offline')) {
return;
}
this.acceptingNewConections = true;
}
stopAcceptingNewConections() {
if (!GK.get('flipper_disconnect_device_when_ui_offline')) {
return;
}
this.acceptingNewConections = false;
this.stopAcceptingNewConectionsImpl();
}
protected abstract stopAcceptingNewConectionsImpl(): void;
}
export default ServerWebSocketBase;

View File

@@ -16,11 +16,10 @@ import {
} from './openssl-wrapper-with-promises';
import path from 'path';
import tmp, {FileOptions} from 'tmp';
import {FlipperServerConfig, reportPlatformFailures} from 'flipper-common';
import {reportPlatformFailures} from 'flipper-common';
import {isTest} from 'flipper-common';
import {flipperDataFolder} from '../../utils/paths';
import * as jwt from 'jsonwebtoken';
import {getFlipperServerConfig} from '../../FlipperServerConfig';
import {Mutex} from 'async-mutex';
import {createSecureContext} from 'tls';
@@ -288,45 +287,6 @@ const writeToTempFile = async (content: string): Promise<string> => {
await fs.writeFile(path, content);
return path;
};
const manifestFilename = 'manifest.json';
const getManifestPath = (config: FlipperServerConfig): string => {
return path.resolve(config.paths.staticPath, manifestFilename);
};
const exportTokenToManifest = async (token: string) => {
console.info('Export token to manifest');
let config: FlipperServerConfig | undefined;
try {
config = getFlipperServerConfig();
} catch {
console.warn(
'Unable to obtain server configuration whilst exporting token to manifest',
);
}
if (!config || !config.environmentInfo.isHeadlessBuild) {
return;
}
const manifestPath = getManifestPath(config);
try {
const manifestData = await fs.readFile(manifestPath, {
encoding: 'utf-8',
});
const manifest = JSON.parse(manifestData);
manifest.token = token;
const newManifestData = JSON.stringify(manifest, null, 4);
await fs.writeFile(manifestPath, newManifestData);
} catch (e) {
console.error(
'Unable to export authentication token to manifest, may be non existent.',
);
}
};
export const generateAuthToken = async () => {
console.info('Generate client authentication token');
@@ -340,8 +300,6 @@ export const generateAuthToken = async () => {
await fs.writeFile(serverAuthToken, token);
await exportTokenToManifest(token);
return token;
};
@@ -375,8 +333,6 @@ export const getAuthToken = async (): Promise<string> => {
return generateAuthToken();
}
await exportTokenToManifest(token);
return token;
};

View File

@@ -82,4 +82,8 @@ export abstract class ServerDevice {
async installApp(_appBundlePath: string): Promise<void> {
throw new Error('installApp not implemented');
}
async openApp(_name: string): Promise<void> {
throw new Error('openApp not implemented');
}
}

View File

@@ -63,6 +63,7 @@ export interface IOSBridge {
ipaPath: string,
tempPath: string,
) => Promise<void>;
openApp: (serial: string, name: string) => Promise<void>;
getInstalledApps: (serial: string) => Promise<IOSInstalledAppDescriptor[]>;
ls: (serial: string, appBundleId: string, path: string) => Promise<string[]>;
pull: (
@@ -149,6 +150,11 @@ export class IDBBridge implements IOSBridge {
await this._execIdb(`install ${ipaPath} --udid ${serial}`);
}
async openApp(serial: string, name: string): Promise<void> {
console.log(`Opening app via IDB ${name} ${serial}`);
await this._execIdb(`launch ${name} --udid ${serial} -f`);
}
async getActiveDevices(bootedOnly: boolean): Promise<DeviceTarget[]> {
return iosUtil
.targets(this.idbPath, this.enablePhysicalDevices, bootedOnly)
@@ -217,6 +223,10 @@ export class SimctlBridge implements IOSBridge {
);
}
async openApp(): Promise<void> {
throw new Error('openApp is not implemented for SimctlBridge');
}
async installApp(
serial: string,
ipaPath: string,

View File

@@ -140,6 +140,10 @@ export default class IOSDevice
);
}
async openApp(name: string): Promise<void> {
return this.iOSBridge.openApp(this.serial, name);
}
async readFlipperFolderForAllApps(): Promise<DeviceDebugData[]> {
console.debug('IOSDevice.readFlipperFolderForAllApps', this.info.serial);
const installedApps = await this.iOSBridge.getInstalledApps(

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 const fetchNewVersion = async (): Promise<void> => {};

View File

@@ -13,13 +13,14 @@ export * from './tracker';
export {loadLauncherSettings} from './utils/launcherSettings';
export {loadProcessConfig} from './utils/processConfig';
export {getEnvironmentInfo} from './utils/environmentInfo';
export {findInstallation} from './utils/findInstallation';
export {processExit, setProcessExitRoutine} from './utils/processExit';
export {getGatekeepers} from './gk';
export {setupPrefetcher} from './fb-stubs/Prefetcher';
export * from './server/attachSocketServer';
export * from './server/startFlipperServer';
export * from './server/startServer';
export * from './server/utilities';
export * from './utils/openUI';
export {isFBBuild} from './fb-stubs/constants';
export {initializeLogger} from './fb-stubs/Logger';

View File

@@ -26,9 +26,10 @@ import {
FlipperServerCompanionEnv,
} from 'flipper-server-companion';
import {URLSearchParams} from 'url';
import {getFlipperServerConfig} from '../FlipperServerConfig';
import {tracker} from '../tracker';
import {getFlipperServerConfig} from '../FlipperServerConfig';
import {performance} from 'perf_hooks';
import {processExit} from '../utils/processExit';
const safe = (f: () => void) => {
try {
@@ -41,6 +42,7 @@ const safe = (f: () => void) => {
};
let numberOfConnectedClients = 0;
let disconnectTimeout: NodeJS.Timeout | undefined;
/**
* Attach and handle incoming messages from clients.
@@ -52,15 +54,9 @@ export function attachSocketServer(
server: FlipperServerImpl,
companionEnv: FlipperServerCompanionEnv,
) {
const t0 = performance.now();
const browserConnectionTimeout = setTimeout(() => {
tracker.track('browser-connection-created', {
successful: false,
timeMS: performance.now() - t0,
});
}, 20000);
socket.on('connection', (client, req) => {
const t0 = performance.now();
const clientAddress =
(req.socket.remoteAddress &&
` ${req.socket.remoteAddress}:${req.socket.remotePort}`) ||
@@ -69,13 +65,14 @@ export function attachSocketServer(
console.log('Client connected', clientAddress);
numberOfConnectedClients++;
clearTimeout(browserConnectionTimeout);
tracker.track('browser-connection-created', {
successful: true,
timeMS: performance.now() - t0,
});
if (disconnectTimeout) {
clearTimeout(disconnectTimeout);
}
server.emit('browser-connection-created', {});
let connected = true;
server.startAcceptingNewConections();
let flipperServerCompanion: FlipperServerCompanion | undefined;
if (req.url) {
@@ -242,7 +239,7 @@ export function attachSocketServer(
safe(() => onClientMessage(data));
});
async function onClientClose(closeOnIdle: boolean) {
async function onClientClose(code?: number, error?: string) {
console.log(`Client disconnected ${clientAddress}`);
numberOfConnectedClients--;
@@ -251,29 +248,39 @@ export function attachSocketServer(
server.offAny(onServerEvent);
flipperServerCompanion?.destroyAll();
tracker.track('server-client-close', {
code,
error,
sessionLength: performance.now() - t0,
});
if (numberOfConnectedClients === 0) {
server.stopAcceptingNewConections();
}
if (
getFlipperServerConfig().environmentInfo.isHeadlessBuild &&
closeOnIdle
isProduction()
) {
if (numberOfConnectedClients === 0 && isProduction()) {
console.info('Shutdown as no clients are currently connected');
process.exit(0);
const FIVE_HOURS = 5 * 60 * 60 * 1000;
if (disconnectTimeout) {
clearTimeout(disconnectTimeout);
}
disconnectTimeout = setTimeout(() => {
if (numberOfConnectedClients === 0) {
console.info(
'[flipper-server] Shutdown as no clients are currently connected',
);
processExit(0);
}
}, FIVE_HOURS);
}
}
client.on('close', (code, _reason) => {
console.info('[flipper-server] Client close with code', code);
/**
* The socket will close as the endpoint is terminating
* the connection. Status code 1000 and 1001 are used for normal
* closures. Either the connection is no longer needed or the
* endpoint is going away i.e. browser navigating away from the
* current page.
* WS RFC: https://www.rfc-editor.org/rfc/rfc6455
*/
const closeOnIdle = code === 1000 || code === 1001;
safe(() => onClientClose(closeOnIdle));
safe(() => onClientClose(code));
});
client.on('error', (error) => {
@@ -283,7 +290,7 @@ export function attachSocketServer(
* do not close on idle as there's a high probability the
* client will attempt to connect again.
*/
onClientClose(false);
onClientClose(undefined, error.message);
console.error('Client disconnected with error', error);
});
});

View File

@@ -7,7 +7,7 @@
* @format
*/
import express, {Express} from 'express';
import express, {Express, RequestHandler} from 'express';
import http from 'http';
import path from 'path';
import fs from 'fs-extra';
@@ -18,11 +18,18 @@ import exitHook from 'exit-hook';
import {attachSocketServer} from './attachSocketServer';
import {FlipperServerImpl} from '../FlipperServerImpl';
import {FlipperServerCompanionEnv} from 'flipper-server-companion';
import {validateAuthToken} from '../app-connectivity/certificate-exchange/certificate-utils';
import {
getAuthToken,
validateAuthToken,
} from '../app-connectivity/certificate-exchange/certificate-utils';
import {tracker} from '../tracker';
import {EnvironmentInfo, isProduction} from 'flipper-common';
import {GRAPH_SECRET} from '../fb-stubs/constants';
import {sessionId} from '../sessionId';
import {UIPreference, openUI} from '../utils/openUI';
import {processExit} from '../utils/processExit';
import util from 'node:util';
type Config = {
port: number;
@@ -37,6 +44,7 @@ type ReadyForConnections = (
const verifyAuthToken = (req: http.IncomingMessage): boolean => {
let token: string | null = null;
if (req.url) {
const url = new URL(req.url, `http://${req.headers.host}`);
token = url.searchParams.get('token');
@@ -46,6 +54,10 @@ const verifyAuthToken = (req: http.IncomingMessage): boolean => {
token = req.headers['x-access-token'] as string;
}
if (!isProduction()) {
console.info('[conn] verifyAuthToken -> token', token);
}
if (!token) {
console.warn('[conn] A token is required for authentication');
tracker.track('server-auth-token-verification', {
@@ -114,7 +126,7 @@ export async function startServer(
console.error(
`[flipper-server] Unable to become ready within ${timeoutSeconds} seconds, exit`,
);
process.exit(1);
processExit(1);
}
}, timeoutSeconds * 1000);
@@ -145,20 +157,34 @@ async function startHTTPServer(
next();
});
app.get('/', (_req, res) => {
const serveRoot: RequestHandler = async (_req, res) => {
const resource = isReady
? path.join(config.staticPath, config.entry)
: path.join(config.staticPath, 'loading.html');
const token = await getAuthToken();
const flipperConfig = {
theme: 'light',
entryPoint: isProduction()
? 'bundle.js'
: 'flipper-ui-browser/src/index-fast-refresh.bundle?platform=web&dev=true&minify=false',
debug: !isProduction(),
graphSecret: GRAPH_SECRET,
appVersion: environmentInfo.appVersion,
sessionId: sessionId,
unixname: environmentInfo.os.unixname,
authToken: token,
};
fs.readFile(resource, (_err, content) => {
const processedContent = content
.toString()
.replace('GRAPH_SECRET_REPLACE_ME', GRAPH_SECRET)
.replace('FLIPPER_APP_VERSION_REPLACE_ME', environmentInfo.appVersion)
.replace('FLIPPER_UNIXNAME_REPLACE_ME', environmentInfo.os.unixname)
.replace('FLIPPER_SESSION_ID_REPLACE_ME', sessionId);
.replace('FLIPPER_CONFIG_PLACEHOLDER', util.inspect(flipperConfig));
res.end(processedContent);
});
});
};
app.get('/', serveRoot);
app.get('/index.web.html', serveRoot);
app.get('/ready', (_req, res) => {
tracker.track('server-endpoint-hit', {name: 'ready'});
@@ -178,6 +204,7 @@ async function startHTTPServer(
res.json({success: true});
// Just exit the process, this will trigger the shutdown hooks.
// Do not use prcoessExit util as we want the serve to shutdown immediately
process.exit(0);
});
@@ -186,6 +213,13 @@ async function startHTTPServer(
res.end('flipper-ok');
});
app.get('/open-ui', (_req, res) => {
tracker.track('server-endpoint-hit', {name: 'open-ui'});
const preference = isProduction() ? UIPreference.PWA : UIPreference.Browser;
openUI(preference, config.port);
res.json({success: true});
});
app.use(express.static(config.staticPath));
const server = http.createServer(app);
@@ -205,7 +239,7 @@ async function startHTTPServer(
`[flipper-server] Unable to listen at port: ${config.port}, is already in use`,
);
tracker.track('server-socket-already-in-use', {});
process.exit(1);
processExit(1);
}
});

View File

@@ -50,7 +50,9 @@ export async function checkServerRunning(
port: number,
): Promise<string | undefined> {
try {
const response = await fetch(`http://localhost:${port}/info`);
const response = await fetch(`http://localhost:${port}/info`, {
timeout: 1000,
});
if (response.status >= 200 && response.status < 300) {
const environmentInfo: EnvironmentInfo = await response.json();
return environmentInfo.appVersion;
@@ -74,7 +76,9 @@ export async function checkServerRunning(
*/
export async function shutdownRunningInstance(port: number): Promise<boolean> {
try {
const response = await fetch(`http://localhost:${port}/shutdown`);
const response = await fetch(`http://localhost:${port}/shutdown`, {
timeout: 1000,
});
if (response.status >= 200 && response.status < 300) {
const json = await response.json();
console.info(

View File

@@ -9,4 +9,9 @@
import {uuid} from 'flipper-common';
export const sessionId = uuid();
if (process.env.FLIPPER_SESSION_ID) {
console.info('Use external session ID', process.env.FLIPPER_SESSION_ID);
}
export const sessionId = `${
process.env.FLIPPER_SESSION_ID ?? 'unset'
}::${uuid()}`;

View File

@@ -48,11 +48,13 @@ type TrackerEvents = {
};
'server-socket-already-in-use': {};
'server-open-ui': {browser: boolean; hasToken: boolean};
'server-client-close': {code?: number; error?: string; sessionLength: number};
'server-ws-server-error': {port: number; error: string};
'server-ready-timeout': {timeout: number};
'browser-connection-created': {
successful: boolean;
timeMS: number;
timedOut: boolean;
};
'app-connection-created': AppConnectionPayload;
'app-connection-secure-attempt': AppConnectionPayload;

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 open from 'open';
import {getAuthToken} from '../app-connectivity/certificate-exchange/certificate-utils';
import {findInstallation} from './findInstallation';
import {tracker} from '../tracker';
export enum UIPreference {
Browser,
PWA,
}
export async function openUI(preference: UIPreference, port: number) {
console.info('[flipper-server] Launch UI');
const token = await getAuthToken();
console.info(
`[flipper-server] Get authentication token: ${token?.length != 0}`,
);
const openInBrowser = async () => {
console.info('[flipper-server] Open in browser');
const url = new URL(`http://localhost:${port}`);
console.info(`[flipper-server] Go to: ${url.toString()}`);
open(url.toString(), {app: {name: open.apps.chrome}});
tracker.track('server-open-ui', {
browser: true,
hasToken: token?.length != 0,
});
};
if (preference === UIPreference.Browser) {
await openInBrowser();
} else {
const path = await findInstallation();
if (path) {
console.info('[flipper-server] Open in PWA. Location:', path);
tracker.track('server-open-ui', {
browser: false,
hasToken: token?.length != 0,
});
open(path);
} else {
await openInBrowser();
}
}
console.info('[flipper-server] Launch UI completed');
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
const onBeforeExitFns: (() => void | Promise<void>)[] = [];
export const setProcessExitRoutine = (
onBeforeExit: () => void | Promise<void>,
) => {
onBeforeExitFns.push(onBeforeExit);
};
const resIsPromise = (res: void | Promise<void>): res is Promise<void> =>
res instanceof Promise;
export const processExit = async (code: number) => {
console.debug('processExit', code);
setTimeout(() => {
console.error('Process exit routines timed out');
process.exit(code);
}, 5000);
// eslint-disable-next-line promise/catch-or-return
await Promise.all(
onBeforeExitFns.map(async (fn) => {
try {
const res = fn();
if (resIsPromise(res)) {
return res.catch((e) => {
console.error('Process exit routine failed', e);
});
}
} catch (e) {
console.error('Process exit routine failed', e);
}
}),
).finally(() => {
process.exit(code);
});
};

View File

@@ -8,6 +8,7 @@
*/
import os from 'os';
import fs from 'fs-extra';
import {resolve} from 'path';
import {Settings, Tristate} from 'flipper-common';
import {readFile, writeFile, pathExists, mkdirp} from 'fs-extra';
@@ -18,7 +19,7 @@ export async function loadSettings(
): Promise<Settings> {
if (settingsString !== '') {
try {
return replaceDefaultSettings(JSON.parse(settingsString));
return await replaceDefaultSettings(JSON.parse(settingsString));
} catch (e) {
throw new Error("couldn't read the user settingsString");
}
@@ -48,9 +49,9 @@ function getSettingsFile() {
export const DEFAULT_ANDROID_SDK_PATH = getDefaultAndroidSdkPath();
function getDefaultSettings(): Settings {
async function getDefaultSettings(): Promise<Settings> {
return {
androidHome: getDefaultAndroidSdkPath(),
androidHome: await getDefaultAndroidSdkPath(),
enableAndroid: true,
enableIOS: os.platform() === 'darwin',
enablePhysicalIOS: os.platform() === 'darwin',
@@ -76,14 +77,24 @@ function getDefaultSettings(): Settings {
};
}
function getDefaultAndroidSdkPath() {
return os.platform() === 'win32' ? getWindowsSdkPath() : '/opt/android_sdk';
async function getDefaultAndroidSdkPath() {
if (os.platform() === 'win32') {
return `${os.homedir()}\\AppData\\Local\\android\\sdk`;
}
// non windows platforms
// created when created a project in Android Studio
const androidStudioSdkPath = `${os.homedir()}/Library/Android/sdk`;
if (await fs.exists(androidStudioSdkPath)) {
return androidStudioSdkPath;
}
return '/opt/android_sdk';
}
function getWindowsSdkPath() {
return `${os.homedir()}\\AppData\\Local\\android\\sdk`;
}
function replaceDefaultSettings(userSettings: Partial<Settings>): Settings {
return {...getDefaultSettings(), ...userSettings};
async function replaceDefaultSettings(
userSettings: Partial<Settings>,
): Promise<Settings> {
return {...(await getDefaultSettings()), ...userSettings};
}

View File

@@ -16,22 +16,23 @@ import {attachDevServer} from './attachDevServer';
import {initializeLogger} from './logger';
import fs from 'fs-extra';
import yargs from 'yargs';
import open from 'open';
import os from 'os';
import {initCompanionEnv} from 'flipper-server-companion';
import {
UIPreference,
checkPortInUse,
checkServerRunning,
compareServerVersion,
getEnvironmentInfo,
openUI,
setupPrefetcher,
shutdownRunningInstance,
startFlipperServer,
startServer,
tracker,
processExit,
} from 'flipper-server-core';
import {addLogTailer, isTest, LoggerFormat} from 'flipper-common';
import exitHook from 'exit-hook';
import {getAuthToken, findInstallation} from 'flipper-server-core';
const argv = yargs
.usage('yarn flipper-server [args]')
@@ -95,9 +96,30 @@ const rootPath = argv.bundler
: path.resolve(__dirname, '..'); // In pre-packaged versions of the server, static is copied inside the package.
const staticPath = path.join(rootPath, 'static');
async function start() {
const t0 = performance.now();
const t0 = performance.now();
const browserConnectionTimeout = setTimeout(() => {
tracker.track('browser-connection-created', {
successful: false,
timeMS: performance.now() - t0,
timedOut: true,
});
}, 10000);
let reported = false;
const reportBrowserConnection = (successful: boolean) => {
if (reported) {
return;
}
clearTimeout(browserConnectionTimeout);
reported = true;
tracker.track('browser-connection-created', {
successful,
timeMS: performance.now() - t0,
timedOut: false,
});
};
async function start() {
const isProduction =
process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test';
const environmentInfo = await getEnvironmentInfo(
@@ -143,30 +165,24 @@ async function start() {
`[flipper-server][bootstrap] Keytar loaded (${keytarLoadedMS} ms)`,
);
let launchAndFinish = false;
console.info('[flipper-server] Check for running instances');
const existingRunningInstanceVersion = await checkServerRunning(argv.port);
if (existingRunningInstanceVersion) {
console.info(
`[flipper-server] Running instance found with version: ${existingRunningInstanceVersion}, current version: ${environmentInfo.appVersion}`,
);
if (
compareServerVersion(
environmentInfo.appVersion,
existingRunningInstanceVersion,
) > 0
) {
console.info(`[flipper-server] Shutdown running instance`);
await shutdownRunningInstance(argv.port);
} else {
launchAndFinish = true;
}
console.info(`[flipper-server] Shutdown running instance`);
const success = await shutdownRunningInstance(argv.port);
console.info(
`[flipper-server] Shutdown running instance acknowledged: ${success}`,
);
} else {
console.info('[flipper-server] Checking if port is in use (TCP)');
if (await checkPortInUse(argv.port)) {
console.info(`[flipper-server] Shutdown running instance`);
await shutdownRunningInstance(argv.port);
const success = await shutdownRunningInstance(argv.port);
console.info(
`[flipper-server] Shutdown running instance acknowledged: ${success}`,
);
}
}
@@ -176,14 +192,10 @@ async function start() {
`[flipper-server][bootstrap] Check for running instances completed (${runningInstanceShutdownMS} ms)`,
);
if (launchAndFinish) {
return await launch();
}
const {app, server, socket, readyForIncomingConnections} = await startServer(
{
staticPath,
entry: `index.web${argv.bundler ? '.dev' : ''}.html`,
entry: `index.web.html`,
port: argv.port,
},
environmentInfo,
@@ -206,6 +218,10 @@ async function start() {
environmentInfo,
);
flipperServer.once('browser-connection-created', () => {
reportBrowserConnection(true);
});
const t5 = performance.now();
const serverCreatedMS = t5 - t4;
console.info(
@@ -244,7 +260,7 @@ async function start() {
console.error(
'[flipper-server] state changed to error, process will exit.',
);
process.exit(1);
processExit(1);
}
});
}
@@ -277,6 +293,8 @@ async function start() {
)} (${serverStartedMS} ms)`,
);
setupPrefetcher(flipperServer.config.settings);
const startupMS = t10 - t0;
tracker.track('server-bootstrap-performance', {
@@ -295,47 +313,14 @@ async function start() {
}
async function launch() {
console.info('[flipper-server] Launch UI');
const token = await getAuthToken();
console.info(
`[flipper-server] Get authentication token: ${token?.length != 0}`,
);
if (!argv.open) {
console.warn(
'[flipper-server] Not opening UI, --open flag was not provided',
);
return;
}
const openInBrowser = async () => {
console.info('[flipper-server] Open in browser');
const url = new URL(`http://localhost:${argv.port}`);
console.info(`[flipper-server] Go to: ${chalk.blue(url.toString())}`);
open(url.toString(), {app: {name: open.apps.chrome}});
tracker.track('server-open-ui', {
browser: true,
hasToken: token?.length != 0,
});
};
if (argv.bundler) {
await openInBrowser();
} else {
const path = await findInstallation();
if (path) {
tracker.track('server-open-ui', {
browser: false,
hasToken: token?.length != 0,
});
open(path);
} else {
await openInBrowser();
}
}
console.info('[flipper-server] Launch UI completed');
openUI(UIPreference.PWA, argv.port);
}
process.on('uncaughtException', (error) => {
@@ -343,7 +328,8 @@ process.on('uncaughtException', (error) => {
'[flipper-server] uncaught exception, process will exit.',
error,
);
process.exit(1);
reportBrowserConnection(false);
processExit(1);
});
process.on('unhandledRejection', (reason, promise) => {
@@ -355,7 +341,17 @@ process.on('unhandledRejection', (reason, promise) => {
);
});
start().catch((e) => {
console.error(chalk.red('Server startup error: '), e);
process.exit(1);
});
// It has to fit in 32 bit int
const MAX_TIMEOUT = 2147483647;
// Node.js process never waits for all promises to settle and exits as soon as there is not pending timers or open sockets or tasks in teh macroqueue
const runtimeTimeout = setTimeout(() => {}, MAX_TIMEOUT);
// eslint-disable-next-line promise/catch-or-return
start()
.catch((e) => {
console.error(chalk.red('Server startup error: '), e);
reportBrowserConnection(false);
return processExit(1);
})
.finally(() => {
clearTimeout(runtimeTimeout);
});

View File

@@ -21,7 +21,10 @@ import fsRotator from 'file-stream-rotator';
import {ensureFile} from 'fs-extra';
import {access} from 'fs/promises';
import {constants} from 'fs';
import {initializeLogger as initLogger} from 'flipper-server-core';
import {
initializeLogger as initLogger,
setProcessExitRoutine,
} from 'flipper-server-core';
export const loggerOutputFile = 'flipper-server-log.out';
@@ -64,4 +67,14 @@ export async function initializeLogger(
logStream?.write(`${name}: \n${stack}\n`);
}
});
const finalizeLogger = async () => {
const logStreamToEnd = logStream;
// Prevent future writes
logStream = undefined;
await new Promise<void>((resolve) => {
logStreamToEnd?.end(resolve);
});
};
setProcessExitRoutine(finalizeLogger);
}

View File

@@ -270,7 +270,7 @@ function showCompileError() {
// Symbolicating compile errors is wasted effort
// because the stack trace is meaningless:
(error as any).preventSymbolication = true;
window.flipperShowMessage?.(message);
window.flipperShowMessage?.({detail: message});
throw error;
}

View File

@@ -17,13 +17,14 @@ declare global {
theme: 'light' | 'dark' | 'system';
entryPoint: string;
debug: boolean;
graphSecret: string;
appVersion: string;
sessionId: string;
unixname: string;
authToken: string;
};
GRAPH_SECRET: string;
FLIPPER_APP_VERSION: string;
FLIPPER_SESSION_ID: string;
FLIPPER_UNIXNAME: string;
flipperShowMessage?(message: string): void;
flipperShowMessage?(message: {title?: string; detail?: string}): void;
flipperHideMessage?(): void;
}
}

View File

@@ -10,6 +10,7 @@
import {
getLogger,
getStringFromErrorLike,
isProduction,
setLoggerInstance,
} from 'flipper-common';
import {init as initLogger} from './fb-stubs/Logger';
@@ -51,28 +52,57 @@ async function start() {
const params = new URL(location.href).searchParams;
const tokenProvider = async () => {
if (!isProduction()) {
let token = params.get('token');
if (!token) {
token = window.flipperConfig.authToken;
}
const socket = new WebSocket(`ws://${location.host}?token=${token}`);
socket.addEventListener('message', ({data: dataRaw}) => {
const message = JSON.parse(dataRaw.toString());
if (typeof message.event === 'string') {
switch (message.event) {
case 'hasErrors': {
console.warn('Error message received', message.payload);
break;
}
case 'plugins-source-updated': {
window.postMessage({
type: 'plugins-source-updated',
data: message.payload,
});
break;
}
}
}
});
}
const tokenProvider = () => {
const providerParams = new URL(location.href).searchParams;
let token = providerParams.get('token');
if (!token) {
console.info(
'[flipper-client][ui-browser] Get token from manifest instead',
);
try {
const manifestResponse = await fetch('manifest.json');
const manifest = await manifestResponse.json();
token = manifest.token;
} catch (e) {
console.info('[flipper-client][ui-browser] Get token from HTML instead');
token = window.flipperConfig.authToken;
if (!token || token === 'FLIPPER_AUTH_TOKEN_REPLACE_ME') {
console.warn(
'[flipper-client][ui-browser] Failed to get token from manifest. Error:',
e.message,
'[flipper-client][ui-browser] Failed to get token from HTML',
token,
);
window.flipperShowMessage?.({
detail:
'[flipper-client][ui-browser] Failed to get token from HTML: ' +
token,
});
}
}
getLogger().info(
'[flipper-client][ui-browser] Token is available: ',
token?.length != 0,
token?.length === 460,
);
return token;
@@ -112,7 +142,7 @@ async function start() {
switch (state) {
case FlipperServerState.CONNECTING:
getLogger().info('[flipper-client] Connecting to server');
window.flipperShowMessage?.('Connecting to server...');
window.flipperShowMessage?.({title: 'Connecting to server...'});
break;
case FlipperServerState.CONNECTED:
getLogger().info(
@@ -122,7 +152,7 @@ async function start() {
break;
case FlipperServerState.DISCONNECTED:
getLogger().info('[flipper-client] Disconnected from server');
window.flipperShowMessage?.('Waiting for server...');
window.flipperShowMessage?.({title: 'Waiting for server...'});
break;
}
},
@@ -176,7 +206,7 @@ start().catch((e) => {
error: getStringFromErrorLike(e),
pwa: window.matchMedia('(display-mode: standalone)').matches,
});
window.flipperShowMessage?.('Failed to start UI with error: ' + e);
window.flipperShowMessage?.({detail: 'Failed to start UI with error: ' + e});
});
async function initializePWA() {

View File

@@ -7,7 +7,7 @@
* @format
*/
import {notification, Typography} from 'antd';
import {Button, notification, Typography} from 'antd';
import isProduction from '../utils/isProduction';
import {reportPlatformFailures, ReleaseChannel} from 'flipper-common';
import React, {useEffect, useState} from 'react';
@@ -91,17 +91,29 @@ export default function UpdateIndicator() {
isProduction()
) {
reportPlatformFailures(
checkForUpdate(version).then((res) => {
if (res.kind === 'error') {
console.warn('Version check failure: ', res);
checkForUpdate(version)
.then((res) => {
if (res.kind === 'error') {
throw new Error(res.msg);
}
if (res.kind === 'up-to-date') {
setVersionCheckResult(res);
return;
}
return getRenderHostInstance()
.flipperServer.exec('fetch-new-version', res.version)
.then(() => {
setVersionCheckResult(res);
});
})
.catch((e) => {
console.warn('Version check failure: ', e);
setVersionCheckResult({
kind: 'error',
msg: res.msg,
msg: e,
});
} else {
setVersionCheckResult(res);
}
}),
}),
'publicVersionCheck',
);
}
@@ -114,18 +126,31 @@ export function getUpdateAvailableMessage(versionCheckResult: {
url: string;
version: string;
}): React.ReactNode {
const {launcherSettings} = getRenderHostInstance().serverConfig;
const shutdownFlipper = () => {
getRenderHostInstance().flipperServer.exec('shutdown');
window.close();
};
return (
<>
Flipper version {versionCheckResult.version} is now available.
{fbConfig.isFBBuild ? (
fbConfig.getReleaseChannel() === ReleaseChannel.INSIDERS ? (
<> Restart Flipper to update to the latest version.</>
fbConfig.getReleaseChannel() === ReleaseChannel.INSIDERS ||
launcherSettings.ignoreLocalPin ? (
<Button block type="primary" onClick={shutdownFlipper}>
Quit Flipper to upgrade
</Button>
) : (
<>
{' '}
Run <code>arc pull</code> (optionally with <code>--latest</code>) in{' '}
<code>~/fbsource</code> and restart Flipper to update to the latest
version.
<code>~/fbsource</code> and{' '}
<Button block type="primary" onClick={shutdownFlipper}>
Quit Flipper to upgrade
</Button>
.
</>
)
) : (

View File

@@ -121,82 +121,49 @@ test('It can render rows', async () => {
(await renderer.findByText('unique-string')).parentElement?.parentElement,
).toMatchInlineSnapshot(`
<div
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
style="position: absolute; top: 0px; left: 0px; width: 100%; height: 24px; transform: translateY(24px);"
>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
00:00:00.000
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
Android Phone
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
FB4A
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
unique-string
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</span>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
<span>
<span
style="background-color: rgb(255, 245, 102);"
/>
</div>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
/>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
/>
<div
class="css-12luweq-TableBodyColumnContainer e1luu51r0"
width="14%"
>
toClient:send
</span>
</div>
</div>
</div>
`);

View File

@@ -105,11 +105,11 @@ class UIPluginInitializer extends AbstractPluginInitializer {
let uiPluginInitializer: UIPluginInitializer;
export default async (store: Store, _logger: Logger) => {
let FlipperPlugin = FlipperPluginSDK;
if (getRenderHostInstance().GK('flipper_power_search')) {
if (!getRenderHostInstance().GK('flipper_power_search')) {
FlipperPlugin = {
...FlipperPlugin,
MasterDetail: FlipperPlugin._MasterDetailWithPowerSearch as any,
DataTable: FlipperPlugin._DataTableWithPowerSearch as any,
MasterDetail: FlipperPlugin.MasterDetailLegacy as any,
DataTable: FlipperPlugin.DataTableLegacy as any,
};
}

View File

@@ -72,6 +72,7 @@ import {TroubleshootingGuide} from './appinspect/fb-stubs/TroubleshootingGuide';
import {FlipperDevTools} from '../chrome/FlipperDevTools';
import {TroubleshootingHub} from '../chrome/TroubleshootingHub';
import {Notification} from './notification/Notification';
import {SandyRatingButton} from './RatingButton';
export const Navbar = withTrackingScope(function Navbar() {
return (
@@ -104,6 +105,7 @@ export const Navbar = withTrackingScope(function Navbar() {
<NotificationButton />
<TroubleshootMenu />
<SandyRatingButton />
<ExtrasMenu />
<RightSidebarToggleButton />
{getRenderHostInstance().serverConfig.environmentInfo

View File

@@ -0,0 +1,349 @@
/**
* Copyright (c) Meta Platforms, Inc. and 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 {styled, Input, Link, FlexColumn, FlexRow} from '../ui';
import * as UserFeedback from '../fb-stubs/UserFeedback';
import {FeedbackPrompt} from '../fb-stubs/UserFeedback';
import {StarOutlined} from '@ant-design/icons';
import {Button, Checkbox, Popover, Rate} from 'antd';
import {currentUser} from '../fb-stubs/user';
import {theme, useValue} from 'flipper-plugin';
import {reportPlatformFailures} from 'flipper-common';
import {getRenderHostInstance} from 'flipper-frontend-core';
import {NavbarButton} from './Navbar';
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={(e) => this.onAllowUserSharingChanged(e.target.checked)}
/>
{'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: theme.primaryColor, 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 = getRenderHostInstance().serverConfig.sessionId;
const loggedIn = useValue(currentUser());
const triggerPopover = useCallback(() => {
if (!hasTriggered) {
setIsShown(true);
setHasTriggered(true);
}
}, [hasTriggered]);
useEffect(() => {
if (
getRenderHostInstance().GK('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">
<NavbarButton
icon={StarOutlined}
label="Rate Flipper"
onClick={onClick}
/>
</Popover>
);
}

View File

@@ -224,7 +224,6 @@ const outOfContentsContainer = (
const MainContainer = styled(Layout.Container)({
background: theme.backgroundWash,
padding: `0 ${theme.space.large}px ${theme.space.large}px 0`,
overflow: 'hidden',
});

View File

@@ -133,7 +133,11 @@ function CollapsableCategory(props: {
key={check.key}
header={check.label}
extra={<CheckIcon status={check.result.status} />}>
<Paragraph>{check.result.message}</Paragraph>
{check.result.message?.split('\n').map((line, index) => (
<Paragraph key={index} style={{marginBottom: 0}}>
{line}
</Paragraph>
))}
{check.result.commands && (
<List>
{check.result.commands.map(({title, command}, i) => (

View File

@@ -122,11 +122,15 @@ export const LaunchEmulatorDialog = withTrackingScope(
'ios-get-simulators',
false,
);
const nonPhysical = simulators.filter(
(simulator) => simulator.type !== 'physical',
);
setWaitingForIos(false);
setIosEmulators(simulators);
setIosEmulators(nonPhysical);
} catch (error) {
console.warn('Failed to find iOS simulators', error);
setiOSMessage(`Error: ${error.message} \nRetrying...`);
setiOSMessage(`Error: ${error.message ?? error} \nRetrying...`);
setTimeout(getiOSSimulators, 1000);
}
};
@@ -148,7 +152,7 @@ export const LaunchEmulatorDialog = withTrackingScope(
setAndroidEmulators(emulators);
} catch (error) {
console.warn('Failed to find Android emulators', error);
setAndroidMessage(`Error: ${error.message} \nRetrying...`);
setAndroidMessage(`Error: ${error.message ?? error} \nRetrying...`);
setTimeout(getAndroidEmulators, 1000);
}
};

View File

@@ -24,8 +24,17 @@ export function createMockDownloadablePluginDetails(
lastUpdated?: Date;
} = {},
): DownloadablePluginDetails {
const {id, version, title, flipperEngineVersion, gatekeeper, lastUpdated} = {
const {
id,
buildId,
version,
title,
flipperEngineVersion,
gatekeeper,
lastUpdated,
} = {
id: 'test',
buildId: '1337',
version: '3.0.1',
flipperEngineVersion: '0.46.0',
lastUpdated: new Date(1591226525 * 1000),
@@ -36,6 +45,7 @@ export function createMockDownloadablePluginDetails(
const details: DownloadablePluginDetails = {
name: name || `flipper-plugin-${lowercasedID}`,
id: id,
buildId,
bugs: {
email: 'bugs@localhost',
url: 'bugs.localhost',

View File

@@ -171,7 +171,7 @@
"npm": "use yarn instead",
"yarn": "^1.16"
},
"version": "0.236.0",
"version": "0.239.0",
"workspaces": {
"packages": [
"scripts",

View File

@@ -10,7 +10,7 @@
"bugs": "https://github.com/facebook/flipper/issues",
"dependencies": {
"chalk": "^4",
"esbuild": "^0.15.7",
"esbuild": "^0.15.18",
"fb-watchman": "^2.0.2",
"flipper-common": "0.0.0",
"flipper-plugin-lib": "0.0.0",

View File

@@ -28,6 +28,40 @@ const resolveFbStubsToFbPlugin: Plugin = {
},
};
const workerPlugin: Plugin = {
name: 'worker-plugin',
setup({onResolve, onLoad}) {
onResolve({filter: /\?worker$/}, (args) => {
return {
path: require.resolve(args.path.slice(0, -7), {
paths: [args.resolveDir],
}),
namespace: 'worker',
};
});
onLoad({filter: /.*/, namespace: 'worker'}, async (args) => {
// Bundle the worker file
const result = await build({
entryPoints: [args.path],
bundle: true,
write: false,
format: 'iife',
platform: 'browser',
});
const dataUri = `data:text/javascript;base64,${Buffer.from(
result.outputFiles[0].text,
).toString('base64')}`;
return {
contents: `export default function() { return new Worker("${dataUri}"); }`,
loader: 'js',
};
});
},
};
interface RunBuildConfig {
pluginDir: string;
entry: string;
@@ -73,7 +107,7 @@ async function runBuild({
],
sourcemap: dev ? 'inline' : 'external',
minify: !dev,
plugins: intern ? [resolveFbStubsToFbPlugin] : undefined,
plugins: [workerPlugin, ...(intern ? [resolveFbStubsToFbPlugin] : [])],
loader: {
'.ttf': 'dataurl',
},

View File

@@ -30,7 +30,12 @@ export async function getPluginSourceFolders(): Promise<string[]> {
const pluginFolders: string[] = [];
const flipperConfigPath = path.join(homedir(), '.flipper', 'config.json');
if (await fs.pathExists(flipperConfigPath)) {
const config = await fs.readJson(flipperConfigPath);
let config = {pluginPaths: []};
try {
config = await fs.readJson(flipperConfigPath);
} catch (e) {
console.error('Failed to read local flipper config: ', e);
}
if (config.pluginPaths) {
pluginFolders.push(...config.pluginPaths);
}

View File

@@ -27,7 +27,7 @@ You also need to compile in the `litho-annotations` package, as Flipper reflects
```groovy
dependencies {
debugImplementation 'com.facebook.flipper:flipper-litho-plugin:0.236.0'
debugImplementation 'com.facebook.flipper:flipper-litho-plugin:0.239.0'
debugImplementation 'com.facebook.litho:litho-annotations:0.19.0'
// ...
}

View File

@@ -8,7 +8,7 @@ To setup the <Link to={useBaseUrl("/docs/features/plugins/leak-canary")}>LeakCan
```groovy
dependencies {
debugImplementation 'com.facebook.flipper:flipper-leakcanary2-plugin:0.236.0'
debugImplementation 'com.facebook.flipper:flipper-leakcanary2-plugin:0.239.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
}
```

View File

@@ -57,6 +57,7 @@ test('it will merge equal rows', () => {
"date": 2021-01-28T17:15:12.859Z,
"message": "test1",
"pid": 0,
"pidStr": "0",
"tag": "test",
"tid": 1,
"type": "error",
@@ -67,6 +68,7 @@ test('it will merge equal rows', () => {
"date": 2021-01-28T17:15:17.859Z,
"message": "test2",
"pid": 2,
"pidStr": "2",
"tag": "test",
"tid": 3,
"type": "warn",
@@ -77,6 +79,7 @@ test('it will merge equal rows', () => {
"date": 2021-01-28T17:15:12.859Z,
"message": "test3",
"pid": 0,
"pidStr": "0",
"tag": "test",
"tid": 1,
"type": "error",
@@ -103,9 +106,12 @@ test('it supports deeplink and select nodes + navigating to bottom', async () =>
await sleep(1000);
expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([
const current = instance.tableManagerRef.current;
console.error('ref', current);
expect(current?.getSelectedItems()).toEqual([
{
...entry2,
pidStr: '2',
count: 1,
},
]);
@@ -116,6 +122,7 @@ test('it supports deeplink and select nodes + navigating to bottom', async () =>
expect(instance.tableManagerRef.current?.getSelectedItems()).toEqual([
{
...entry3,
pidStr: '0',
count: 1,
},
]);
@@ -138,6 +145,7 @@ test('export / import plugin does work', async () => {
"date": 2021-01-28T17:15:12.859Z,
"message": "test1",
"pid": 0,
"pidStr": "0",
"tag": "test",
"tid": 1,
"type": "error",
@@ -148,6 +156,7 @@ test('export / import plugin does work', async () => {
"date": 2021-01-28T17:15:17.859Z,
"message": "test2",
"pid": 2,
"pidStr": "2",
"tag": "test",
"tid": 3,
"type": "warn",

View File

@@ -12,13 +12,16 @@ import {
DeviceLogEntry,
usePlugin,
createDataSource,
dataTablePowerSearchOperators,
DataTableColumn,
DataTable,
theme,
DataTableManager,
createState,
useValue,
DataFormatter,
DataTable,
EnumLabels,
SearchExpressionTerm,
} from 'flipper-plugin';
import {
PlayCircleOutlined,
@@ -32,28 +35,31 @@ import {baseRowStyle, logTypes} from './logTypes';
export type ExtendedLogEntry = DeviceLogEntry & {
count: number;
pidStr: string; //for the purposes of inferring (only supports string type)
};
const logLevelEnumLabels = Object.entries(logTypes).reduce(
(res, [key, {label}]) => {
res[key] = label;
return res;
},
{} as EnumLabels,
);
function createColumnConfig(
_os: 'iOS' | 'Android' | 'Metro',
): DataTableColumn<ExtendedLogEntry>[] {
return [
{
key: 'type',
title: '',
title: 'Level',
width: 30,
filters: Object.entries(logTypes).map(([value, config]) => ({
label: config.label,
value,
enabled: config.enabled,
})),
onRender(entry) {
return entry.count > 1 ? (
<Badge
count={entry.count}
size="small"
style={{
marginTop: 4,
color: theme.white,
background:
(logTypes[entry.type]?.style as any)?.color ??
@@ -64,17 +70,28 @@ function createColumnConfig(
logTypes[entry.type]?.icon
);
},
powerSearchConfig: {
type: 'enum',
inferEnumOptionsFromData: true,
},
},
{
key: 'date',
title: 'Time',
width: 120,
powerSearchConfig: {
type: 'dateTime',
},
},
{
key: 'pid',
key: 'pidStr',
title: 'PID',
width: 60,
visible: true,
powerSearchConfig: {
type: 'enum',
inferEnumOptionsFromData: true,
},
},
{
key: 'tid',
@@ -86,6 +103,10 @@ function createColumnConfig(
key: 'tag',
title: 'Tag',
width: 160,
powerSearchConfig: {
type: 'enum',
inferEnumOptionsFromData: true,
},
},
{
key: 'app',
@@ -110,10 +131,25 @@ function getRowStyle(entry: DeviceLogEntry): CSSProperties | undefined {
return (logTypes[entry.type]?.style as any) ?? baseRowStyle;
}
const powerSearchInitialState: SearchExpressionTerm[] = [
{
field: {
key: 'type',
label: 'Level',
},
operator:
dataTablePowerSearchOperators.enum_set_is_any_of(logLevelEnumLabels),
searchValue: Object.entries(logTypes)
.filter(([_, item]) => item.enabled)
.map(([key]) => key),
},
];
export function devicePlugin(client: DevicePluginClient) {
const rows = createDataSource<ExtendedLogEntry>([], {
limit: 200000,
persist: 'logs',
indices: [['pidStr'], ['tag']], //there are for inferring enum types
});
const isPaused = createState(true);
const tableManagerRef = createRef<
@@ -122,6 +158,7 @@ export function devicePlugin(client: DevicePluginClient) {
client.onDeepLink((payload: unknown) => {
if (typeof payload === 'string') {
tableManagerRef.current?.setSearchExpression(powerSearchInitialState);
// timeout as we want to await restoring any previous scroll positin first, then scroll to the
setTimeout(() => {
let hasMatch = false;
@@ -168,11 +205,13 @@ export function devicePlugin(client: DevicePluginClient) {
) {
rows.update(lastIndex, {
...previousRow,
pidStr: previousRow.pid.toString(),
count: previousRow.count + 1,
});
} else {
rows.append({
...entry,
pidStr: entry.pid.toString(),
count: 1,
});
}
@@ -248,6 +287,7 @@ export function Component() {
) : undefined
}
tableManagerRef={plugin.tableManagerRef}
powerSearchInitialState={powerSearchInitialState}
/>
);
}

View File

@@ -180,7 +180,7 @@ test('Reducer correctly combines initial response and followup chunk', () => {
responseHeaders: [{key: 'Content-Type', value: 'text/plain'}],
responseIsMock: false,
responseLength: 5,
status: 200,
status: '200',
url: 'http://test.com',
});
});

View File

@@ -113,7 +113,7 @@ test('Can handle custom headers', async () => {
responseIsMock: false,
responseLength: 0,
'response_header_second-test-header': 'dolphins',
status: 200,
status: '200',
url: 'http://www.fbflipper.com',
},
]);

View File

@@ -233,7 +233,7 @@ test('binary data gets serialized correctly', async () => {
],
responseIsMock: false,
responseLength: 24838,
status: 200,
status: '200',
url: 'http://www.fbflipper.com',
},
],
@@ -265,7 +265,7 @@ test('binary data gets serialized correctly', async () => {
],
responseIsMock: false,
responseLength: 24838,
status: 200,
status: '200',
url: 'http://www.fbflipper.com',
});
});

View File

@@ -12,7 +12,7 @@ The network plugin is shipped as a separate Maven artifact, as follows:
```groovy
dependencies {
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.236.0'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.239.0'
}
```

View File

@@ -28,12 +28,13 @@ import {
usePlugin,
useValue,
createDataSource,
DataTableLegacy as DataTable,
DataTableColumnLegacy as DataTableColumn,
DataTableManagerLegacy as DataTableManager,
DataTable,
DataTableColumn,
DataTableManager,
theme,
renderReactRoot,
batch,
dataTablePowerSearchOperators,
} from 'flipper-plugin';
import {
Request,
@@ -50,7 +51,6 @@ import {
getHeaderValue,
getResponseLength,
getRequestLength,
formatStatus,
formatBytes,
formatDuration,
requestsToText,
@@ -118,6 +118,7 @@ export function plugin(client: PluginClient<Events, Methods>) {
);
const requests = createDataSource<Request, 'id'>([], {
key: 'id',
indices: [['method'], ['status']],
});
const selectedId = createState<string | undefined>(undefined);
const tableManagerRef = createRef<undefined | DataTableManager<Request>>();
@@ -136,11 +137,16 @@ export function plugin(client: PluginClient<Events, Methods>) {
return;
} else if (payload.startsWith(searchTermDelim)) {
tableManagerRef.current?.clearSelection();
tableManagerRef.current?.setSearchValue(
payload.slice(searchTermDelim.length),
);
tableManagerRef.current?.setSearchExpression([
{
field: {label: 'Row', key: 'entireRow', useWholeRow: true},
operator:
dataTablePowerSearchOperators.searializable_object_contains(),
searchValue: payload.slice(searchTermDelim.length),
},
]);
} else {
tableManagerRef.current?.setSearchValue('');
tableManagerRef.current?.setSearchExpression([]);
tableManagerRef.current?.selectItemById(payload);
}
});
@@ -537,6 +543,7 @@ function createRequestFromRequestInfo(
domain,
requestHeaders: data.headers,
requestData: decodeBody(data.headers, data.data),
status: '...',
};
customColumns
.filter((c) => c.type === 'request')
@@ -557,7 +564,7 @@ function updateRequestWithResponseInfo(
const res = {
...request,
responseTime: new Date(response.timestamp),
status: response.status,
status: response.status.toString(),
reason: response.reason,
responseHeaders: response.headers,
responseData: decodeBody(response.headers, response.data),
@@ -659,12 +666,14 @@ const baseColumns: DataTableColumn<Request>[] = [
key: 'requestTime',
title: 'Request Time',
width: 120,
powerSearchConfig: {type: 'dateTime'},
},
{
key: 'responseTime',
title: 'Response Time',
width: 120,
visible: false,
powerSearchConfig: {type: 'dateTime'},
},
{
key: 'requestData',
@@ -672,26 +681,36 @@ const baseColumns: DataTableColumn<Request>[] = [
width: 120,
visible: false,
formatters: formatOperationName,
powerSearchConfig: {type: 'object'},
},
{
key: 'domain',
powerSearchConfig: {type: 'string'},
},
{
key: 'url',
title: 'Full URL',
visible: false,
powerSearchConfig: {type: 'string'},
},
{
key: 'method',
title: 'Method',
width: 70,
powerSearchConfig: {
type: 'enum',
inferEnumOptionsFromData: true,
},
},
{
key: 'status',
title: 'Status',
width: 70,
formatters: formatStatus,
align: 'right',
powerSearchConfig: {
type: 'enum',
inferEnumOptionsFromData: true,
},
},
{
key: 'requestLength',
@@ -699,6 +718,7 @@ const baseColumns: DataTableColumn<Request>[] = [
width: 100,
formatters: formatBytes,
align: 'right',
powerSearchConfig: {type: 'float'},
},
{
key: 'responseLength',
@@ -706,6 +726,7 @@ const baseColumns: DataTableColumn<Request>[] = [
width: 100,
formatters: formatBytes,
align: 'right',
powerSearchConfig: {type: 'float'},
},
{
key: 'duration',
@@ -713,6 +734,7 @@ const baseColumns: DataTableColumn<Request>[] = [
width: 100,
formatters: formatDuration,
align: 'right',
powerSearchConfig: {type: 'float'},
},
];
@@ -727,7 +749,10 @@ const errorStyle = {
function getRowStyle(row: Request) {
return row.responseIsMock
? mockingStyle
: row.status && row.status >= 400 && row.status < 600
: row.status &&
row.status !== '...' &&
parseInt(row.status, 10) >= 400 &&
parseInt(row.status, 10) < 600
? errorStyle
: undefined;
}

View File

@@ -7,11 +7,7 @@
* @format
*/
import {
Atom,
DataTableManagerLegacy as DataTableManager,
getFlipperLib,
} from 'flipper-plugin';
import {Atom, DataTableManager, getFlipperLib} from 'flipper-plugin';
import {createContext} from 'react';
import {Header, Request} from '../types';

View File

@@ -23,7 +23,7 @@ export interface Request {
requestData: string | Uint8Array | undefined;
// response
responseTime?: Date;
status?: number;
status: string;
reason?: string;
responseHeaders?: Array<Header>;
responseData?: string | Uint8Array | undefined;

View File

@@ -268,10 +268,6 @@ export function formatBytes(count: number | undefined): string {
return count + ' B';
}
export function formatStatus(status: number | undefined) {
return status ? '' + status : '';
}
export function formatOperationName(requestData: string): string {
try {
const parsedData = JSON.parse(requestData);

View File

@@ -228,6 +228,7 @@ export type Inspectable =
| InspectableSize
| InspectableBounds
| InspectableSpaceBox
| InspectablePluginDeepLink
| InspectableUnknown;
export type InspectableText = {
@@ -285,6 +286,13 @@ export type InspectableObject = {
fields: Record<MetadataId, Inspectable>;
};
export type InspectablePluginDeepLink = {
type: 'pluginDeeplink';
label?: string;
pluginId: string;
deeplinkPayload: unknown;
};
export type InspectableArray = {
type: 'array';
items: Inspectable[];

View File

@@ -11,9 +11,10 @@ import {DeleteOutlined, PartitionOutlined} from '@ant-design/icons';
import {
DataTable,
DataTableColumn,
DataTableManager,
DetailSidebar,
Layout,
DataTableManager,
dataTablePowerSearchOperators,
usePlugin,
useValue,
} from 'flipper-plugin';
@@ -41,6 +42,7 @@ export function FrameworkEventsTable({
const instance = usePlugin(plugin);
const focusedNode = useValue(instance.uiState.focusedNode);
const managerRef = useRef<DataTableManager<AugmentedFrameworkEvent> | null>(
null,
);
@@ -51,13 +53,31 @@ export function FrameworkEventsTable({
if (nodeId != null) {
managerRef.current?.resetFilters();
if (isTree) {
managerRef.current?.addColumnFilter('treeId', nodeId as string, {
exact: true,
});
managerRef.current?.setSearchExpression([
{
field: {
key: 'treeId',
label: 'TreeId',
},
operator: {
...dataTablePowerSearchOperators.int_equals(),
},
searchValue: nodeId,
},
]);
} else {
managerRef.current?.addColumnFilter('nodeId', nodeId as string, {
exact: true,
});
managerRef.current?.setSearchExpression([
{
field: {
key: 'nodeId',
label: 'NodeId',
},
operator: {
...dataTablePowerSearchOperators.int_equals(),
},
searchValue: nodeId,
},
]);
}
}
}, [instance.uiActions, isTree, nodeId]);
@@ -68,9 +88,9 @@ export function FrameworkEventsTable({
const customColumns = [...customColumnKeys].map(
(customKey: string) =>
({
key: customKey,
key: `payload.${customKey}` as any,
title: startCase(customKey),
onRender: (row: AugmentedFrameworkEvent) => row.payload?.[customKey],
powerSearchConfig: stringConfig,
} as DataTableColumn<AugmentedFrameworkEvent>),
);
@@ -135,42 +155,91 @@ export function FrameworkEventsTable({
);
}
const MonoSpace = (t: any) => (
<span style={{fontFamily: 'monospace'}}>{t}</span>
);
const stringConfig = [
dataTablePowerSearchOperators.string_contains(),
dataTablePowerSearchOperators.string_not_contains(),
dataTablePowerSearchOperators.string_matches_exactly(),
];
const idConfig = [dataTablePowerSearchOperators.int_equals()];
const inferredEnum = [
dataTablePowerSearchOperators.enum_set_is_any_of({}),
dataTablePowerSearchOperators.enum_is({}),
dataTablePowerSearchOperators.enum_set_is_none_of({}),
dataTablePowerSearchOperators.enum_is_not({}),
];
const staticColumns: DataTableColumn<AugmentedFrameworkEvent>[] = [
{
key: 'timestamp',
sortable: true,
onRender: (row: FrameworkEvent) => formatTimestampMillis(row.timestamp),
title: 'Timestamp',
formatters: MonoSpace,
powerSearchConfig: [
dataTablePowerSearchOperators.newer_than_absolute_date(),
dataTablePowerSearchOperators.older_than_absolute_date(),
],
},
{
key: 'type',
title: 'Event type',
onRender: (row: FrameworkEvent) => eventTypeToName(row.type),
powerSearchConfig: {
inferEnumOptionsFromData: true,
operators: inferredEnum,
},
},
{
key: 'duration',
title: 'Duration',
title: 'Duration (Nanos)',
onRender: (row: FrameworkEvent) =>
row.duration != null ? formatDuration(row.duration) : null,
formatters: MonoSpace,
powerSearchConfig: [
dataTablePowerSearchOperators.int_greater_or_equal(),
dataTablePowerSearchOperators.int_greater_than(),
dataTablePowerSearchOperators.int_equals(),
dataTablePowerSearchOperators.int_less_or_equal(),
dataTablePowerSearchOperators.int_less_than(),
],
},
{
key: 'treeId',
title: 'TreeId',
powerSearchConfig: idConfig,
formatters: MonoSpace,
},
{
key: 'rootComponentName',
title: 'Root component name',
powerSearchConfig: stringConfig,
formatters: MonoSpace,
},
{
key: 'nodeId',
title: 'Component ID',
powerSearchConfig: idConfig,
formatters: MonoSpace,
},
{
key: 'nodeName',
title: 'Component name',
powerSearchConfig: stringConfig,
formatters: MonoSpace,
},
{
key: 'thread',
title: 'Thread',
onRender: (row: FrameworkEvent) => startCase(row.thread),
powerSearchConfig: stringConfig,
formatters: MonoSpace,
},
];

View File

@@ -16,6 +16,7 @@ import {
Layout,
styled,
useLocalStorageState,
usePlugin,
} from 'flipper-plugin';
import React, {useState} from 'react';
import {
@@ -33,6 +34,9 @@ import {any} from 'lodash/fp';
import {InspectableColor} from '../../ClientTypes';
import {transformAny} from '../../utils/dataTransform';
import {SearchOutlined} from '@ant-design/icons';
import {plugin} from '../../index';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import {Glyph} from 'flipper';
type ModalData = {
data: unknown;
@@ -315,6 +319,7 @@ function NamedAttribute({
* disables hover and focsued states
*/
const readOnlyInput = css`
overflow: hidden; //stop random scrollbars from showing up
font-size: small;
:hover {
border-color: ${theme.disabledColor} !important;
@@ -388,7 +393,7 @@ function StyledTextArea({
return (
<Input.TextArea
autoSize
className={!mutable ? readOnlyInput : ''}
className={cx(!mutable && readOnlyInput)}
bordered
style={{color: color}}
readOnly={!mutable}
@@ -432,6 +437,7 @@ function AttributeValue({
name: string;
inspectable: Inspectable;
}) {
const instance = usePlugin(plugin);
switch (inspectable.type) {
case 'boolean':
return (
@@ -550,6 +556,39 @@ function AttributeValue({
</span>
</Button>
);
case 'pluginDeeplink':
return (
<Button
size="small"
onClick={() => {
instance.client.selectPlugin(
inspectable.pluginId,
inspectable.deeplinkPayload,
);
}}
style={{
height: 26,
boxSizing: 'border-box',
alignItems: 'center',
justifyContent: 'center',
}}
type="ghost">
<span
style={{
marginTop: 2,
fontFamily: 'monospace',
color: theme.textColorSecondary,
fontSize: 'small',
}}>
{inspectable.label}
</span>
<Glyph
style={{marginLeft: 8, marginBottom: 2}}
size={12}
name="share-external"
/>
</Button>
);
}
return null;
}

View File

@@ -7,7 +7,7 @@
* @format
*/
import {Id, ClientNode, MetadataId, Metadata} from '../../ClientTypes';
import {Id, ClientNode, NodeMap, MetadataId, Metadata} from '../../ClientTypes';
import {Color, OnSelectNode} from '../../DesktopTypes';
import React, {
CSSProperties,
@@ -30,7 +30,7 @@ import {
} from 'flipper-plugin';
import {plugin} from '../../index';
import {head, last} from 'lodash';
import {Badge, Typography} from 'antd';
import {Badge, Tooltip, Typography} from 'antd';
import {useVirtualizer} from '@tanstack/react-virtual';
import {ContextMenu} from './ContextMenu';
@@ -60,7 +60,7 @@ export function Tree2({
additionalHeightOffset,
}: {
additionalHeightOffset: number;
nodes: Map<Id, ClientNode>;
nodes: NodeMap;
metadata: Map<MetadataId, Metadata>;
rootId: Id;
}) {
@@ -125,12 +125,21 @@ export function Tree2({
return;
}
prevSearchTerm.current = searchTerm;
const matchingIndexes = findSearchMatchingIndexes(treeNodes, searchTerm);
const matchingNodesIds = findMatchingNodes(nodes, searchTerm);
if (matchingIndexes.length > 0) {
rowVirtualizer.scrollToIndex(matchingIndexes[0], {align: 'start'});
matchingNodesIds.forEach((id) => {
instance.uiActions.ensureAncestorsExpanded(id);
});
if (matchingNodesIds.length > 0) {
const firstTreeNode = treeNodes.find(searchPredicate(searchTerm));
const idx = firstTreeNode?.idx;
if (idx != null) {
rowVirtualizer.scrollToIndex(idx, {align: 'start'});
}
}
}, [rowVirtualizer, searchTerm, treeNodes]);
}, [instance.uiActions, nodes, rowVirtualizer, searchTerm, treeNodes]);
useKeyboardControls(
treeNodes,
@@ -488,7 +497,9 @@ function InlineAttributes({attributes}: {attributes: Record<string, string>}) {
<>
{Object.entries(attributes ?? {}).map(([key, value]) => (
<TreeAttributeContainer key={key}>
<span style={{color: theme.warningColor}}>{key}</span>
<span style={{color: theme.warningColor}}>
{highlightManager.render(key)}
</span>
<span>={highlightManager.render(value)}</span>
</TreeAttributeContainer>
))}
@@ -577,33 +588,52 @@ function HighlightedText(props: {text: string}) {
}
function nodeIcon(node: TreeNode) {
const [icon, tooltip] = nodeData(node);
const iconComp =
typeof icon === 'string' ? <NodeIconImage src={icon} /> : icon;
if (tooltip == null) {
return iconComp;
} else {
return <Tooltip title={tooltip}>{iconComp}</Tooltip>;
}
}
function nodeData(node: TreeNode) {
if (node.tags.includes('LithoMountable')) {
return <NodeIconImage src="icons/litho-logo-blue.png" />;
return ['icons/litho-logo-blue.png', 'Litho Mountable (Primitive)'];
} else if (node.tags.includes('Litho')) {
return <NodeIconImage src="icons/litho-logo.png" />;
return ['icons/litho-logo.png', 'Litho Component'];
} else if (node.tags.includes('CK')) {
if (node.tags.includes('iOS')) {
return <NodeIconImage src="icons/ck-mounted-logo.png" />;
return ['icons/ck-mounted-logo.png', 'CK Mounted Component'];
}
return <NodeIconImage src="icons/ck-logo.png" />;
return ['icons/ck-logo.png', 'CK Component'];
} else if (node.tags.includes('BloksBoundTree')) {
return <NodeIconImage src="facebook/bloks-logo-orange.png" />;
return ['facebook/bloks-logo-orange.png', 'Bloks Bridged component'];
} else if (node.tags.includes('BloksDerived')) {
return <NodeIconImage src="facebook/bloks-logo-blue.png" />;
return ['facebook/bloks-logo-blue.png', 'Bloks Derived (Server) component'];
} else if (node.tags.includes('Warning')) {
return (
<WarningOutlined style={{...nodeiconStyle, color: theme.errorColor}} />
);
return [
<WarningOutlined
key="0"
style={{...nodeiconStyle, color: theme.errorColor}}
/>,
null,
];
} else {
return (
return [
<div
key="0"
style={{
height: NodeIconSize,
width: 0,
marginRight: IconRightMargin,
}}
/>
);
/>,
null,
];
}
}
@@ -619,22 +649,24 @@ const NodeIconImage = styled.img({...nodeiconStyle});
const renderDepthOffset = 12;
//due to virtualisation the out of the box dom based scrolling doesnt work
function findSearchMatchingIndexes(
treeNodes: TreeNode[],
searchTerm: string,
): number[] {
function findMatchingNodes(nodes: NodeMap, searchTerm: string): Id[] {
if (!searchTerm) {
return [];
}
return treeNodes
.map((value, index) => [value, index] as [TreeNode, number])
.filter(
([value, _]) =>
value.name.toLowerCase().includes(searchTerm) ||
Object.values(value.inlineAttributes).find((inlineAttr) =>
inlineAttr.toLocaleLowerCase().includes(searchTerm),
),
)
.map(([_, index]) => index);
return [...nodes.values()]
.filter(searchPredicate(searchTerm))
.map((node) => node.id);
}
function searchPredicate(
searchTerm: string,
): (node: ClientNode) => string | true | undefined {
return (node: ClientNode): string | true | undefined =>
node.name.toLowerCase().includes(searchTerm) ||
Object.keys(node.inlineAttributes).find((inlineAttr) =>
inlineAttr.toLocaleLowerCase().includes(searchTerm),
) ||
Object.values(node.inlineAttributes).find((inlineAttr) =>
inlineAttr.toLocaleLowerCase().includes(searchTerm),
);
}

View File

@@ -51,7 +51,10 @@ export function plugin(client: PluginClient<Events, Methods>) {
const snapshot = createState<SnapshotInfo | null>(null);
const nodesAtom = createState<Map<Id, ClientNode>>(new Map());
const frameworkEvents = createDataSource<AugmentedFrameworkEvent>([], {
indices: [['nodeId']],
indices: [
['nodeId'],
['type'], //for inferred values
],
limit: 10000,
});
const frameworkEventsCustomColumns = createState<Set<string>>(new Set());
@@ -294,6 +297,7 @@ export function plugin(client: PluginClient<Events, Methods>) {
metadata,
perfEvents,
os: client.device.os,
client,
};
}

View File

@@ -26,6 +26,7 @@ import {
serverDir,
staticDir,
rootDir,
sonarDir,
} from './paths';
import isFB from './isFB';
import yargs from 'yargs';
@@ -244,7 +245,6 @@ async function copyStaticResources(outDir: string, versionNumber: string) {
'icon.png',
'icon_grey.png',
'icons.json',
'index.web.dev.html',
'index.web.html',
'install_desktop.svg',
'loading.html',
@@ -651,8 +651,11 @@ async function installNodeBinary(outputPath: string, platform: BuildPlatform) {
console.log(`✅ Node successfully downloaded and unpacked.`);
}
console.log(`⚙️ Copying node binary from ${nodePath} to ${outputPath}`);
await fs.copyFile(nodePath, outputPath);
console.log(`⚙️ Moving node binary from ${nodePath} to ${outputPath}`);
if (await fs.exists(outputPath)) {
await fs.rm(outputPath);
}
await fs.move(nodePath, outputPath);
} else {
console.log(`⚙️ Downloading node version for ${platform} using pkg-fetch`);
const nodePath = await pkgFetch({
@@ -661,8 +664,11 @@ async function installNodeBinary(outputPath: string, platform: BuildPlatform) {
nodeRange: SUPPORTED_NODE_PLATFORM,
});
console.log(`⚙️ Copying node binary from ${nodePath} to ${outputPath}`);
await fs.copyFile(nodePath, outputPath);
console.log(`⚙️ Moving node binary from ${nodePath} to ${outputPath}`);
if (await fs.exists(outputPath)) {
await fs.rm(outputPath);
}
await fs.move(nodePath, outputPath);
}
if (
@@ -740,86 +746,138 @@ async function setUpWindowsBundle(outputDir: string) {
async function setUpMacBundle(
outputDir: string,
serverDir: string,
platform: BuildPlatform,
versionNumber: string,
): Promise<{nodePath: string; resourcesPath: string}> {
) {
console.log(`⚙️ Creating Mac bundle in ${outputDir}`);
let appTemplate = path.join(staticDir, 'flipper-server-app-template');
if (isFB) {
appTemplate = path.join(
staticDir,
'facebook',
'flipper-server-app-template',
platform,
);
console.info('⚙️ Using internal template from: ' + appTemplate);
}
let serverOutputDir = '';
let nodeBinaryFile = '';
await fs.copy(appTemplate, outputDir);
/**
* Use the most basic template for MacOS.
* - Copy the contents of the template into the output directory.
* - Replace the version placeholder value with the actual version.
*/
if (!isFB) {
const template = path.join(staticDir, 'flipper-server-app-template');
await fs.copy(template, outputDir);
function replacePropertyValue(
obj: any,
targetValue: string,
replacementValue: string,
): any {
if (typeof obj === 'object' && !Array.isArray(obj) && obj !== null) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
obj[key] = replacePropertyValue(
obj[key],
targetValue,
replacementValue,
);
function replacePropertyValue(
obj: any,
targetValue: string,
replacementValue: string,
): any {
if (typeof obj === 'object' && !Array.isArray(obj) && obj !== null) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
obj[key] = replacePropertyValue(
obj[key],
targetValue,
replacementValue,
);
}
}
} else if (typeof obj === 'string' && obj === targetValue) {
obj = replacementValue;
}
} else if (typeof obj === 'string' && obj === targetValue) {
obj = replacementValue;
return obj;
}
return obj;
console.log(`⚙️ Update plist with build information`);
const plistPath = path.join(
outputDir,
'Flipper.app',
'Contents',
'Info.plist',
);
/* eslint-disable node/no-sync*/
const pListContents: Record<any, any> = plist.readFileSync(plistPath);
replacePropertyValue(
pListContents,
'{flipper-server-version}',
versionNumber,
);
plist.writeBinaryFileSync(plistPath, pListContents);
/* eslint-enable node/no-sync*/
serverOutputDir = path.join(
outputDir,
'Flipper.app',
'Contents',
'Resources',
'server',
);
nodeBinaryFile = path.join(
outputDir,
'Flipper.app',
'Contents',
'MacOS',
'flipper-runtime',
);
} else {
serverOutputDir = path.join(
sonarDir,
'facebook',
'flipper-server',
'Resources',
'server',
);
nodeBinaryFile = path.join(
sonarDir,
'facebook',
'flipper-server',
'Resources',
'flipper-runtime',
);
}
console.log(`⚙️ Writing plist`);
const plistPath = path.join(
outputDir,
'Flipper.app',
'Contents',
'Info.plist',
);
/* eslint-disable node/no-sync*/
const pListContents: Record<any, any> = plist.readFileSync(plistPath);
replacePropertyValue(
pListContents,
'{flipper-server-version}',
versionNumber,
);
plist.writeBinaryFileSync(plistPath, pListContents);
/* eslint-enable node/no-sync*/
const resourcesOutputDir = path.join(
outputDir,
'Flipper.app',
'Contents',
'Resources',
'server',
);
if (!(await fs.exists(resourcesOutputDir))) {
await fs.mkdir(resourcesOutputDir);
if (await fs.exists(serverOutputDir)) {
await fs.rm(serverOutputDir, {recursive: true, force: true});
}
await fs.mkdirp(serverOutputDir);
console.log(`⚙️ Copying from ${serverDir} to ${serverOutputDir}`);
// Copy resources instead of moving. This is because we want to keep the original
// files in the right location because they are used whilst bundling for
// other platforms.
await fs.copy(serverDir, serverOutputDir, {
overwrite: true,
dereference: true,
});
console.log(`⚙️ Downloading compatible node version`);
await installNodeBinary(nodeBinaryFile, platform);
if (isFB) {
const {buildFlipperServer} = await import(
// @ts-ignore only used inside Meta
'./fb/build-flipper-server-macos'
);
const outputPath = await buildFlipperServer(versionNumber, false);
console.log(
`⚙️ Successfully built platform: ${platform}, output: ${outputPath}`,
);
const appPath = path.join(outputDir, 'Flipper.app');
await fs.emptyDir(appPath);
await fs.copy(outputPath, appPath);
// const appPath = path.join(outputDir, 'Flipper.app');
// if (await fs.exists(appPath)) {
// await fs.rm(appPath, {recursive: true, force: true});
// }
// await fs.move(outputPath, appPath);
}
const nodeOutputPath = path.join(
outputDir,
'Flipper.app',
'Contents',
'MacOS',
'flipper-runtime',
);
return {resourcesPath: resourcesOutputDir, nodePath: nodeOutputPath};
}
async function bundleServerReleaseForPlatform(
dir: string,
bundleDir: string,
versionNumber: string,
platform: BuildPlatform,
) {
@@ -830,39 +888,38 @@ async function bundleServerReleaseForPlatform(
);
await fs.mkdirp(outputDir);
let outputPaths = {
nodePath: path.join(outputDir, 'flipper-runtime'),
resourcesPath: outputDir,
};
// On the mac, we need to set up a resource bundle which expects paths
// to be in different places from Linux/Windows bundles.
if (
platform === BuildPlatform.MAC_X64 ||
platform === BuildPlatform.MAC_AARCH64
) {
outputPaths = await setUpMacBundle(outputDir, platform, versionNumber);
} else if (platform === BuildPlatform.LINUX) {
await setUpLinuxBundle(outputDir);
} else if (platform === BuildPlatform.WINDOWS) {
await setUpWindowsBundle(outputDir);
}
await setUpMacBundle(outputDir, bundleDir, platform, versionNumber);
if (argv.dmg) {
await createMacDMG(platform, outputDir, distDir);
}
} else {
const outputPaths = {
nodePath: path.join(outputDir, 'flipper-runtime'),
resourcesPath: outputDir,
};
console.log(`⚙️ Copying from ${dir} to ${outputPaths.resourcesPath}`);
await fs.copy(dir, outputPaths.resourcesPath, {
overwrite: true,
dereference: true,
});
if (platform === BuildPlatform.LINUX) {
await setUpLinuxBundle(outputDir);
} else if (platform === BuildPlatform.WINDOWS) {
await setUpWindowsBundle(outputDir);
}
console.log(`⚙️ Downloading compatible node version`);
await installNodeBinary(outputPaths.nodePath, platform);
console.log(
`⚙️ Copying from ${bundleDir} to ${outputPaths.resourcesPath}`,
);
await fs.copy(bundleDir, outputPaths.resourcesPath, {
overwrite: true,
dereference: true,
});
if (
argv.dmg &&
(platform === BuildPlatform.MAC_X64 ||
platform === BuildPlatform.MAC_AARCH64)
) {
await createMacDMG(platform, outputDir, distDir);
console.log(`⚙️ Downloading compatible node version`);
await installNodeBinary(outputPaths.nodePath, platform);
}
console.log(`✅ Wrote ${platform}-specific server version to ${outputDir}`);

View File

@@ -210,6 +210,13 @@ async function buildDist(buildFolder: string) {
const targetsRaw: Map<Platform, Map<Arch, string[]>>[] = [];
const postBuildCallbacks: (() => void)[] = [];
const productName = process.env.FLIPPER_REACT_NATIVE_ONLY
? 'Flipper-Electron'
: 'Flipper';
const appId = process.env.FLIPPER_REACT_NATIVE_ONLY
? 'com.facebook.sonar-electron'
: `com.facebook.sonar`;
if (argv.mac || argv['mac-dmg']) {
targetsRaw.push(Platform.MAC.createTarget(['dir'], Arch.universal));
// You can build mac apps on Linux but can't build dmgs, so we separate those.
@@ -231,10 +238,14 @@ async function buildDist(buildFolder: string) {
}
}
postBuildCallbacks.push(() =>
spawn('zip', ['-qyr9', '../Flipper-mac.zip', 'Flipper.app'], {
cwd: macPath,
encoding: 'utf-8',
}),
spawn(
'zip',
['-qyr9', `../${productName}-mac.zip`, `${productName}.app`],
{
cwd: macPath,
encoding: 'utf-8',
},
),
);
}
if (argv.linux || argv['linux-deb'] || argv['linux-snap']) {
@@ -273,8 +284,8 @@ async function buildDist(buildFolder: string) {
await build({
publish: 'never',
config: {
appId: `com.facebook.sonar`,
productName: 'Flipper',
appId,
productName,
directories: {
buildResources: buildFolder,
output: distDir,

View File

@@ -10,6 +10,7 @@
import path from 'path';
export const rootDir = path.resolve(__dirname, '..');
export const sonarDir = path.resolve(__dirname, '..', '..');
export const appDir = path.join(rootDir, 'app');
export const browserUiDir = path.join(rootDir, 'flipper-ui-browser');
export const staticDir = path.join(rootDir, 'static');

View File

@@ -49,6 +49,11 @@ const argv = yargs
choices: ['stable', 'insiders'],
default: 'stable',
},
open: {
describe: 'Open Flipper in the default browser after starting',
type: 'boolean',
default: true,
},
})
.version('DEV')
.help()
@@ -103,7 +108,9 @@ async function copyStaticResources() {
async function restartServer() {
try {
await compileServerMain();
await launchServer(true, ++startCount === 1); // only open on the first time
// Only open the UI the first time it runs. Subsequent runs, likely triggered after
// saving changes, should just reload the existing UI.
await launchServer(true, argv.open && ++startCount === 1);
} catch (e) {
console.error(
chalk.red(

View File

@@ -1,3 +1,19 @@
# 0.239.0 (16/11/2023)
* [D51346366](https://github.com/facebook/flipper/search?q=D51346366&type=Commits) - UIDebugger fix issue with scrollbars sometimes appearing in sidebar
# 0.238.0 (14/11/2023)
* [D51199644](https://github.com/facebook/flipper/search?q=D51199644&type=Commits) - [Logs] Improve power search config to populate dropdown for level, PID & Tag
* [D51199783](https://github.com/facebook/flipper/search?q=D51199783&type=Commits) - [Analytics] Improve power search config to populate dropdown for low cardinality columns
# 0.237.0 (10/11/2023)
* [D51113095](https://github.com/facebook/flipper/search?q=D51113095&type=Commits) - UIdebugger added powersearch operators to Framework event table
# 0.234.0 (1/11/2023)
* [D50595987](https://github.com/facebook/flipper/search?q=D50595987&type=Commits) - UIDebugger, new sidebar design

View File

@@ -1,227 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="icon.png">
<link rel="apple-touch-icon" href="/icon.png">
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
<link id="flipper-theme-import" rel="stylesheet">
<title>Flipper</title>
<script>
window.flipperConfig = {
theme: 'light',
entryPoint: 'flipper-ui-browser/src/index-fast-refresh.bundle?platform=web&dev=true&minify=false',
debug: true,
}
</script>
<style>
.message {
-webkit-app-region: drag;
z-index: 999999;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 50px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #525252;
text-align: center;
}
.console {
font-family: 'Fira Mono';
width: 600px;
height: 250px;
box-sizing: border-box;
margin: auto;
}
.console header {
border-top-left-radius: 15px;
border-top-right-radius: 15px;
background-color: #9254de;
height: 45px;
line-height: 45px;
text-align: center;
color: white;
}
.console .consolebody {
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
box-sizing: border-box;
padding: 0px 10px;
height: calc(100% - 40px);
overflow: scroll;
background-color: #000;
color: white;
text-align: left;
}
input[type="submit"] {
background-color: #9254de;
color: white;
font-family: system-ui;
font-size: 15px;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="submit"]:hover {
background-color: #722ed1;
}
input[type="submit"]:active {
background-color: #722ed1;
}
#troubleshoot {
display: none;
background-color: white;
}
</style>
</head>
<body>
<div id="troubleshoot" class="message">
</div>
<div id="root">
<div id="loading" class="message">
Connecting...
</div>
</div>
<script>
(async function () {
// Line below needed to make Metro work. Alternatives could be considered.
window.global = window;
let connected = false;
// Listen to changes in the network state, reload when online.
// This handles the case when the device is completely offline
// i.e. no network connection.
window.addEventListener('online', () => {
window.location.reload();
});
const root = document.getElementById('root');
const troubleshootBox = document.getElementById('troubleshoot');
function showMessage(text, centered) {
troubleshootBox.innerText = text;
root.style.display = 'none';
troubleshootBox.style.display = 'flex';
}
function hideMessage() {
root.style.display = 'block';
troubleshootBox.style.display = 'none';
}
window.flipperShowMessage = showMessage;
window.flipperHideMessage = hideMessage;
window.GRAPH_SECRET = 'GRAPH_SECRET_REPLACE_ME';
window.FLIPPER_APP_VERSION = 'FLIPPER_APP_VERSION_REPLACE_ME';
window.FLIPPER_SESSION_ID = 'FLIPPER_SESSION_ID_REPLACE_ME';
window.FLIPPER_UNIXNAME = 'FLIPPER_UNIXNAME_REPLACE_ME';
const params = new URL(location.href).searchParams;
let token = params.get('token');
if (!token) {
const manifestResponse = await fetch('manifest.json');
const manifest = await manifestResponse.json();
token = manifest.token;
}
const socket = new WebSocket(`ws://${location.host}?token=${token}`);
window.devSocket = socket;
socket.addEventListener('message', ({ data: dataRaw }) => {
const message = JSON.parse(dataRaw.toString())
if (typeof message.event === 'string') {
switch (message.event) {
case 'hasErrors': {
console.warn('Error message received'. message.payload);
break;
}
case 'plugins-source-updated': {
window.postMessage({
type: 'plugins-source-updated',
data: message.payload
})
break;
}
}
}
})
socket.addEventListener('error', (e) => {
if (!connected) {
console.warn('Socket failed to connect. Is the server running? Have you provided a valid authentication token?');
}
else {
console.warn('Socket failed with error.', e);
}
});
socket.addEventListener('open', () => {
connected = true;
})
// load correct theme (n.b. this doesn't handle system value specifically, will assume light in such cases)
try {
if (window.flipperConfig.theme === 'dark') {
document.getElementById('flipper-theme-import').href = "themes/dark.css";
} else {
document.getElementById('flipper-theme-import').href = "themes/light.css";
}
} catch (e) {
console.error("Failed to initialize theme", e);
document.getElementById('flipper-theme-import').href = "themes/light.css";
}
function init() {
const script = document.createElement('script');
script.src = window.flipperConfig.entryPoint;
script.onerror = (e) => {
const retry = (retries) => {
showMessage(`Failed to load entry point. Check Chrome Dev Tools console for more info. Retrying in: ${retries}`);
retries -= 1;
if (retries < 0) {
window.location.reload();
}
else {
setTimeout(() => retry(retries), 1000);
}
}
retry(3);
};
document.body.appendChild(script);
}
init();
})();
</script>
</body>
</html>

View File

@@ -14,11 +14,7 @@
<title>Flipper</title>
<script>
window.flipperConfig = {
theme: 'light',
entryPoint: 'bundle.js',
debug: false,
}
window.flipperConfig = FLIPPER_CONFIG_PLACEHOLDER;
</script>
<style>
.message {
@@ -32,6 +28,7 @@
padding: 50px;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 20px;
@@ -39,15 +36,22 @@
text-align: center;
}
.message p {
font-size: 12px;
}
#troubleshoot {
display: none;
background-color: white;
}
</style>
</head>
<body>
<div id="troubleshoot" class="message">
<h1 id="tourbleshoot_title"></h1>
<p id="tourbleshoot_details"></p>
</div>
<div id="root">
@@ -58,7 +62,7 @@
<script>
(function () {
// FIXME: needed to make Metro work
// Line below needed to make Metro work. Alternatives could be considered.
window.global = window;
// Listen to changes in the network state, reload when online.
@@ -70,9 +74,18 @@
const root = document.getElementById('root');
const troubleshootBox = document.getElementById('troubleshoot');
const troubleshootBoxTitle = document.getElementById('tourbleshoot_title');
const troubleshootBoxDetails = document.getElementById('tourbleshoot_details');
function showMessage(text) {
troubleshootBox.innerText = text;
function showMessage({ title, detail }) {
if (title) {
troubleshootBoxTitle.innerText = title
}
if (detail) {
const newMessage = document.createElement('p')
newMessage.innerText = detail;
troubleshootBoxDetails.appendChild(newMessage)
}
root.style.display = 'none';
troubleshootBox.style.display = 'flex';
@@ -81,16 +94,13 @@
function hideMessage() {
root.style.display = 'block';
troubleshootBox.style.display = 'none';
troubleshootBoxTitle.innerHTML = ''
troubleshootBoxDetails.innerHTML = ''
}
window.flipperShowMessage = showMessage;
window.flipperHideMessage = hideMessage;
window.GRAPH_SECRET = 'GRAPH_SECRET_REPLACE_ME';
window.FLIPPER_APP_VERSION = 'FLIPPER_APP_VERSION_REPLACE_ME';
window.FLIPPER_SESSION_ID = 'FLIPPER_SESSION_ID_REPLACE_ME';
window.FLIPPER_UNIXNAME = 'FLIPPER_UNIXNAME_REPLACE_ME';
// load correct theme (n.b. this doesn't handle system value specifically, will assume light in such cases)
try {
if (window.flipperConfig.theme === 'dark') {
@@ -117,12 +127,13 @@
setTimeout(() => retry(retries), 1000);
}
}
retry(3);
};
document.body.appendChild(script);
}
init();
})();
</script>

View File

@@ -2961,10 +2961,15 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
"@esbuild/linux-loong64@0.15.7":
version "0.15.7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.7.tgz#1ec4af4a16c554cbd402cc557ccdd874e3f7be53"
integrity sha512-IKznSJOsVUuyt7cDzzSZyqBEcZe+7WlBqTVXiF1OXP/4Nm387ToaXZ0fyLwI1iBlI/bzpxVq411QE2/Bt2XWWw==
"@esbuild/android-arm@0.15.18":
version "0.15.18"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80"
integrity sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==
"@esbuild/linux-loong64@0.15.18":
version "0.15.18"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239"
integrity sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==
"@eslint/eslintrc@^0.4.3":
version "0.4.3"
@@ -7503,132 +7508,133 @@ es6-error@^4.1.1:
resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d"
integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==
esbuild-android-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.7.tgz#a521604d8c4c6befc7affedc897df8ccde189bea"
integrity sha512-p7rCvdsldhxQr3YHxptf1Jcd86dlhvc3EQmQJaZzzuAxefO9PvcI0GLOa5nCWem1AJ8iMRu9w0r5TG8pHmbi9w==
esbuild-android-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz#20a7ae1416c8eaade917fb2453c1259302c637a5"
integrity sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==
esbuild-android-arm64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.7.tgz#307b81f1088bf1e81dfe5f3d1d63a2d2a2e3e68e"
integrity sha512-L775l9ynJT7rVqRM5vo+9w5g2ysbOCfsdLV4CWanTZ1k/9Jb3IYlQ06VCI1edhcosTYJRECQFJa3eAvkx72eyQ==
esbuild-android-arm64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz#9cc0ec60581d6ad267568f29cf4895ffdd9f2f04"
integrity sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==
esbuild-darwin-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.7.tgz#270117b0c4ec6bcbc5cf3a297a7d11954f007e11"
integrity sha512-KGPt3r1c9ww009t2xLB6Vk0YyNOXh7hbjZ3EecHoVDxgtbUlYstMPDaReimKe6eOEfyY4hBEEeTvKwPsiH5WZg==
esbuild-darwin-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz#428e1730ea819d500808f220fbc5207aea6d4410"
integrity sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==
esbuild-darwin-arm64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.7.tgz#97851eacd11dacb7719713602e3319e16202fc77"
integrity sha512-kBIHvtVqbSGajN88lYMnR3aIleH3ABZLLFLxwL2stiuIGAjGlQW741NxVTpUHQXUmPzxi6POqc9npkXa8AcSZQ==
esbuild-darwin-arm64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz#b6dfc7799115a2917f35970bfbc93ae50256b337"
integrity sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==
esbuild-freebsd-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.7.tgz#1de15ffaf5ae916aa925800aa6d02579960dd8c4"
integrity sha512-hESZB91qDLV5MEwNxzMxPfbjAhOmtfsr9Wnuci7pY6TtEh4UDuevmGmkUIjX/b+e/k4tcNBMf7SRQ2mdNuK/HQ==
esbuild-freebsd-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz#4e190d9c2d1e67164619ae30a438be87d5eedaf2"
integrity sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==
esbuild-freebsd-arm64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.7.tgz#0f160dbf5c9a31a1d8dd87acbbcb1a04b7031594"
integrity sha512-dLFR0ChH5t+b3J8w0fVKGvtwSLWCv7GYT2Y2jFGulF1L5HftQLzVGN+6pi1SivuiVSmTh28FwUhi9PwQicXI6Q==
esbuild-freebsd-arm64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz#18a4c0344ee23bd5a6d06d18c76e2fd6d3f91635"
integrity sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==
esbuild-linux-32@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.7.tgz#422eb853370a5e40bdce8b39525380de11ccadec"
integrity sha512-v3gT/LsONGUZcjbt2swrMjwxo32NJzk+7sAgtxhGx1+ZmOFaTRXBAi1PPfgpeo/J//Un2jIKm/I+qqeo4caJvg==
esbuild-linux-32@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz#9a329731ee079b12262b793fb84eea762e82e0ce"
integrity sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==
esbuild-linux-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.7.tgz#f89c468453bb3194b14f19dc32e0b99612e81d2b"
integrity sha512-LxXEfLAKwOVmm1yecpMmWERBshl+Kv5YJ/1KnyAr6HRHFW8cxOEsEfisD3sVl/RvHyW//lhYUVSuy9jGEfIRAQ==
esbuild-linux-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz#532738075397b994467b514e524aeb520c191b6c"
integrity sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==
esbuild-linux-arm64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.7.tgz#68a79d6eb5e032efb9168a0f340ccfd33d6350a1"
integrity sha512-P3cfhudpzWDkglutWgXcT2S7Ft7o2e3YDMrP1n0z2dlbUZghUkKCyaWw0zhp4KxEEzt/E7lmrtRu/pGWnwb9vw==
esbuild-linux-arm64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz#5372e7993ac2da8f06b2ba313710d722b7a86e5d"
integrity sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==
esbuild-linux-arm@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.7.tgz#2b7c784d0b3339878013dfa82bf5eaf82c7ce7d3"
integrity sha512-JKgAHtMR5f75wJTeuNQbyznZZa+pjiUHV7sRZp42UNdyXC6TiUYMW/8z8yIBAr2Fpad8hM1royZKQisqPABPvQ==
esbuild-linux-arm@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz#e734aaf259a2e3d109d4886c9e81ec0f2fd9a9cc"
integrity sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==
esbuild-linux-mips64le@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.7.tgz#bb8330a50b14aa84673816cb63cc6c8b9beb62cc"
integrity sha512-T7XKuxl0VpeFLCJXub6U+iybiqh0kM/bWOTb4qcPyDDwNVhLUiPcGdG2/0S7F93czUZOKP57YiLV8YQewgLHKw==
esbuild-linux-mips64le@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz#c0487c14a9371a84eb08fab0e1d7b045a77105eb"
integrity sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==
esbuild-linux-ppc64le@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.7.tgz#52544e7fa992811eb996674090d0bc41f067a14b"
integrity sha512-6mGuC19WpFN7NYbecMIJjeQgvDb5aMuvyk0PDYBJrqAEMkTwg3Z98kEKuCm6THHRnrgsdr7bp4SruSAxEM4eJw==
esbuild-linux-ppc64le@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz#af048ad94eed0ce32f6d5a873f7abe9115012507"
integrity sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==
esbuild-linux-riscv64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.7.tgz#a43ae60697992b957e454cbb622f7ee5297e8159"
integrity sha512-uUJsezbswAYo/X7OU/P+PuL/EI9WzxsEQXDekfwpQ23uGiooxqoLFAPmXPcRAt941vjlY9jtITEEikWMBr+F/g==
esbuild-linux-riscv64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz#423ed4e5927bd77f842bd566972178f424d455e6"
integrity sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==
esbuild-linux-s390x@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.7.tgz#8c76a125dd10a84c166294d77416caaf5e1c7b64"
integrity sha512-+tO+xOyTNMc34rXlSxK7aCwJgvQyffqEM5MMdNDEeMU3ss0S6wKvbBOQfgd5jRPblfwJ6b+bKiz0g5nABpY0QQ==
esbuild-linux-s390x@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz#21d21eaa962a183bfb76312e5a01cc5ae48ce8eb"
integrity sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==
esbuild-netbsd-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.7.tgz#19b2e75449d7d9c32b5d8a222bac2f1e0c3b08fd"
integrity sha512-yVc4Wz+Pu3cP5hzm5kIygNPrjar/v5WCSoRmIjCPWfBVJkZNb5brEGKUlf+0Y759D48BCWa0WHrWXaNy0DULTQ==
esbuild-netbsd-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz#ae75682f60d08560b1fe9482bfe0173e5110b998"
integrity sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==
esbuild-openbsd-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.7.tgz#1357b2bf72fd037d9150e751420a1fe4c8618ad7"
integrity sha512-GsimbwC4FSR4lN3wf8XmTQ+r8/0YSQo21rWDL0XFFhLHKlzEA4SsT1Tl8bPYu00IU6UWSJ+b3fG/8SB69rcuEQ==
esbuild-openbsd-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz#79591a90aa3b03e4863f93beec0d2bab2853d0a8"
integrity sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==
esbuild-sunos-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.7.tgz#87ab2c604592a9c3c763e72969da0d72bcde91d2"
integrity sha512-8CDI1aL/ts0mDGbWzjEOGKXnU7p3rDzggHSBtVryQzkSOsjCHRVe0iFYUuhczlxU1R3LN/E7HgUO4NXzGGP/Ag==
esbuild-sunos-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz#fd528aa5da5374b7e1e93d36ef9b07c3dfed2971"
integrity sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==
esbuild-windows-32@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.7.tgz#c81e688c0457665a8d463a669e5bf60870323e99"
integrity sha512-cOnKXUEPS8EGCzRSFa1x6NQjGhGsFlVgjhqGEbLTPsA7x4RRYiy2RKoArNUU4iR2vHmzqS5Gr84MEumO/wxYKA==
esbuild-windows-32@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz#0e92b66ecdf5435a76813c4bc5ccda0696f4efc3"
integrity sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==
esbuild-windows-64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.7.tgz#2421d1ae34b0561a9d6767346b381961266c4eff"
integrity sha512-7MI08Ec2sTIDv+zH6StNBKO+2hGUYIT42GmFyW6MBBWWtJhTcQLinKS6ldIN1d52MXIbiJ6nXyCJ+LpL4jBm3Q==
esbuild-windows-64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz#0fc761d785414284fc408e7914226d33f82420d0"
integrity sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==
esbuild-windows-arm64@0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.7.tgz#7d5e9e060a7b454cb2f57f84a3f3c23c8f30b7d2"
integrity sha512-R06nmqBlWjKHddhRJYlqDd3Fabx9LFdKcjoOy08YLimwmsswlFBJV4rXzZCxz/b7ZJXvrZgj8DDv1ewE9+StMw==
esbuild-windows-arm64@0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz#5b5bdc56d341d0922ee94965c89ee120a6a86eb7"
integrity sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==
esbuild@^0.15.7:
version "0.15.7"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.7.tgz#8a1f1aff58671a3199dd24df95314122fc1ddee8"
integrity sha512-7V8tzllIbAQV1M4QoE52ImKu8hT/NLGlGXkiDsbEU5PS6K8Mn09ZnYoS+dcmHxOS9CRsV4IRAMdT3I67IyUNXw==
esbuild@^0.15.18:
version "0.15.18"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d"
integrity sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==
optionalDependencies:
"@esbuild/linux-loong64" "0.15.7"
esbuild-android-64 "0.15.7"
esbuild-android-arm64 "0.15.7"
esbuild-darwin-64 "0.15.7"
esbuild-darwin-arm64 "0.15.7"
esbuild-freebsd-64 "0.15.7"
esbuild-freebsd-arm64 "0.15.7"
esbuild-linux-32 "0.15.7"
esbuild-linux-64 "0.15.7"
esbuild-linux-arm "0.15.7"
esbuild-linux-arm64 "0.15.7"
esbuild-linux-mips64le "0.15.7"
esbuild-linux-ppc64le "0.15.7"
esbuild-linux-riscv64 "0.15.7"
esbuild-linux-s390x "0.15.7"
esbuild-netbsd-64 "0.15.7"
esbuild-openbsd-64 "0.15.7"
esbuild-sunos-64 "0.15.7"
esbuild-windows-32 "0.15.7"
esbuild-windows-64 "0.15.7"
esbuild-windows-arm64 "0.15.7"
"@esbuild/android-arm" "0.15.18"
"@esbuild/linux-loong64" "0.15.18"
esbuild-android-64 "0.15.18"
esbuild-android-arm64 "0.15.18"
esbuild-darwin-64 "0.15.18"
esbuild-darwin-arm64 "0.15.18"
esbuild-freebsd-64 "0.15.18"
esbuild-freebsd-arm64 "0.15.18"
esbuild-linux-32 "0.15.18"
esbuild-linux-64 "0.15.18"
esbuild-linux-arm "0.15.18"
esbuild-linux-arm64 "0.15.18"
esbuild-linux-mips64le "0.15.18"
esbuild-linux-ppc64le "0.15.18"
esbuild-linux-riscv64 "0.15.18"
esbuild-linux-s390x "0.15.18"
esbuild-netbsd-64 "0.15.18"
esbuild-openbsd-64 "0.15.18"
esbuild-sunos-64 "0.15.18"
esbuild-windows-32 "0.15.18"
esbuild-windows-64 "0.15.18"
esbuild-windows-arm64 "0.15.18"
escalade@^3.1.1:
version "3.1.1"