diff --git a/.eslintrc.js b/.eslintrc.js index f5b98c6b4887f..7fe08f4cdf36e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -500,6 +500,7 @@ module.exports = { 'packages/react-devtools-shared/src/hook.js', 'packages/react-devtools-shared/src/backend/console.js', 'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js', + 'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js', ], globals: { __IS_CHROME__: 'readonly', @@ -507,6 +508,7 @@ module.exports = { __IS_EDGE__: 'readonly', __IS_NATIVE__: 'readonly', __IS_INTERNAL_VERSION__: 'readonly', + chrome: 'readonly', }, }, { diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 99b6bb4a272e2..9a592780b17c4 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -476,9 +476,7 @@ jobs: fi - name: Playwright install deps working-directory: fixtures/flight - run: | - npx playwright install - sudo npx playwright install-deps + run: npx playwright install --with-deps chromium - name: Run tests working-directory: fixtures/flight run: yarn test diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 5d4c4f35e7227..4b2d810f60411 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -43,7 +43,9 @@ "permissions": [ "scripting", "storage", - "tabs", + "tabs" + ], + "optional_permissions": [ "clipboardWrite" ], "host_permissions": [ diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 9a3b99cf17aa0..37a76be2f24bc 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -43,7 +43,9 @@ "permissions": [ "scripting", "storage", - "tabs", + "tabs" + ], + "optional_permissions": [ "clipboardWrite" ], "host_permissions": [ diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/types.js b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js index e2e8cecae33ac..c2f296db10fe5 100644 --- a/packages/react-devtools-shared/src/devtools/ContextMenu/types.js +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/types.js @@ -10,7 +10,7 @@ import type {Node as ReactNode} from 'react'; export type ContextMenuItem = { - onClick: () => void, + onClick: () => mixed, content: ReactNode, }; diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 0a3fbe82bb6a0..3895217053df1 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -38,6 +38,7 @@ import { currentBridgeProtocol, } from 'react-devtools-shared/src/bridge'; import {StrictMode} from 'react-devtools-shared/src/frontend/types'; +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; import type { Element, @@ -1494,7 +1495,7 @@ export default class Store extends EventEmitter<{ }; onSaveToClipboard: (text: string) => void = text => { - copy(text); + withPermissionsCheck({permissions: ['clipboardWrite']}, () => copy(text))(); }; onBackendInitialized: () => void = () => { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js index 044a6d9b48d11..941fa5fe01ddb 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js @@ -19,6 +19,7 @@ import { ElementTypeClass, ElementTypeFunction, } from 'react-devtools-shared/src/frontend/types'; +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; @@ -41,14 +42,18 @@ export default function InspectedElementContextTree({ const isReadOnly = type !== ElementTypeClass && type !== ElementTypeFunction; - const entries = context != null ? Object.entries(context) : null; - if (entries !== null) { - entries.sort(alphaSortEntries); + if (context == null) { + return null; } - const isEmpty = entries === null || entries.length === 0; + const entries = Object.entries(context); + entries.sort(alphaSortEntries); + const isEmpty = entries.length === 0; - const handleCopy = () => copy(serializeDataForCopy(((context: any): Object))); + const handleCopy = withPermissionsCheck( + {permissions: ['clipboardWrite']}, + () => copy(serializeDataForCopy(context)), + ); // We add an object with a "value" key as a wrapper around Context data // so that we can use the shared component to display it. diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js index 942d2a2490b46..729b517b46f98 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js @@ -18,6 +18,7 @@ import {alphaSortEntries, serializeDataForCopy} from '../utils'; import Store from '../../store'; import styles from './InspectedElementSharedStyles.css'; import {ElementTypeClass} from 'react-devtools-shared/src/frontend/types'; +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; @@ -53,17 +54,19 @@ export default function InspectedElementPropsTree({ const canRenamePaths = type === ElementTypeClass || canEditFunctionPropsRenamePaths; - const entries = props != null ? Object.entries(props) : null; - if (entries === null) { - // Skip the section for null props. + // Skip the section for null props. + if (props == null) { return null; } + const entries = Object.entries(props); entries.sort(alphaSortEntries); - const isEmpty = entries.length === 0; - const handleCopy = () => copy(serializeDataForCopy(((props: any): Object))); + const handleCopy = withPermissionsCheck( + {permissions: ['clipboardWrite']}, + () => copy(serializeDataForCopy(props)), + ); return (
@@ -76,7 +79,7 @@ export default function InspectedElementPropsTree({ )}
{!isEmpty && - (entries: any).map(([name, value]) => ( + entries.map(([name, value]) => ( copy(`${sourceURL}:${line}:${column}`); + const handleCopy = withPermissionsCheck( + {permissions: ['clipboardWrite']}, + () => copy(`${sourceURL}:${line}:${column}`), + ); return ( diff --git a/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js index 425dfe1d08c13..7bfd86fb9c19d 100644 --- a/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js +++ b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js @@ -16,6 +16,7 @@ import Button from './Button'; import ButtonIcon from './ButtonIcon'; import {copy} from 'clipboard-js'; import styles from './UnsupportedBridgeProtocolDialog.css'; +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; import type {BridgeProtocol} from 'react-devtools-shared/src/bridge'; @@ -82,7 +83,10 @@ function DialogContent({
           {upgradeInstructions}
           
@@ -99,7 +103,10 @@ function DialogContent({
         
           {downgradeInstructions}
           
diff --git a/packages/react-devtools-shared/src/errors/PermissionNotGrantedError.js b/packages/react-devtools-shared/src/errors/PermissionNotGrantedError.js
new file mode 100644
index 0000000000000..dc9e4ad734e74
--- /dev/null
+++ b/packages/react-devtools-shared/src/errors/PermissionNotGrantedError.js
@@ -0,0 +1,21 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+export class PermissionNotGrantedError extends Error {
+  constructor() {
+    super("User didn't grant the required permission to perform an action");
+
+    // Maintains proper stack trace for where our error was thrown (only available on V8)
+    if (Error.captureStackTrace) {
+      Error.captureStackTrace(this, PermissionNotGrantedError);
+    }
+
+    this.name = 'PermissionNotGrantedError';
+  }
+}
diff --git a/packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js b/packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js
new file mode 100644
index 0000000000000..d87db82546484
--- /dev/null
+++ b/packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js
@@ -0,0 +1,35 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+import {PermissionNotGrantedError} from 'react-devtools-shared/src/errors/PermissionNotGrantedError';
+
+type SupportedPermission = 'clipboardWrite';
+type Permissions = Array;
+type PermissionsOptions = {permissions: Permissions};
+
+// browser.permissions is not available for DevTools pages in Firefox
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1796933
+// We are going to assume that requested permissions are not optional.
+export function withPermissionsCheck) => mixed>(
+  options: PermissionsOptions,
+  callback: T,
+): T | (() => Promise>) {
+  if (!__IS_CHROME__ && !__IS_EDGE__) {
+    return callback;
+  } else {
+    return async () => {
+      const granted = await chrome.permissions.request(options);
+      if (granted) {
+        return callback();
+      }
+
+      return Promise.reject(new PermissionNotGrantedError());
+    };
+  }
+}
diff --git a/packages/react-devtools-timeline/src/CanvasPageContextMenu.js b/packages/react-devtools-timeline/src/CanvasPageContextMenu.js
index 2af32a68aa4e5..5bd4da3141b79 100644
--- a/packages/react-devtools-timeline/src/CanvasPageContextMenu.js
+++ b/packages/react-devtools-timeline/src/CanvasPageContextMenu.js
@@ -13,6 +13,7 @@ import {copy} from 'clipboard-js';
 import prettyMilliseconds from 'pretty-ms';
 
 import ContextMenuContainer from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer';
+import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
 
 import {getBatchRange} from './utils/getBatchRange';
 import {moveStateToRange} from './view-base/utils/scrollState';
@@ -138,7 +139,9 @@ export default function CanvasPageContextMenu({
           content: 'Zoom to batch',
         },
         {
-          onClick: () => copySummary(timelineData, measure),
+          onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
+            copySummary(timelineData, measure),
+          ),
           content: 'Copy summary',
         },
       );
@@ -147,16 +150,19 @@ export default function CanvasPageContextMenu({
     if (flamechartStackFrame != null) {
       items.push(
         {
-          onClick: () => copy(flamechartStackFrame.scriptUrl),
+          onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
+            copy(flamechartStackFrame.scriptUrl),
+          ),
           content: 'Copy file path',
         },
         {
-          onClick: () =>
+          onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
             copy(
               `line ${flamechartStackFrame.locationLine ?? ''}, column ${
                 flamechartStackFrame.locationColumn ?? ''
               }`,
             ),
+          ),
           content: 'Copy location',
         },
       );
diff --git a/scripts/flow/react-devtools.js b/scripts/flow/react-devtools.js
index 4e5fe0db1c625..4e0f2a915ede6 100644
--- a/scripts/flow/react-devtools.js
+++ b/scripts/flow/react-devtools.js
@@ -16,3 +16,5 @@ declare const __IS_FIREFOX__: boolean;
 declare const __IS_CHROME__: boolean;
 declare const __IS_EDGE__: boolean;
 declare const __IS_NATIVE__: boolean;
+
+declare const chrome: any;