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;