From 221f3002caa2314cba0a62950da6fb92b453d1d0 Mon Sep 17 00:00:00 2001
From: Ruslan Lesiutin <rdlesyutin@gmail.com>
Date: Thu, 30 Jan 2025 20:08:17 +0000
Subject: [PATCH] chore[DevTools]: make clipboardWrite optional for chromium
 (#32262)

Addresses https://github.com/facebook/react/issues/32244.

### Chromium
We will use
[chrome.permissions](https://developer.chrome.com/docs/extensions/reference/api/permissions)
for checking / requesting `clipboardWrite` permission before copying
something to the clipboard.

### Firefox
We will keep `clipboardWrite` as a required permission, because there is
no reliable and working API for requesting optional permissions for
extensions that are extending browser DevTools:
- `chrome.permissions` is unavailable for devtools pages -
https://bugzilla.mozilla.org/show_bug.cgi?id=1796933
- You can't call `chrome.permissions.request` from background, because
this instruction has to be executed inside user-event callback,
basically only initiated by user.

I don't really want to come up with solutions like opening a new tab
with a button that user has to click.
---
 .eslintrc.js                                  |  2 ++
 .../chrome/manifest.json                      |  4 ++-
 .../edge/manifest.json                        |  4 ++-
 .../src/devtools/ContextMenu/types.js         |  2 +-
 .../src/devtools/store.js                     |  3 +-
 .../Components/InspectedElementContextTree.js | 15 +++++---
 .../Components/InspectedElementPropsTree.js   | 15 ++++----
 .../Components/InspectedElementSourcePanel.js | 11 ++++--
 .../Components/InspectedElementStateTree.js   | 20 ++++++-----
 .../NativeStyleEditor/StyleEditor.js          |  6 +++-
 .../views/Profiler/SidebarEventInfo.js        |  6 +++-
 .../views/UnsupportedBridgeProtocolDialog.js  | 11 ++++--
 .../src/errors/PermissionNotGrantedError.js   | 21 +++++++++++
 .../frontend/utils/withPermissionsCheck.js    | 35 +++++++++++++++++++
 .../src/CanvasPageContextMenu.js              | 12 +++++--
 scripts/flow/react-devtools.js                |  2 ++
 16 files changed, 136 insertions(+), 33 deletions(-)
 create mode 100644 packages/react-devtools-shared/src/errors/PermissionNotGrantedError.js
 create mode 100644 packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js

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/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 <KeyValue> 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 (
     <div data-testname="InspectedElementPropsTree">
@@ -76,7 +79,7 @@ export default function InspectedElementPropsTree({
         )}
       </div>
       {!isEmpty &&
-        (entries: any).map(([name, value]) => (
+        entries.map(([name, value]) => (
           <KeyValue
             key={name}
             alphaSort={true}
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
index 16ca5d1bfe589..0f7203b12a750 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
@@ -14,6 +14,7 @@ import {toNormalUrl} from 'jsc-safe-url';
 import Button from '../Button';
 import ButtonIcon from '../ButtonIcon';
 import Skeleton from './Skeleton';
+import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
 
 import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types';
 import styles from './InspectedElementSourcePanel.css';
@@ -59,7 +60,10 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
   const symbolicatedSource = React.use(symbolicatedSourcePromise);
   if (symbolicatedSource == null) {
     const {sourceURL, line, column} = source;
-    const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
+    const handleCopy = withPermissionsCheck(
+      {permissions: ['clipboardWrite']},
+      () => copy(`${sourceURL}:${line}:${column}`),
+    );
 
     return (
       <Button onClick={handleCopy} title="Copy to clipboard">
@@ -69,7 +73,10 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
   }
 
   const {sourceURL, line, column} = symbolicatedSource;
-  const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
+  const handleCopy = withPermissionsCheck(
+    {permissions: ['clipboardWrite']},
+    () => copy(`${sourceURL}:${line}:${column}`),
+  );
 
   return (
     <Button onClick={handleCopy} title="Copy to clipboard">
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js
index b8fe8a89a3022..7987a720ddb06 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js
@@ -16,6 +16,7 @@ import KeyValue from './KeyValue';
 import {alphaSortEntries, serializeDataForCopy} from '../utils';
 import Store from '../../store';
 import styles from './InspectedElementSharedStyles.css';
+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';
@@ -35,22 +36,23 @@ export default function InspectedElementStateTree({
   store,
 }: Props): React.Node {
   const {state, type} = inspectedElement;
+  if (state == null) {
+    return null;
+  }
 
   // HostSingleton and HostHoistable may have state that we don't want to expose to users
   const isHostComponent = type === ElementTypeHostComponent;
-
-  const entries = state != null ? Object.entries(state) : null;
-  const isEmpty = entries === null || entries.length === 0;
-
+  const entries = Object.entries(state);
+  const isEmpty = entries.length === 0;
   if (isEmpty || isHostComponent) {
     return null;
   }
 
-  if (entries !== null) {
-    entries.sort(alphaSortEntries);
-  }
-
-  const handleCopy = () => copy(serializeDataForCopy(((state: any): Object)));
+  entries.sort(alphaSortEntries);
+  const handleCopy = withPermissionsCheck(
+    {permissions: ['clipboardWrite']},
+    () => copy(serializeDataForCopy(state)),
+  );
 
   return (
     <div>
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js b/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js
index 47c8a0aaa8322..b1416f6e89f95 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js
@@ -20,6 +20,7 @@ import {serializeDataForCopy} from '../../utils';
 import AutoSizeInput from './AutoSizeInput';
 import styles from './StyleEditor.css';
 import {sanitizeForParse} from '../../../utils';
+import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
 
 import type {Style} from './types';
 
@@ -62,7 +63,10 @@ export default function StyleEditor({id, style}: Props): React.Node {
 
   const keys = useMemo(() => Array.from(Object.keys(style)), [style]);
 
-  const handleCopy = () => copy(serializeDataForCopy(style));
+  const handleCopy = withPermissionsCheck(
+    {permissions: ['clipboardWrite']},
+    () => copy(serializeDataForCopy(style)),
+  );
 
   return (
     <div className={styles.StyleEditor}>
diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
index 6797d5274929f..97977380efdb3 100644
--- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
+++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
@@ -21,6 +21,7 @@ import {
 } from 'react-devtools-timeline/src/utils/formatting';
 import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
 import {copy} from 'clipboard-js';
+import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
 
 import styles from './SidebarEventInfo.css';
 
@@ -53,7 +54,10 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
               <div className={styles.Row}>
                 <label className={styles.Label}>Rendered by</label>
                 <Button
-                  onClick={() => copy(componentStack)}
+                  onClick={withPermissionsCheck(
+                    {permissions: ['clipboardWrite']},
+                    () => copy(componentStack),
+                  )}
                   title="Copy component stack to clipboard">
                   <ButtonIcon type="copy" />
                 </Button>
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({
         <pre className={styles.NpmCommand}>
           {upgradeInstructions}
           <Button
-            onClick={() => copy(upgradeInstructions)}
+            onClick={withPermissionsCheck(
+              {permissions: ['clipboardWrite']},
+              () => copy(upgradeInstructions),
+            )}
             title="Copy upgrade command to clipboard">
             <ButtonIcon type="copy" />
           </Button>
@@ -99,7 +103,10 @@ function DialogContent({
         <pre className={styles.NpmCommand}>
           {downgradeInstructions}
           <Button
-            onClick={() => copy(downgradeInstructions)}
+            onClick={withPermissionsCheck(
+              {permissions: ['clipboardWrite']},
+              () => copy(downgradeInstructions),
+            )}
             title="Copy downgrade command to clipboard">
             <ButtonIcon type="copy" />
           </Button>
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<SupportedPermission>;
+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<T: (...$ReadOnlyArray<empty>) => mixed>(
+  options: PermissionsOptions,
+  callback: T,
+): T | (() => Promise<ReturnType<T>>) {
+  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;