@samitouri / QOS-React / commits / 221f3002ca

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.

Ruslan Lesiutin committed Jan 30, 2025 at 20:08 UTC 221f3002caa2314cba0a62950da6fb92b453d1d0
16 files changed +136 -33
.eslintrc.js
+2
@@ -500,6 +500,7 @@ module.exports = {
500 'packages/react-devtools-shared/src/hook.js',
501 'packages/react-devtools-shared/src/backend/console.js',
502 'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js',
503 + 'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js',
504 ],
505 globals: {
506 __IS_CHROME__: 'readonly',
@@ -507,6 +508,7 @@ module.exports = {
508 __IS_EDGE__: 'readonly',
509 __IS_NATIVE__: 'readonly',
510 __IS_INTERNAL_VERSION__: 'readonly',
511 + chrome: 'readonly',
512 },
513 },
514 {
packages/react-devtools-extensions/chrome/manifest.json
+3 -1
@@ -43,7 +43,9 @@
43 "permissions": [
44 "scripting",
45 "storage",
46 - "tabs",
46 + "tabs"
47 + ],
48 + "optional_permissions": [
49 "clipboardWrite"
50 ],
51 "host_permissions": [
packages/react-devtools-extensions/edge/manifest.json
+3 -1
@@ -43,7 +43,9 @@
43 "permissions": [
44 "scripting",
45 "storage",
46 - "tabs",
46 + "tabs"
47 + ],
48 + "optional_permissions": [
49 "clipboardWrite"
50 ],
51 "host_permissions": [
packages/react-devtools-shared/src/devtools/ContextMenu/types.js
+1 -1
@@ -10,7 +10,7 @@
10 import type {Node as ReactNode} from 'react';
11
12 export type ContextMenuItem = {
13 - onClick: () => void,
13 + onClick: () => mixed,
14 content: ReactNode,
15 };
16
packages/react-devtools-shared/src/devtools/store.js
+2 -1
@@ -38,6 +38,7 @@ import {
38 currentBridgeProtocol,
39 } from 'react-devtools-shared/src/bridge';
40 import {StrictMode} from 'react-devtools-shared/src/frontend/types';
41 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
42
43 import type {
44 Element,
@@ -1494,7 +1495,7 @@ export default class Store extends EventEmitter<{
1495 };
1496
1497 onSaveToClipboard: (text: string) => void = text => {
1497 - copy(text);
1498 + withPermissionsCheck({permissions: ['clipboardWrite']}, () => copy(text))();
1499 };
1500
1501 onBackendInitialized: () => void = () => {
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js
+10 -5
@@ -19,6 +19,7 @@ import {
19 ElementTypeClass,
20 ElementTypeFunction,
21 } from 'react-devtools-shared/src/frontend/types';
22 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
23
24 import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
25 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
@@ -41,14 +42,18 @@ export default function InspectedElementContextTree({
42
43 const isReadOnly = type !== ElementTypeClass && type !== ElementTypeFunction;
44
44 - const entries = context != null ? Object.entries(context) : null;
45 - if (entries !== null) {
46 - entries.sort(alphaSortEntries);
45 + if (context == null) {
46 + return null;
47 }
48
49 - const isEmpty = entries === null || entries.length === 0;
49 + const entries = Object.entries(context);
50 + entries.sort(alphaSortEntries);
51 + const isEmpty = entries.length === 0;
52
51 - const handleCopy = () => copy(serializeDataForCopy(((context: any): Object)));
53 + const handleCopy = withPermissionsCheck(
54 + {permissions: ['clipboardWrite']},
55 + () => copy(serializeDataForCopy(context)),
56 + );
57
58 // We add an object with a "value" key as a wrapper around Context data
59 // so that we can use the shared <KeyValue> component to display it.
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js
+9 -6
@@ -18,6 +18,7 @@ import {alphaSortEntries, serializeDataForCopy} from '../utils';
18 import Store from '../../store';
19 import styles from './InspectedElementSharedStyles.css';
20 import {ElementTypeClass} from 'react-devtools-shared/src/frontend/types';
21 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
22
23 import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
24 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
@@ -53,17 +54,19 @@ export default function InspectedElementPropsTree({
54 const canRenamePaths =
55 type === ElementTypeClass || canEditFunctionPropsRenamePaths;
56
56 - const entries = props != null ? Object.entries(props) : null;
57 - if (entries === null) {
58 - // Skip the section for null props.
57 + // Skip the section for null props.
58 + if (props == null) {
59 return null;
60 }
61
62 + const entries = Object.entries(props);
63 entries.sort(alphaSortEntries);
63 -
64 const isEmpty = entries.length === 0;
65
66 - const handleCopy = () => copy(serializeDataForCopy(((props: any): Object)));
66 + const handleCopy = withPermissionsCheck(
67 + {permissions: ['clipboardWrite']},
68 + () => copy(serializeDataForCopy(props)),
69 + );
70
71 return (
72 <div data-testname="InspectedElementPropsTree">
@@ -76,7 +79,7 @@ export default function InspectedElementPropsTree({
79 )}
80 </div>
81 {!isEmpty &&
79 - (entries: any).map(([name, value]) => (
82 + entries.map(([name, value]) => (
83 <KeyValue
84 key={name}
85 alphaSort={true}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
+9 -2
@@ -14,6 +14,7 @@ import {toNormalUrl} from 'jsc-safe-url';
14 import Button from '../Button';
15 import ButtonIcon from '../ButtonIcon';
16 import Skeleton from './Skeleton';
17 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
18
19 import type {Source as InspectedElementSource} from 'react-devtools-shared/src/shared/types';
20 import styles from './InspectedElementSourcePanel.css';
@@ -59,7 +60,10 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
60 const symbolicatedSource = React.use(symbolicatedSourcePromise);
61 if (symbolicatedSource == null) {
62 const {sourceURL, line, column} = source;
62 - const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
63 + const handleCopy = withPermissionsCheck(
64 + {permissions: ['clipboardWrite']},
65 + () => copy(`${sourceURL}:${line}:${column}`),
66 + );
67
68 return (
69 <Button onClick={handleCopy} title="Copy to clipboard">
@@ -69,7 +73,10 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
73 }
74
75 const {sourceURL, line, column} = symbolicatedSource;
72 - const handleCopy = () => copy(`${sourceURL}:${line}:${column}`);
76 + const handleCopy = withPermissionsCheck(
77 + {permissions: ['clipboardWrite']},
78 + () => copy(`${sourceURL}:${line}:${column}`),
79 + );
80
81 return (
82 <Button onClick={handleCopy} title="Copy to clipboard">
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js
+11 -9
@@ -16,6 +16,7 @@ import KeyValue from './KeyValue';
16 import {alphaSortEntries, serializeDataForCopy} from '../utils';
17 import Store from '../../store';
18 import styles from './InspectedElementSharedStyles.css';
19 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
20
21 import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
22 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
@@ -35,22 +36,23 @@ export default function InspectedElementStateTree({
36 store,
37 }: Props): React.Node {
38 const {state, type} = inspectedElement;
39 + if (state == null) {
40 + return null;
41 + }
42
43 // HostSingleton and HostHoistable may have state that we don't want to expose to users
44 const isHostComponent = type === ElementTypeHostComponent;
41 -
42 - const entries = state != null ? Object.entries(state) : null;
43 - const isEmpty = entries === null || entries.length === 0;
44 -
45 + const entries = Object.entries(state);
46 + const isEmpty = entries.length === 0;
47 if (isEmpty || isHostComponent) {
48 return null;
49 }
50
49 - if (entries !== null) {
50 - entries.sort(alphaSortEntries);
51 - }
52 -
53 - const handleCopy = () => copy(serializeDataForCopy(((state: any): Object)));
51 + entries.sort(alphaSortEntries);
52 + const handleCopy = withPermissionsCheck(
53 + {permissions: ['clipboardWrite']},
54 + () => copy(serializeDataForCopy(state)),
55 + );
56
57 return (
58 <div>
packages/react-devtools-shared/src/devtools/views/Components/NativeStyleEditor/StyleEditor.js
+5 -1
@@ -20,6 +20,7 @@ import {serializeDataForCopy} from '../../utils';
20 import AutoSizeInput from './AutoSizeInput';
21 import styles from './StyleEditor.css';
22 import {sanitizeForParse} from '../../../utils';
23 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
24
25 import type {Style} from './types';
26
@@ -62,7 +63,10 @@ export default function StyleEditor({id, style}: Props): React.Node {
63
64 const keys = useMemo(() => Array.from(Object.keys(style)), [style]);
65
65 - const handleCopy = () => copy(serializeDataForCopy(style));
66 + const handleCopy = withPermissionsCheck(
67 + {permissions: ['clipboardWrite']},
68 + () => copy(serializeDataForCopy(style)),
69 + );
70
71 return (
72 <div className={styles.StyleEditor}>
packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
+5 -1
@@ -21,6 +21,7 @@ import {
21 } from 'react-devtools-timeline/src/utils/formatting';
22 import {stackToComponentSources} from 'react-devtools-shared/src/devtools/utils';
23 import {copy} from 'clipboard-js';
24 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
25
26 import styles from './SidebarEventInfo.css';
27
@@ -53,7 +54,10 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
54 <div className={styles.Row}>
55 <label className={styles.Label}>Rendered by</label>
56 <Button
56 - onClick={() => copy(componentStack)}
57 + onClick={withPermissionsCheck(
58 + {permissions: ['clipboardWrite']},
59 + () => copy(componentStack),
60 + )}
61 title="Copy component stack to clipboard">
62 <ButtonIcon type="copy" />
63 </Button>
packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js
+9 -2
@@ -16,6 +16,7 @@ import Button from './Button';
16 import ButtonIcon from './ButtonIcon';
17 import {copy} from 'clipboard-js';
18 import styles from './UnsupportedBridgeProtocolDialog.css';
19 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
20
21 import type {BridgeProtocol} from 'react-devtools-shared/src/bridge';
22
@@ -82,7 +83,10 @@ function DialogContent({
83 <pre className={styles.NpmCommand}>
84 {upgradeInstructions}
85 <Button
85 - onClick={() => copy(upgradeInstructions)}
86 + onClick={withPermissionsCheck(
87 + {permissions: ['clipboardWrite']},
88 + () => copy(upgradeInstructions),
89 + )}
90 title="Copy upgrade command to clipboard">
91 <ButtonIcon type="copy" />
92 </Button>
@@ -99,7 +103,10 @@ function DialogContent({
103 <pre className={styles.NpmCommand}>
104 {downgradeInstructions}
105 <Button
102 - onClick={() => copy(downgradeInstructions)}
106 + onClick={withPermissionsCheck(
107 + {permissions: ['clipboardWrite']},
108 + () => copy(downgradeInstructions),
109 + )}
110 title="Copy downgrade command to clipboard">
111 <ButtonIcon type="copy" />
112 </Button>
packages/react-devtools-shared/src/errors/PermissionNotGrantedError.js new
+21
@@ -0,0 +1,21 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export class PermissionNotGrantedError extends Error {
11 + constructor() {
12 + super("User didn't grant the required permission to perform an action");
13 +
14 + // Maintains proper stack trace for where our error was thrown (only available on V8)
15 + if (Error.captureStackTrace) {
16 + Error.captureStackTrace(this, PermissionNotGrantedError);
17 + }
18 +
19 + this.name = 'PermissionNotGrantedError';
20 + }
21 +}
packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js new
+35
@@ -0,0 +1,35 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {PermissionNotGrantedError} from 'react-devtools-shared/src/errors/PermissionNotGrantedError';
11 +
12 +type SupportedPermission = 'clipboardWrite';
13 +type Permissions = Array<SupportedPermission>;
14 +type PermissionsOptions = {permissions: Permissions};
15 +
16 +// browser.permissions is not available for DevTools pages in Firefox
17 +// https://bugzilla.mozilla.org/show_bug.cgi?id=1796933
18 +// We are going to assume that requested permissions are not optional.
19 +export function withPermissionsCheck<T: (...$ReadOnlyArray<empty>) => mixed>(
20 + options: PermissionsOptions,
21 + callback: T,
22 +): T | (() => Promise<ReturnType<T>>) {
23 + if (!__IS_CHROME__ && !__IS_EDGE__) {
24 + return callback;
25 + } else {
26 + return async () => {
27 + const granted = await chrome.permissions.request(options);
28 + if (granted) {
29 + return callback();
30 + }
31 +
32 + return Promise.reject(new PermissionNotGrantedError());
33 + };
34 + }
35 +}
packages/react-devtools-timeline/src/CanvasPageContextMenu.js
+9 -3
@@ -13,6 +13,7 @@ import {copy} from 'clipboard-js';
13 import prettyMilliseconds from 'pretty-ms';
14
15 import ContextMenuContainer from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuContainer';
16 +import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
17
18 import {getBatchRange} from './utils/getBatchRange';
19 import {moveStateToRange} from './view-base/utils/scrollState';
@@ -138,7 +139,9 @@ export default function CanvasPageContextMenu({
139 content: 'Zoom to batch',
140 },
141 {
141 - onClick: () => copySummary(timelineData, measure),
142 + onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
143 + copySummary(timelineData, measure),
144 + ),
145 content: 'Copy summary',
146 },
147 );
@@ -147,16 +150,19 @@ export default function CanvasPageContextMenu({
150 if (flamechartStackFrame != null) {
151 items.push(
152 {
150 - onClick: () => copy(flamechartStackFrame.scriptUrl),
153 + onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
154 + copy(flamechartStackFrame.scriptUrl),
155 + ),
156 content: 'Copy file path',
157 },
158 {
154 - onClick: () =>
159 + onClick: withPermissionsCheck({permissions: ['clipboardWrite']}, () =>
160 copy(
161 `line ${flamechartStackFrame.locationLine ?? ''}, column ${
162 flamechartStackFrame.locationColumn ?? ''
163 }`,
164 ),
165 + ),
166 content: 'Copy location',
167 },
168 );
scripts/flow/react-devtools.js
+2
@@ -16,3 +16,5 @@ declare const __IS_FIREFOX__: boolean;
16 declare const __IS_CHROME__: boolean;
17 declare const __IS_EDGE__: boolean;
18 declare const __IS_NATIVE__: boolean;
19 +
20 +declare const chrome: any;