Skip to content

Commit

Permalink
chore[DevTools]: make clipboardWrite optional for chromium (#32262)
Browse files Browse the repository at this point in the history
Addresses #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.
  • Loading branch information
hoxyq authored Jan 30, 2025
1 parent 55b54b0 commit 221f300
Show file tree
Hide file tree
Showing 16 changed files with 136 additions and 33 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -500,13 +500,15 @@ 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',
__IS_FIREFOX__: 'readonly',
__IS_EDGE__: 'readonly',
__IS_NATIVE__: 'readonly',
__IS_INTERNAL_VERSION__: 'readonly',
chrome: 'readonly',
},
},
{
Expand Down
4 changes: 3 additions & 1 deletion packages/react-devtools-extensions/chrome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"permissions": [
"scripting",
"storage",
"tabs",
"tabs"
],
"optional_permissions": [
"clipboardWrite"
],
"host_permissions": [
Expand Down
4 changes: 3 additions & 1 deletion packages/react-devtools-extensions/edge/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"permissions": [
"scripting",
"storage",
"tabs",
"tabs"
],
"optional_permissions": [
"clipboardWrite"
],
"host_permissions": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type {Node as ReactNode} from 'react';

export type ContextMenuItem = {
onClick: () => void,
onClick: () => mixed,
content: ReactNode,
};

Expand Down
3 changes: 2 additions & 1 deletion packages/react-devtools-shared/src/devtools/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1494,7 +1495,7 @@ export default class Store extends EventEmitter<{
};

onSaveToClipboard: (text: string) => void = text => {
copy(text);
withPermissionsCheck({permissions: ['clipboardWrite']}, () => copy(text))();
};

onBackendInitialized: () => void = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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">
Expand All @@ -76,7 +79,7 @@ export default function InspectedElementPropsTree({
)}
</div>
{!isEmpty &&
(entries: any).map(([name, value]) => (
entries.map(([name, value]) => (
<KeyValue
key={name}
alphaSort={true}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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">
Expand All @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
}
}
Original file line number Diff line number Diff line change
@@ -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());
};
}
}
12 changes: 9 additions & 3 deletions packages/react-devtools-timeline/src/CanvasPageContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
},
);
Expand All @@ -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',
},
);
Expand Down
2 changes: 2 additions & 0 deletions scripts/flow/react-devtools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

0 comments on commit 221f300

Please sign in to comment.